feat: ltm edge fact viewer

This commit is contained in:
Soulter
2025-06-02 09:29:58 +08:00
parent bc9fe82860
commit 58a815dd6b
+436 -15
View File
@@ -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>