feat: ltm edge fact viewer
This commit is contained in:
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<div id="long-term-memory" class="flex-grow-1" style="display: flex; flex-direction: row; ">
|
||||
<!-- <div id="graph-container"
|
||||
<div id="graph-container"
|
||||
style="flex-grow: 1; width: 100%; border: 1px solid #eee; border-radius: 8px; max-height: calc(100% - 40px);">
|
||||
</div> -->
|
||||
<div id="graph-container-nonono"
|
||||
</div>
|
||||
<!-- <div id="graph-container-nonono"
|
||||
style="display: flex; justify-content: center; align-items: center; width: 100%; font-weight: 1000; font-size: 24px;">
|
||||
加速开发中...
|
||||
</div>
|
||||
</div> -->
|
||||
<div id="graph-control-panel"
|
||||
style="min-width: 450px; border: 1px solid #eee; border-radius: 8px; padding: 16px; padding-bottom: 0px; margin-left: 16px; max-height: calc(100% - 40px);">
|
||||
<div>
|
||||
@@ -153,6 +153,99 @@
|
||||
</div>
|
||||
</v-card>
|
||||
</div>
|
||||
|
||||
<v-dialog v-model="showFactDialog" max-width="550" scrollable>
|
||||
<v-card class="fact-detail-card">
|
||||
<v-card-title class="d-flex align-center bg-primary text-white px-4 py-3">
|
||||
<v-icon class="mr-2" color="white">mdi-memory</v-icon>
|
||||
记忆事实
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn icon variant="text" color="white" @click="showFactDialog = false">
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text class="px-4 pt-4 pb-0">
|
||||
<template v-if="selectedEdgeFactData">
|
||||
<v-alert color="primary" variant="tonal" density="compact" class="mb-4">
|
||||
<div class="text-body-1 font-weight-medium">{{ selectedEdgeFactData.text }}</div>
|
||||
</v-alert>
|
||||
|
||||
<v-row>
|
||||
<v-col cols="6">
|
||||
<div class="d-flex align-center mb-2">
|
||||
<v-icon size="small" color="primary" class="mr-2">mdi-identifier</v-icon>
|
||||
<div class="text-subtitle-2">ID</div>
|
||||
</div>
|
||||
<div class="text-body-2 text-grey pa-1">{{ selectedEdgeFactData.id }}</div>
|
||||
</v-col>
|
||||
<v-col cols="6">
|
||||
<div class="d-flex align-center mb-2">
|
||||
<v-icon size="small" color="primary" class="mr-2">mdi-file-document-outline</v-icon>
|
||||
<div class="text-subtitle-2">文档ID</div>
|
||||
</div>
|
||||
<div class="text-body-2 text-grey pa-1">{{ selectedEdgeFactData.doc_id }}</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- 时间信息 -->
|
||||
<v-row class="mt-2">
|
||||
<v-col cols="6">
|
||||
<div class="d-flex align-center mb-2">
|
||||
<v-icon size="small" color="primary" class="mr-2">mdi-calendar-plus</v-icon>
|
||||
<div class="text-subtitle-2">创建时间</div>
|
||||
</div>
|
||||
<div class="text-body-2 text-grey pa-1">{{ formatTime(selectedEdgeFactData.created_at) }}</div>
|
||||
</v-col>
|
||||
<v-col cols="6">
|
||||
<div class="d-flex align-center mb-2">
|
||||
<v-icon size="small" color="primary" class="mr-2">mdi-calendar-edit</v-icon>
|
||||
<div class="text-subtitle-2">更新时间</div>
|
||||
</div>
|
||||
<div class="text-body-2 text-grey pa-1">{{ formatTime(selectedEdgeFactData.updated_at) }}</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- 改进元数据展示,解析为键值对 -->
|
||||
<div v-if="parsedMetadata && Object.keys(parsedMetadata).length > 0" class="mt-4">
|
||||
<div class="d-flex align-center mb-2">
|
||||
<v-icon size="small" color="primary" class="mr-2">mdi-database-cog</v-icon>
|
||||
<div class="text-subtitle-2">元数据</div>
|
||||
</div>
|
||||
<v-card variant="outlined" class="metadata-table">
|
||||
<v-table density="compact" hover>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="text-left">键</th>
|
||||
<th class="text-left">值</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(value, key) in parsedMetadata" :key="key">
|
||||
<td class="font-weight-medium">{{ key }}</td>
|
||||
<td>{{ formatMetadataValue(value) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
</v-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="text-center py-6">
|
||||
<v-progress-circular indeterminate color="primary" size="50" width="5"></v-progress-circular>
|
||||
<div class="mt-3 text-body-1">加载中...</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
|
||||
<v-divider v-if="selectedEdgeFactData"></v-divider>
|
||||
|
||||
<v-card-actions class="pa-4" v-if="selectedEdgeFactData">
|
||||
<v-btn block color="primary" variant="tonal" @click="showFactDialog = false">
|
||||
关闭
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -199,6 +292,16 @@ export default {
|
||||
isSearching: false,
|
||||
searchResults: [],
|
||||
hasSearched: false,
|
||||
|
||||
// 添加边点击相关数据
|
||||
selectedEdge: null,
|
||||
selectedEdgeFactId: null,
|
||||
selectedEdgeFactData: null,
|
||||
showFactDialog: false,
|
||||
isLoadingFactData: false,
|
||||
|
||||
// 改进元数据展示
|
||||
parsedMetadata: null,
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
@@ -393,6 +496,83 @@ export default {
|
||||
this.ltmGetGraph();
|
||||
},
|
||||
|
||||
// 添加获取Fact详情的方法
|
||||
getFactDetails(factId) {
|
||||
if (!factId) return;
|
||||
|
||||
this.isLoadingFactData = true;
|
||||
this.selectedEdgeFactData = null;
|
||||
this.parsedMetadata = null;
|
||||
|
||||
axios.get('/api/plug/alkaid/ltm/graph/fact', {
|
||||
params: { fact_id: factId }
|
||||
})
|
||||
.then(response => {
|
||||
if (response.data.status === 'ok') {
|
||||
this.selectedEdgeFactData = response.data.data;
|
||||
// 解析元数据
|
||||
this.parsedMetadata = this.parseMetadata(this.selectedEdgeFactData.metadata);
|
||||
this.showFactDialog = true;
|
||||
} else {
|
||||
this.$toast.error('获取记忆详情失败: ' + response.data.message);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('获取记忆详情失败:', error);
|
||||
this.$toast.error('获取记忆详情失败: ' + (error.response?.data?.message || error.message));
|
||||
})
|
||||
.finally(() => {
|
||||
this.isLoadingFactData = false;
|
||||
});
|
||||
},
|
||||
|
||||
// 添加元数据解析方法
|
||||
parseMetadata(metadata) {
|
||||
if (!metadata) return null;
|
||||
|
||||
try {
|
||||
// 如果是字符串,尝试解析JSON
|
||||
if (typeof metadata === 'string') {
|
||||
try {
|
||||
return JSON.parse(metadata);
|
||||
} catch (e) {
|
||||
return { value: metadata }; // 如果无法解析为JSON,则作为单个值返回
|
||||
}
|
||||
}
|
||||
|
||||
// 如果已经是对象,直接返回
|
||||
if (typeof metadata === 'object') {
|
||||
return metadata;
|
||||
}
|
||||
|
||||
return { value: String(metadata) };
|
||||
} catch (e) {
|
||||
console.error('解析元数据出错:', e);
|
||||
return { error: '无法解析元数据' };
|
||||
}
|
||||
},
|
||||
|
||||
// 格式化元数据值
|
||||
formatMetadataValue(value) {
|
||||
if (value === null || value === undefined) return '无';
|
||||
|
||||
if (typeof value === 'object') {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
return String(value);
|
||||
},
|
||||
|
||||
// 格式化时间戳的辅助方法
|
||||
formatTime(timestamp) {
|
||||
if (!timestamp) return '未知';
|
||||
try {
|
||||
return new Date(timestamp).toLocaleString();
|
||||
} catch (e) {
|
||||
return timestamp;
|
||||
}
|
||||
},
|
||||
|
||||
initD3Graph() {
|
||||
const container = document.getElementById("graph-container");
|
||||
if (!container) return;
|
||||
@@ -431,6 +611,8 @@ export default {
|
||||
if (!this.svg || !this.simulation) return;
|
||||
const g = this.g;
|
||||
g.selectAll("*").remove();
|
||||
|
||||
// 添加箭头定义
|
||||
g.append("defs").append("marker")
|
||||
.attr("id", "arrowhead")
|
||||
.attr("viewBox", "0 -5 10 10")
|
||||
@@ -442,13 +624,22 @@ export default {
|
||||
.append("path")
|
||||
.attr("d", "M0,-5L10,0L0,5")
|
||||
.attr("fill", "#999");
|
||||
|
||||
// 预处理边数据,标识和处理重复边
|
||||
const linkGroups = this.identifyParallelLinks(this.links);
|
||||
|
||||
// 使用路径替代直线来绘制边,以便支持曲线
|
||||
const link = g.append("g")
|
||||
.selectAll("line")
|
||||
.selectAll("path")
|
||||
.data(this.links)
|
||||
.join("line")
|
||||
.join("path")
|
||||
.attr("stroke", d => d.color)
|
||||
.attr("stroke-width", 1.5)
|
||||
.attr("marker-end", "url(#arrowhead)");
|
||||
.attr("fill", "none")
|
||||
.attr("marker-end", "url(#arrowhead)")
|
||||
.style("cursor", "pointer");
|
||||
|
||||
// 边标签需要相应调整位置
|
||||
const edgeLabels = g.append("g")
|
||||
.selectAll("text")
|
||||
.data(this.links)
|
||||
@@ -457,7 +648,22 @@ export default {
|
||||
.attr("font-size", "8px")
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", "#666")
|
||||
.attr("dy", -5);
|
||||
.style("cursor", "pointer")
|
||||
.on("click", (event, d) => {
|
||||
event.stopPropagation();
|
||||
|
||||
// 检查边数据中是否有fact_id
|
||||
const factId = d.originalData?.fact_id;
|
||||
if (factId) {
|
||||
this.selectedEdge = d;
|
||||
this.selectedEdgeFactId = factId;
|
||||
this.getFactDetails(factId);
|
||||
} else {
|
||||
this.$toast.info('该关系没有关联的记忆数据');
|
||||
}
|
||||
});
|
||||
|
||||
// 节点绘制部分保持不变
|
||||
const node = g.append("g")
|
||||
.selectAll("circle")
|
||||
.data(this.nodes)
|
||||
@@ -466,6 +672,7 @@ export default {
|
||||
.attr("fill", d => d.color)
|
||||
.style("cursor", "pointer")
|
||||
.call(this.dragBehavior());
|
||||
|
||||
const nodeLabels = g.append("g")
|
||||
.selectAll("text")
|
||||
.data(this.nodes)
|
||||
@@ -475,27 +682,33 @@ export default {
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", "#333")
|
||||
.attr("dy", -12);
|
||||
|
||||
node.on("click", (event, d) => {
|
||||
event.stopPropagation();
|
||||
this.selectedNode = d.originalData;
|
||||
});
|
||||
|
||||
// 给SVG添加全局点击事件,用于关闭气泡
|
||||
this.svg.on("click", () => {
|
||||
this.selectedNode = null;
|
||||
});
|
||||
|
||||
this.simulation
|
||||
.nodes(this.nodes)
|
||||
.on("tick", () => {
|
||||
link
|
||||
.attr("x1", d => d.source.x)
|
||||
.attr("y1", d => d.source.y)
|
||||
.attr("x2", d => d.target.x)
|
||||
.attr("y2", d => d.target.y);
|
||||
// 更新边的路径
|
||||
link.attr("d", d => this.generateLinkPath(d));
|
||||
|
||||
// 更新边标签位置
|
||||
edgeLabels
|
||||
.attr("x", d => (d.source.x + d.target.x) / 2)
|
||||
.attr("y", d => (d.source.y + d.target.y) / 2);
|
||||
.attr("x", d => this.getLinkLabelX(d))
|
||||
.attr("y", d => this.getLinkLabelY(d));
|
||||
|
||||
// 更新节点位置
|
||||
node
|
||||
.attr("cx", d => d.x)
|
||||
.attr("cy", d => d.y);
|
||||
|
||||
nodeLabels
|
||||
.attr("x", d => d.x)
|
||||
.attr("y", d => d.y);
|
||||
@@ -506,6 +719,175 @@ export default {
|
||||
|
||||
this.simulation.alpha(1).restart();
|
||||
},
|
||||
|
||||
// 识别并标记平行边(连接相同两个节点的多条边)
|
||||
identifyParallelLinks(links) {
|
||||
// 创建一个映射来存储连接相同节点对的边
|
||||
const linkMap = new Map();
|
||||
|
||||
// 遍历所有边,按照起点和终点进行分组
|
||||
links.forEach(link => {
|
||||
// 创建边的键,确保无论边的方向如何,同一对节点生成的键都相同
|
||||
const sourceId = typeof link.source === 'object' ? link.source.id : link.source;
|
||||
const targetId = typeof link.target === 'object' ? link.target.id : link.target;
|
||||
|
||||
const forwardKey = `${sourceId}-${targetId}`;
|
||||
const reverseKey = `${targetId}-${sourceId}`;
|
||||
|
||||
// 判断是从source到target的边还是反向边
|
||||
const isForwardLink = sourceId < targetId;
|
||||
const key = isForwardLink ? forwardKey : reverseKey;
|
||||
|
||||
// 使用方向信息
|
||||
if (!linkMap.has(key)) {
|
||||
linkMap.set(key, []);
|
||||
}
|
||||
|
||||
// 存储边和其方向
|
||||
linkMap.get(key).push({
|
||||
link,
|
||||
isForward: isForwardLink
|
||||
});
|
||||
});
|
||||
|
||||
// 处理每一组平行边,为它们分配曲率
|
||||
linkMap.forEach((parallels, key) => {
|
||||
if (parallels.length > 1) {
|
||||
// 有多条平行边,分配不同曲率
|
||||
parallels.forEach((item, index) => {
|
||||
// 根据边的数量计算适当的曲率
|
||||
const totalLinks = parallels.length;
|
||||
// 基础曲率,可根据边数调整
|
||||
const baseCurvature = 0.45;
|
||||
// 根据边的索引计算曲率:中间的边较直,两侧的边较弯
|
||||
let curvature;
|
||||
|
||||
if (totalLinks % 2 === 1) {
|
||||
// 奇数条边,中间的边直线,其他边弯曲
|
||||
const middleIndex = Math.floor(totalLinks / 2);
|
||||
if (index === middleIndex) {
|
||||
curvature = 0; // 中间的边为直线
|
||||
} else {
|
||||
// 到中间边的距离决定曲率大小
|
||||
const distance = Math.abs(index - middleIndex);
|
||||
const direction = index < middleIndex ? -1 : 1;
|
||||
curvature = direction * baseCurvature * distance;
|
||||
}
|
||||
} else {
|
||||
// 偶数条边,所有边都弯曲
|
||||
const middleIndex = totalLinks / 2 - 0.5;
|
||||
const distance = Math.abs(index - middleIndex);
|
||||
const direction = index < middleIndex ? -1 : 1;
|
||||
curvature = direction * baseCurvature * distance;
|
||||
}
|
||||
|
||||
// 如果是反向边,翻转曲率方向
|
||||
if (!item.isForward) {
|
||||
curvature = -curvature;
|
||||
}
|
||||
|
||||
// 存储曲率值到边对象
|
||||
item.link.curvature = curvature;
|
||||
});
|
||||
} else {
|
||||
// 只有一条边,不需要弯曲
|
||||
parallels[0].link.curvature = 0;
|
||||
}
|
||||
});
|
||||
|
||||
return linkMap;
|
||||
},
|
||||
|
||||
// 根据曲率生成边的路径
|
||||
generateLinkPath(d) {
|
||||
// 确保source和target是对象
|
||||
const source = typeof d.source === 'object' ? d.source : this.nodes.find(n => n.id === d.source);
|
||||
const target = typeof d.target === 'object' ? d.target : this.nodes.find(n => n.id === d.target);
|
||||
|
||||
if (!source || !target) return '';
|
||||
|
||||
// 如果是直线(无曲率)
|
||||
if (!d.curvature || d.curvature === 0) {
|
||||
return `M${source.x},${source.y}L${target.x},${target.y}`;
|
||||
}
|
||||
|
||||
// 计算曲线的控制点
|
||||
const dx = target.x - source.x;
|
||||
const dy = target.y - source.y;
|
||||
const dr = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
// 控制点偏移距离,由曲率决定
|
||||
const offset = dr * d.curvature;
|
||||
|
||||
// 计算中点
|
||||
const midX = (source.x + target.x) / 2;
|
||||
const midY = (source.y + target.y) / 2;
|
||||
|
||||
// 计算垂直于连线的方向向量
|
||||
const nx = -dy / dr;
|
||||
const ny = dx / dr;
|
||||
|
||||
// 计算控制点坐标
|
||||
const cpx = midX + offset * nx;
|
||||
const cpy = midY + offset * ny;
|
||||
|
||||
// 创建二次贝塞尔曲线路径
|
||||
return `M${source.x},${source.y} Q${cpx},${cpy} ${target.x},${target.y}`;
|
||||
},
|
||||
|
||||
// 新增方法:计算边标签的X坐标
|
||||
getLinkLabelX(d) {
|
||||
const source = typeof d.source === 'object' ? d.source : this.nodes.find(n => n.id === d.source);
|
||||
const target = typeof d.target === 'object' ? d.target : this.nodes.find(n => n.id === d.target);
|
||||
|
||||
if (!source || !target) return 0;
|
||||
|
||||
// 如果是直线
|
||||
if (!d.curvature || d.curvature === 0) {
|
||||
return (source.x + target.x) / 2;
|
||||
}
|
||||
|
||||
// 计算曲线上的点
|
||||
const dx = target.x - source.x;
|
||||
const dy = target.y - source.y;
|
||||
const dr = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
// 中点
|
||||
const midX = (source.x + target.x) / 2;
|
||||
|
||||
// 垂直向量
|
||||
const nx = -dy / dr;
|
||||
|
||||
// 曲线路径上的点,使用曲率进行调整
|
||||
return midX + d.curvature * dr * nx * 0.5;
|
||||
},
|
||||
|
||||
// 新增方法:计算边标签的Y坐标
|
||||
getLinkLabelY(d) {
|
||||
const source = typeof d.source === 'object' ? d.source : this.nodes.find(n => n.id === d.source);
|
||||
const target = typeof d.target === 'object' ? d.target : this.nodes.find(n => n.id === d.target);
|
||||
|
||||
if (!source || !target) return 0;
|
||||
|
||||
// 如果是直线
|
||||
if (!d.curvature || d.curvature === 0) {
|
||||
return (source.y + target.y) / 2;
|
||||
}
|
||||
|
||||
// 计算曲线上的点
|
||||
const dx = target.x - source.x;
|
||||
const dy = target.y - source.y;
|
||||
const dr = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
// 中点
|
||||
const midY = (source.y + target.y) / 2;
|
||||
|
||||
// 垂直向量
|
||||
const ny = dx / dr;
|
||||
|
||||
// 曲线路径上的点,使用曲率进行调整
|
||||
return midY + d.curvature * dr * ny * 0.5;
|
||||
},
|
||||
|
||||
dragBehavior() {
|
||||
return d3.drag()
|
||||
@@ -578,4 +960,43 @@ export default {
|
||||
background-color: #f2f6f9;
|
||||
}
|
||||
|
||||
/* 为连接线添加交互样式 */
|
||||
#graph-container line {
|
||||
transition: stroke-width 0.2s;
|
||||
}
|
||||
|
||||
#graph-container line:hover {
|
||||
stroke-width: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 添加美化详情卡片的样式 */
|
||||
.fact-detail-card :deep(.v-card-title) {
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
.fact-detail-card :deep(.metadata-table) {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fact-detail-card :deep(.v-table) {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.fact-detail-card :deep(.v-table th) {
|
||||
color: var(--v-primary-base);
|
||||
font-weight: bold;
|
||||
background-color: rgba(var(--v-theme-primary), 0.05);
|
||||
}
|
||||
|
||||
.fact-detail-card :deep(pre) {
|
||||
background-color: #f5f5f5;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
max-height: 150px;
|
||||
overflow: auto;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user