From 81aaef8e44d96d249eb0cf9d82d6a515149a3de9 Mon Sep 17 00:00:00 2001 From: Wenjie Zhang Date: Wed, 3 Sep 2025 03:00:25 +0800 Subject: [PATCH] =?UTF-8?q?feat(GraphView):=20=E6=94=B9=E8=BF=9B=E5=9B=BE?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=BA=93=E8=A7=86=E5=9B=BE=E7=9A=84=E4=BA=A4?= =?UTF-8?q?=E4=BA=92=E5=92=8C=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 重构图数据库视图布局,优化搜索和节点加载功能 - 添加节点点击交互,突出显示相关节点和边 - 使用标签展示图谱信息,替换原有描述文本 - 更新节点和边的样式,提升可视化效果 --- docs/changelog/update.md | 2 +- web/src/components/AgentChatComponent.vue | 8 +- web/src/views/GraphView.vue | 312 ++++++++++++++++------ 3 files changed, 240 insertions(+), 82 deletions(-) diff --git a/docs/changelog/update.md b/docs/changelog/update.md index 34621064..d5aaa93e 100644 --- a/docs/changelog/update.md +++ b/docs/changelog/update.md @@ -3,7 +3,7 @@ 💭 **Features Todo** - [ ] LangGraph 升级到 0.6+ 版本,并适配新特性,如 context 等,添加 MCP 工具的支持。 - [x] 支持动态工具配置的同时,将 Configuration替换为 Context 后能够正常使用 - +- [ ] 使用更好的知识图谱检索方法 - [ ] 使用其他的聊天记录管理方法,解决两个问题,一个是上下文长度过长,一个是上下文的类型变得更加丰富,比如多模态等等。(现在是基于 LangGraph 的 Memory 实现的,v0.2.3 版本实现),暂定使用 [mem0](github.com/mem0ai/mem0) 来实现。但是目前了解下来,还不是我想要的那种方案。可能会基于这个实现一个 ThreadConvManager 这个类。 - [ ] 添加对于上传文件的支持:这里的复杂的地方就在于如何和历史记录结合在一起(v0.2.3 版本实现,放在记忆管理后面) diff --git a/web/src/components/AgentChatComponent.vue b/web/src/components/AgentChatComponent.vue index 33a14c0e..afa1b1f8 100644 --- a/web/src/components/AgentChatComponent.vue +++ b/web/src/components/AgentChatComponent.vue @@ -180,7 +180,7 @@ const threadMessages = ref({}); const uiState = reactive({ ...props.state, debug_mode: computed(() => props.state.debug_mode ?? false), - isSidebarOpen: localStorage.getItem('chat_sidebar_open') === 'true', + isSidebarOpen: localStorage.getItem('chat_sidebar_open', 'true') === 'true', isInitialRender: true, showRenameButton: false, containerWidth: 0, @@ -213,7 +213,7 @@ const currentThreadState = computed(() => { const onGoingConvMessages = computed(() => { const threadState = currentThreadState.value; if (!threadState || !threadState.onGoingConv) return []; - + const msgs = Object.values(threadState.onGoingConv.msgChunks).map(MessageProcessor.mergeMessageChunk); return msgs.length > 0 ? MessageProcessor.convertToolResultToMessages(msgs).filter(msg => msg.type !== 'tool') @@ -335,9 +335,9 @@ const resetOnGoingConv = (threadId = null) => { const _processStreamChunk = (chunk, threadId) => { const { status, msg, request_id, message } = chunk; const threadState = getThreadState(threadId); - + if (!threadState) return; - + switch (status) { case 'init': threadState.onGoingConv.msgChunks[request_id] = [msg]; diff --git a/web/src/views/GraphView.vue b/web/src/views/GraphView.vue index 5cedd5f7..7da59ee4 100644 --- a/web/src/views/GraphView.vue +++ b/web/src/views/GraphView.vue @@ -11,7 +11,6 @@
-
-
- - - 检索实体 - -
-
- - 获取节点 +
+
+
+
+ + + +
+
+ + 说明 + + + + +
+
+
+
-
- { const nodeSize = Math.min(15 + degree * 5, 50); return { - id: node.id, + id: String(node.id), data: { label: node.name, degree: degree, // 存储度数信息 }, } }), - edges: graphData.edges.map(edge => { + edges: graphData.edges.map((edge, index) => { return { + id: `edge-${index}`, source: edge.source_id, target: edge.target_id, data: { @@ -351,25 +364,149 @@ const initGraph = () => { // 使用节点度数来决定大小 size: (d) => { const degree = d.data.degree || 0; - // 基础大小为15,每个连接增加5的大小,最小为15,最大为50 return Math.min(15 + degree * 5, 50); }, + // 现代化默认样式 + fillOpacity: 0.8, + opacity: 0.8, + stroke: '#ffffff', + lineWidth: 1.5, + shadowColor: '#94a3b8', + shadowBlur: 4, + 'label-text-fill': '#334155', }, palette: { field: 'label', - color: 'tableau', + color: [ + '#60a5fa', // sky-400 + '#34d399', // emerald-400 + '#f59e0b', // amber-500 + '#f472b6', // pink-400 + '#22d3ee', // cyan-400 + '#a78bfa', // violet-400 + '#f97316', // orange-500 + '#4ade80', // green-400 + '#f43f5e', // rose-500 + '#2dd4bf', // teal-400 + ], + }, + state: { + hidden: { + opacity: 0.15, + 'label-text-opacity': 0, + }, + focus: { + opacity: 1, + stroke: '#2563eb', // blue-600 + lineWidth: 2.5, + shadowColor: '#60a5fa', + shadowBlur: 16, + }, }, }, edge: { type: 'line', style: { labelText: (d) => d.data.label, - labelBackground: '#fff', + labelBackground: '#ffffff', + stroke: '#94a3b8', + opacity: 0.6, + lineWidth: 1.2, endArrow: true, + 'label-text-fill': '#334155', + }, + state: { + hidden: { + opacity: 0.15, + 'label-text-opacity': 0, + }, + focus: { + opacity: 0.95, + stroke: '#2563eb', + lineWidth: 2, + 'label-text-opacity': 1, + }, }, }, behaviors: ['drag-element', 'zoom-canvas', 'drag-canvas'], }); + + let activeNodeId = null; + + const getGraphItemIds = () => { + const { nodes, edges } = graphInstance.getData(); + const nodeIds = nodes.map(node => node.id); + const edgeIds = edges.map(edge => edge.id); + return { nodeIds, edgeIds }; + }; + + // 从事件对象中尽可能稳健地获取被点击元素的 id + const getClickedElementId = (e) => { + return e?.id || e?.data?.id || e?.target?.id || null; + }; + + const resetStyles = async () => { + const { nodeIds, edgeIds } = getGraphItemIds(); + const allElementIds = [...nodeIds, ...edgeIds]; + const updates = {}; + allElementIds.forEach(id => { + updates[id] = []; + }); + + if (allElementIds.length > 0) { + await graphInstance.setElementState(updates); + } + activeNodeId = null; + await graphInstance.draw(); + }; + + graphInstance.on('node:click', async (e) => { + const clickedNodeId = getClickedElementId(e); + if (!clickedNodeId) return; + + if (activeNodeId === clickedNodeId) { + await resetStyles(); + return; + } + + activeNodeId = clickedNodeId; + + const { nodeIds: allNodeIds, edgeIds: allEdgeIds } = getGraphItemIds(); + const { edges: allEdges } = graphInstance.getData(); + + const updates = {}; + + // 1. All nodes and edges are hidden by default + allNodeIds.forEach(id => updates[id] = ['hidden']); + allEdgeIds.forEach(id => updates[id] = ['hidden']); + + // 2. Find neighbors and related edges + const neighborSet = new Set(); + const relatedEdgeIds = []; + allEdges.forEach((edge) => { + if (edge.source === clickedNodeId) { + neighborSet.add(edge.target); + relatedEdgeIds.push(edge.id); + } else if (edge.target === clickedNodeId) { + neighborSet.add(edge.source); + relatedEdgeIds.push(edge.id); + } + }); + const neighborNodeIds = Array.from(neighborSet); + + // 3. Set 'focus' state for the clicked node, its neighbors, and related edges. + // This will overwrite the 'hidden' state. + updates[clickedNodeId] = ['focus']; + neighborNodeIds.forEach(id => updates[id] = ['focus']); + relatedEdgeIds.forEach(id => updates[id] = ['focus']); + + // 4. Apply all state changes at once + await graphInstance.setElementState(updates); + await graphInstance.draw(); + }); + + // 专门监听画布空白处的点击事件来重置样式 + graphInstance.on('canvas:click', resetStyles); window.addEventListener('resize', renderGraph); } @@ -399,13 +536,19 @@ const graphStatusText = computed(() => { return graphInfo.value?.status === 'open' ? '已连接' : '已关闭'; }); -const graphDescription = computed(() => { - const dbName = graphInfo.value?.graph_name || ''; - const entityCount = graphInfo.value?.entity_count || 0; - const relationCount = graphInfo.value?.relationship_count || 0; - const unindexed = unindexedCount.value > 0 ? `,${unindexedCount.value}个节点未索引` : ''; +// 新增:将图谱信息拆分为多条标签用于展示 +const graphTags = computed(() => { + const tags = []; + const dbName = graphInfo.value?.graph_name; + const entityCount = graphInfo.value?.entity_count; + const relationCount = graphInfo.value?.relationship_count; - return `${dbName} - 共 ${entityCount} 实体,${relationCount} 个关系;${unindexed}`; + if (dbName) tags.push({ key: 'name', text: `图谱 ${dbName}`, type: 'blue' }); + if (typeof entityCount === 'number') tags.push({ key: 'entities', text: `实体 ${graphData.nodes.length} of ${entityCount}`, type: 'success' }); + if (typeof relationCount === 'number') tags.push({ key: 'relations', text: `关系 ${graphData.edges.length} of ${relationCount}`, type: 'purple' }); + if (unindexedCount.value > 0) tags.push({ key: 'unindexed', text: `未索引 ${unindexedCount.value}`, type: 'warning' }); + + return tags; }); // 为未索引节点添加索引 @@ -449,8 +592,14 @@ const openLink = (url) => {