From 58a815dd6b46fc4189c20ae8201260d49d1f2968 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Mon, 2 Jun 2025 09:29:58 +0800 Subject: [PATCH] feat: ltm edge fact viewer --- dashboard/src/views/alkaid/LongTermMemory.vue | 451 +++++++++++++++++- 1 file changed, 436 insertions(+), 15 deletions(-) diff --git a/dashboard/src/views/alkaid/LongTermMemory.vue b/dashboard/src/views/alkaid/LongTermMemory.vue index e534c70d8..762c1f8ac 100644 --- a/dashboard/src/views/alkaid/LongTermMemory.vue +++ b/dashboard/src/views/alkaid/LongTermMemory.vue @@ -1,12 +1,12 @@ @@ -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; +}