refactor(graph): 重构知识图谱组件为GraphCanvas并替换GraphContainer
将原有的GraphContainer组件重构为更灵活的GraphCanvas组件,支持插槽布局和更多配置选项 更新KnowledgeGraphResult和GraphView视图使用新组件 删除旧的GraphContainer组件并更新相关文档
This commit is contained in:
parent
91fa5c903e
commit
2267c8634a
@ -6,8 +6,8 @@
|
||||
- [ ] 使用更好的知识图谱检索方法
|
||||
- [ ] 使用其他的聊天记录管理方法,解决两个问题,一个是上下文长度过长,一个是上下文的类型变得更加丰富,比如多模态等等。(现在是基于 LangGraph 的 Memory 实现的,v0.2.3 版本实现),暂定使用 [mem0](github.com/mem0ai/mem0) 来实现。但是目前了解下来,还不是我想要的那种方案。可能会基于这个实现一个 ThreadConvManager 这个类。
|
||||
- [ ] 添加对于上传文件的支持:这里的复杂的地方就在于如何和历史记录结合在一起(v0.2.3 版本实现,放在记忆管理后面)
|
||||
- [ ] 将现在的GraphContainer相关的代码分离到一个单独的组件中,然后 actions 和 footer 都是作为 slot top/bottom 提供的
|
||||
- [ ] 移除现有的 GraphContainer 然后应用到 AgentView 中。
|
||||
- [x] 将现在的 graphview.vue 文件中 GraphContainer相关的代码分离到一个单独的组件中,然后 actions 和 footer 都是作为 slot top/bottom 提供的
|
||||
- [x] 然后应用到 web/src/components/ToolCallingResult/KnowledgeGraphResult.vue 中,替换现有的 GraphContainer。
|
||||
- [ ] 知识图谱的上传和可视化,支持属性,标签的展示
|
||||
|
||||
🐛**BUGs**
|
||||
@ -28,5 +28,5 @@
|
||||
- [ ] 添加 SQL 读取工具
|
||||
- [ ] 添加绘图工具(这里的绘图是指绘制固定格式的图和表等,暂时没想好如何支持自定义绘图)
|
||||
- [ ] 添加测试脚本,覆盖最常见的功能
|
||||
- [ ] 添加用户日志与用户反馈模块,可以在 AgentView 中查看信息
|
||||
- [ ] 优化对文档信息的检索展示(检索结果页、详情页)
|
||||
- [ ] 集成 LangFuse (观望)添加用户日志与用户反馈模块,可以在 AgentView 中查看信息
|
||||
|
||||
380
web/src/components/GraphCanvas.vue
Normal file
380
web/src/components/GraphCanvas.vue
Normal file
@ -0,0 +1,380 @@
|
||||
<template>
|
||||
<div class="graph-canvas-container" ref="rootEl">
|
||||
<div v-show="graphData.nodes.length > 0" class="graph-canvas" ref="container"></div>
|
||||
<div class="slots">
|
||||
<div v-if="$slots.top" class="overlay top">
|
||||
<slot name="top" />
|
||||
</div>
|
||||
<div class="content">
|
||||
<slot name="content" />
|
||||
</div>
|
||||
<div v-if="$slots.bottom" class="overlay bottom">
|
||||
<slot name="bottom" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Graph } from '@antv/g6'
|
||||
import { onMounted, onUnmounted, ref, watch, nextTick } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
graphData: {
|
||||
type: Object,
|
||||
required: true,
|
||||
default: () => ({ nodes: [], edges: [] })
|
||||
},
|
||||
labelField: { type: String, default: 'name' },
|
||||
autoFit: { type: Boolean, default: true },
|
||||
autoResize: { type: Boolean, default: true },
|
||||
layoutOptions: { type: Object, default: () => ({}) },
|
||||
nodeStyleOptions: { type: Object, default: () => ({}) },
|
||||
edgeStyleOptions: { type: Object, default: () => ({}) },
|
||||
enableFocusNeighbor: { type: Boolean, default: true },
|
||||
sizeByDegree: { type: Boolean, default: true }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['ready', 'node-click', 'edge-click', 'canvas-click', 'data-rendered'])
|
||||
|
||||
const container = ref(null)
|
||||
const rootEl = ref(null)
|
||||
let graphInstance = null
|
||||
let resizeObserver = null
|
||||
let renderTimeout = null
|
||||
let retryCount = 0
|
||||
const MAX_RETRIES = 5
|
||||
|
||||
const defaultLayout = {
|
||||
type: 'd3-force',
|
||||
preventOverlap: true,
|
||||
// 性能友好参数(参考 GraphView.vue)
|
||||
alphaDecay: 0.1,
|
||||
alphaMin: 0.01,
|
||||
velocityDecay: 0.7,
|
||||
iterations: 100,
|
||||
force: {
|
||||
center: { x: 0.5, y: 0.5, strength: 0.1 },
|
||||
charge: { strength: -400, distanceMax: 400 },
|
||||
link: { distance: 100, strength: 0.8 },
|
||||
},
|
||||
collide: { radius: 40, strength: 0.8, iterations: 3 },
|
||||
}
|
||||
|
||||
const paletteColors = [
|
||||
'#60a5fa', '#34d399', '#f59e0b', '#f472b6', '#22d3ee',
|
||||
'#a78bfa', '#f97316', '#4ade80', '#f43f5e', '#2dd4bf',
|
||||
]
|
||||
|
||||
function formatData() {
|
||||
const data = props.graphData || { nodes: [], edges: [] }
|
||||
const degrees = new Map()
|
||||
|
||||
for (const n of data.nodes) {
|
||||
degrees.set(String(n.id), 0)
|
||||
}
|
||||
for (const e of data.edges) {
|
||||
const s = String(e.source_id)
|
||||
const t = String(e.target_id)
|
||||
degrees.set(s, (degrees.get(s) || 0) + 1)
|
||||
degrees.set(t, (degrees.get(t) || 0) + 1)
|
||||
}
|
||||
|
||||
const nodes = (data.nodes || []).map((n) => ({
|
||||
id: String(n.id),
|
||||
data: {
|
||||
label: n[props.labelField] ?? n.name ?? String(n.id),
|
||||
degree: degrees.get(String(n.id)) || 0,
|
||||
},
|
||||
}))
|
||||
|
||||
const edges = (data.edges || []).map((e, idx) => ({
|
||||
id: e.id ? String(e.id) : `edge-${idx}`,
|
||||
source: String(e.source_id),
|
||||
target: String(e.target_id),
|
||||
data: { label: e.type ?? '' },
|
||||
}))
|
||||
|
||||
return { nodes, edges }
|
||||
}
|
||||
|
||||
function initGraph() {
|
||||
if (!container.value) return
|
||||
|
||||
const width = container.value.offsetWidth
|
||||
const height = container.value.offsetHeight
|
||||
|
||||
if (width === 0 && height === 0) {
|
||||
if (retryCount < MAX_RETRIES) {
|
||||
retryCount++
|
||||
clearTimeout(renderTimeout)
|
||||
renderTimeout = setTimeout(initGraph, 200)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
retryCount = 0
|
||||
container.value.innerHTML = ''
|
||||
|
||||
if (graphInstance) {
|
||||
try { graphInstance.destroy() } catch (e) {}
|
||||
graphInstance = null
|
||||
}
|
||||
|
||||
graphInstance = new Graph({
|
||||
container: container.value,
|
||||
width,
|
||||
height,
|
||||
autoFit: props.autoFit,
|
||||
autoResize: props.autoResize,
|
||||
layout: { ...defaultLayout, ...props.layoutOptions },
|
||||
node: {
|
||||
type: 'circle',
|
||||
style: {
|
||||
labelText: (d) => d.data.label,
|
||||
size: (d) => {
|
||||
if (!props.sizeByDegree) return 24
|
||||
const deg = d.data.degree || 0
|
||||
return Math.min(15 + deg * 5, 50)
|
||||
},
|
||||
fillOpacity: 0.8,
|
||||
opacity: 0.8,
|
||||
stroke: '#ffffff',
|
||||
lineWidth: 1.5,
|
||||
shadowColor: '#94a3b8',
|
||||
shadowBlur: 4,
|
||||
'label-text-fill': '#334155',
|
||||
...(props.nodeStyleOptions.style || {}),
|
||||
},
|
||||
palette: props.nodeStyleOptions.palette || {
|
||||
field: 'label',
|
||||
color: paletteColors,
|
||||
},
|
||||
state: props.nodeStyleOptions.state || {
|
||||
hidden: { opacity: 0.15, 'label-text-opacity': 0 },
|
||||
focus: { opacity: 1, stroke: '#2563eb', lineWidth: 2.5, shadowColor: '#60a5fa', shadowBlur: 16 },
|
||||
},
|
||||
},
|
||||
edge: {
|
||||
type: 'line',
|
||||
style: {
|
||||
labelText: (d) => d.data.label,
|
||||
labelBackground: '#ffffff',
|
||||
stroke: '#94a3b8',
|
||||
opacity: 0.6,
|
||||
lineWidth: 1.2,
|
||||
endArrow: true,
|
||||
'label-text-fill': '#334155',
|
||||
...(props.edgeStyleOptions.style || {}),
|
||||
},
|
||||
state: props.edgeStyleOptions.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'],
|
||||
})
|
||||
|
||||
bindEvents()
|
||||
emit('ready', graphInstance)
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
if (!graphInstance) return
|
||||
|
||||
const getIds = () => {
|
||||
const { nodes, edges } = graphInstance.getData()
|
||||
return { nodeIds: nodes.map(n => n.id), edgeIds: edges.map(e => e.id), edges }
|
||||
}
|
||||
|
||||
const getClickedId = (e) => e?.id || e?.data?.id || e?.target?.id || null
|
||||
|
||||
let activeNodeId = null
|
||||
|
||||
const resetStyles = async () => {
|
||||
const { nodeIds, edgeIds } = getIds()
|
||||
const updates = {}
|
||||
nodeIds.forEach(id => updates[id] = [])
|
||||
edgeIds.forEach(id => updates[id] = [])
|
||||
if (nodeIds.length + edgeIds.length > 0) {
|
||||
await graphInstance.setElementState(updates)
|
||||
}
|
||||
activeNodeId = null
|
||||
await graphInstance.draw()
|
||||
}
|
||||
|
||||
if (props.enableFocusNeighbor) {
|
||||
graphInstance.on('node:click', async (e) => {
|
||||
emit('node-click', e)
|
||||
const clickedNodeId = getClickedId(e)
|
||||
if (!clickedNodeId) return
|
||||
|
||||
if (activeNodeId === clickedNodeId) {
|
||||
await resetStyles()
|
||||
return
|
||||
}
|
||||
|
||||
activeNodeId = clickedNodeId
|
||||
const { nodeIds, edgeIds, edges } = getIds()
|
||||
|
||||
const updates = {}
|
||||
nodeIds.forEach(id => updates[id] = ['hidden'])
|
||||
edgeIds.forEach(id => updates[id] = ['hidden'])
|
||||
|
||||
const neighborSet = new Set()
|
||||
const relatedEdgeIds = []
|
||||
edges.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) }
|
||||
})
|
||||
|
||||
updates[clickedNodeId] = ['focus']
|
||||
Array.from(neighborSet).forEach(id => updates[id] = ['focus'])
|
||||
relatedEdgeIds.forEach(id => updates[id] = ['focus'])
|
||||
|
||||
await graphInstance.setElementState(updates)
|
||||
await graphInstance.draw()
|
||||
})
|
||||
|
||||
graphInstance.on('canvas:click', async () => {
|
||||
emit('canvas-click')
|
||||
await resetStyles()
|
||||
})
|
||||
} else {
|
||||
graphInstance.on('node:click', (e) => emit('node-click', e))
|
||||
graphInstance.on('edge:click', (e) => emit('edge-click', e))
|
||||
graphInstance.on('canvas:click', () => emit('canvas-click'))
|
||||
}
|
||||
}
|
||||
|
||||
function setGraphData() {
|
||||
if (!graphInstance) initGraph()
|
||||
if (!graphInstance) return
|
||||
const data = formatData()
|
||||
graphInstance.setData(data)
|
||||
graphInstance.render()
|
||||
setTimeout(() => {
|
||||
emit('data-rendered')
|
||||
}, 100)
|
||||
}
|
||||
|
||||
function renderGraph() {
|
||||
if (!graphInstance) initGraph()
|
||||
setGraphData()
|
||||
}
|
||||
|
||||
function refreshGraph() {
|
||||
if (graphInstance) {
|
||||
try { graphInstance.destroy() } catch (e) {}
|
||||
graphInstance = null
|
||||
}
|
||||
if (container.value) container.value.innerHTML = ''
|
||||
retryCount = 0
|
||||
clearTimeout(renderTimeout)
|
||||
renderTimeout = setTimeout(() => { renderGraph() }, 300)
|
||||
}
|
||||
|
||||
function fitView() { if (graphInstance) try { graphInstance.fitView() } catch (_) {} }
|
||||
function fitCenter() { if (graphInstance) try { graphInstance.fitCenter() } catch (_) {} }
|
||||
function getInstance() { return graphInstance }
|
||||
|
||||
async function focusNode(id) {
|
||||
if (!graphInstance || !props.enableFocusNeighbor) return
|
||||
const { nodes, edges } = graphInstance.getData()
|
||||
const nodeIds = nodes.map(n => n.id)
|
||||
const edgeIds = edges.map(e => e.id)
|
||||
const updates = {}
|
||||
nodeIds.forEach(nid => updates[nid] = ['hidden'])
|
||||
edgeIds.forEach(eid => updates[eid] = ['hidden'])
|
||||
const neighborSet = new Set()
|
||||
const related = []
|
||||
edges.forEach((e) => {
|
||||
if (e.source === id) { neighborSet.add(e.target); related.push(e.id) }
|
||||
else if (e.target === id) { neighborSet.add(e.source); related.push(e.id) }
|
||||
})
|
||||
updates[id] = ['focus']
|
||||
Array.from(neighborSet).forEach(nid => updates[nid] = ['focus'])
|
||||
related.forEach(eid => updates[eid] = ['focus'])
|
||||
await graphInstance.setElementState(updates)
|
||||
await graphInstance.draw()
|
||||
}
|
||||
|
||||
async function clearFocus() {
|
||||
if (!graphInstance) return
|
||||
const { nodes, edges } = graphInstance.getData()
|
||||
const nodeIds = nodes.map(n => n.id)
|
||||
const edgeIds = edges.map(e => e.id)
|
||||
const updates = {}
|
||||
nodeIds.forEach(nid => updates[nid] = [])
|
||||
edgeIds.forEach(eid => updates[eid] = [])
|
||||
await graphInstance.setElementState(updates)
|
||||
await graphInstance.draw()
|
||||
}
|
||||
|
||||
watch(() => props.graphData, () => {
|
||||
clearTimeout(renderTimeout)
|
||||
renderTimeout = setTimeout(() => setGraphData(), 50)
|
||||
}, { deep: true })
|
||||
|
||||
onMounted(() => {
|
||||
// ResizeObserver 监听容器尺寸,自动重渲染
|
||||
if (window.ResizeObserver) {
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
if (!container.value || !graphInstance) return
|
||||
const width = container.value.offsetWidth
|
||||
const height = container.value.offsetHeight
|
||||
graphInstance.changeSize(width, height)
|
||||
})
|
||||
if (container.value) resizeObserver.observe(container.value)
|
||||
}
|
||||
|
||||
clearTimeout(renderTimeout)
|
||||
renderTimeout = setTimeout(() => { renderGraph() }, 300)
|
||||
|
||||
window.addEventListener('resize', refreshGraph)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', refreshGraph)
|
||||
if (resizeObserver && container.value) resizeObserver.unobserve(container.value)
|
||||
clearTimeout(renderTimeout)
|
||||
try { graphInstance?.destroy() } catch (e) {}
|
||||
graphInstance = null
|
||||
})
|
||||
|
||||
// 暴露方法
|
||||
defineExpose({ refreshGraph, fitView, fitCenter, getInstance, focusNode, clearFocus, setData: setGraphData })
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.graph-canvas-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
.slots {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 999;
|
||||
}
|
||||
}
|
||||
.graph-canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.overlay {
|
||||
width: 100%;
|
||||
flex-shrink: 0;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.content {
|
||||
flex: 1;
|
||||
}
|
||||
.top { top: 0; }
|
||||
.bottom { bottom: 0; }
|
||||
</style>
|
||||
@ -1,286 +0,0 @@
|
||||
<template>
|
||||
<div class="graph-container" ref="container"></div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Graph } from "@antv/g6";
|
||||
import { onMounted, watch, ref, onUnmounted } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
graphData: {
|
||||
type: Object,
|
||||
required: true,
|
||||
default: () => ({ nodes: [], edges: [] })
|
||||
}
|
||||
});
|
||||
|
||||
const container = ref(null);
|
||||
let graphInstance = null;
|
||||
let resizeObserver = null;
|
||||
let renderTimeout = null;
|
||||
let retryCount = 0;
|
||||
const MAX_RETRIES = 5;
|
||||
|
||||
const initGraph = () => {
|
||||
// 确保容器存在
|
||||
if (!container.value) {
|
||||
console.warn('GraphContainer: container is not available');
|
||||
return;
|
||||
}
|
||||
|
||||
// 确保容器大小已经稳定
|
||||
const width = container.value.offsetWidth;
|
||||
const height = container.value.offsetHeight;
|
||||
|
||||
// 如果容器尺寸为0,增加重试次数
|
||||
if (width === 0 && height === 0) {
|
||||
if (retryCount < MAX_RETRIES) {
|
||||
retryCount++;
|
||||
// console.log(`GraphContainer: Container size is 0, retrying (${retryCount}/${MAX_RETRIES})`);
|
||||
setTimeout(initGraph, 200);
|
||||
return;
|
||||
} else {
|
||||
console.warn('GraphContainer: Container size remains 0 after maximum retries');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
retryCount = 0; // 重置重试计数
|
||||
|
||||
// 确保容器干净,没有残留元素
|
||||
container.value.innerHTML = '';
|
||||
|
||||
// 确保没有已存在的实例
|
||||
if (graphInstance) {
|
||||
graphInstance.destroy();
|
||||
graphInstance = null;
|
||||
}
|
||||
|
||||
graphInstance = new Graph({
|
||||
container: container.value,
|
||||
width: width,
|
||||
height: height,
|
||||
autoFit: true,
|
||||
autoResize: true,
|
||||
layout: {
|
||||
type: 'd3-force',
|
||||
preventOverlap: true,
|
||||
kr: 30,
|
||||
linkDistance: 200,
|
||||
nodeStrength: -100,
|
||||
collide: {
|
||||
strength: 1.5,
|
||||
radius: 60,
|
||||
},
|
||||
alpha: 0.8,
|
||||
alphaDecay: 0.028,
|
||||
center: [width / 2, height / 2],
|
||||
},
|
||||
node: {
|
||||
type: 'circle',
|
||||
style: {
|
||||
labelText: (d) => d.data.label,
|
||||
size: 70,
|
||||
},
|
||||
palette: {
|
||||
field: 'label',
|
||||
color: 'tableau',
|
||||
},
|
||||
},
|
||||
edge: {
|
||||
type: 'line',
|
||||
style: {
|
||||
labelText: (d) => d.data.label,
|
||||
labelBackground: '#fff',
|
||||
endArrow: true,
|
||||
},
|
||||
},
|
||||
behaviors: ['drag-element', 'zoom-canvas', 'drag-canvas'],
|
||||
});
|
||||
|
||||
// console.log(`GraphContainer: Graph initialized with dimensions ${width}x${height}`);
|
||||
};
|
||||
|
||||
const renderGraph = () => {
|
||||
// 检查数据是否有效
|
||||
if (!props.graphData || !props.graphData.nodes || !props.graphData.edges) {
|
||||
console.warn('GraphContainer: Invalid graphData provided');
|
||||
return;
|
||||
}
|
||||
|
||||
// console.log('GraphContainer: Rendering graph with', props.graphData.nodes.length, 'nodes and', props.graphData.edges.length, 'edges');
|
||||
|
||||
// 如果已有实例且容器大小发生明显变化,则销毁重建
|
||||
if (graphInstance && container.value) {
|
||||
const currentWidth = container.value.offsetWidth;
|
||||
const currentHeight = container.value.offsetHeight;
|
||||
let graphWidth = 0;
|
||||
let graphHeight = 0;
|
||||
|
||||
// 安全地获取图表宽高
|
||||
try {
|
||||
if (typeof graphInstance.getWidth === 'function') {
|
||||
graphWidth = graphInstance.getWidth();
|
||||
} else if (graphInstance.cfg && graphInstance.cfg.width) {
|
||||
graphWidth = graphInstance.cfg.width;
|
||||
}
|
||||
|
||||
if (typeof graphInstance.getHeight === 'function') {
|
||||
graphHeight = graphInstance.getHeight();
|
||||
} else if (graphInstance.cfg && graphInstance.cfg.height) {
|
||||
graphHeight = graphInstance.cfg.height;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error getting graph dimensions:', e);
|
||||
}
|
||||
|
||||
// 如果宽度变化超过50px,重新初始化图表
|
||||
if (Math.abs(currentWidth - graphWidth) > 50 || Math.abs(currentHeight - graphHeight) > 50) {
|
||||
// console.log('GraphContainer: Container size changed significantly, reinitializing graph');
|
||||
graphInstance.destroy();
|
||||
graphInstance = null;
|
||||
|
||||
// 清理容器内容
|
||||
if (container.value) {
|
||||
container.value.innerHTML = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有图表实例,尝试初始化
|
||||
if (!graphInstance) {
|
||||
initGraph();
|
||||
}
|
||||
|
||||
// 如果仍然没有图表实例,直接返回
|
||||
if (!graphInstance) {
|
||||
// console.warn('GraphContainer: Failed to initialize graph instance');
|
||||
return;
|
||||
}
|
||||
|
||||
const formattedData = {
|
||||
nodes: props.graphData.nodes.map(node => ({
|
||||
id: node.id,
|
||||
data: { label: node.name }
|
||||
})),
|
||||
edges: props.graphData.edges.map(edge => ({
|
||||
source: edge.source_id,
|
||||
target: edge.target_id,
|
||||
data: { label: edge.type }
|
||||
}))
|
||||
};
|
||||
|
||||
// console.log('GraphContainer: Setting data and rendering graph');
|
||||
graphInstance.setData(formattedData);
|
||||
|
||||
// 强制刷新视图
|
||||
graphInstance.render();
|
||||
|
||||
// 确保图表正确居中和适应视图
|
||||
setTimeout(() => {
|
||||
if (graphInstance) {
|
||||
graphInstance.fitCenter();
|
||||
graphInstance.fitView();
|
||||
// console.log('GraphContainer: Graph rendered and fitted');
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
|
||||
// 添加供父组件调用的刷新方法
|
||||
const refreshGraph = () => {
|
||||
// console.log('GraphContainer: refreshGraph called');
|
||||
|
||||
// 销毁现有图表实例
|
||||
if (graphInstance) {
|
||||
graphInstance.destroy();
|
||||
graphInstance = null;
|
||||
}
|
||||
|
||||
// 清理容器内容
|
||||
if (container.value) {
|
||||
container.value.innerHTML = '';
|
||||
}
|
||||
|
||||
// 重置重试计数
|
||||
retryCount = 0;
|
||||
|
||||
// 延迟重新渲染,确保容器大小已经稳定
|
||||
// 增加延迟时间确保窗口完全打开
|
||||
clearTimeout(renderTimeout);
|
||||
renderTimeout = setTimeout(() => {
|
||||
renderGraph();
|
||||
}, 300);
|
||||
};
|
||||
|
||||
// 向父组件暴露方法
|
||||
defineExpose({
|
||||
refreshGraph
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
// 使用ResizeObserver监听容器大小变化
|
||||
if (window.ResizeObserver) {
|
||||
resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.target === container.value) {
|
||||
// console.log('GraphContainer: Container resized to', entry.contentRect.width, 'x', entry.contentRect.height);
|
||||
renderGraph();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (container.value) {
|
||||
resizeObserver.observe(container.value);
|
||||
}
|
||||
}
|
||||
|
||||
// 初始渲染,增加延迟确保容器完全展开
|
||||
clearTimeout(renderTimeout);
|
||||
renderTimeout = setTimeout(() => {
|
||||
renderGraph();
|
||||
}, 300);
|
||||
|
||||
window.addEventListener('resize', renderGraph);
|
||||
});
|
||||
|
||||
// 添加组件卸载时的清理
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', renderGraph);
|
||||
|
||||
clearTimeout(renderTimeout);
|
||||
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect();
|
||||
resizeObserver = null;
|
||||
}
|
||||
|
||||
if (graphInstance) {
|
||||
graphInstance.destroy();
|
||||
graphInstance = null;
|
||||
}
|
||||
|
||||
// 确保清理容器内容
|
||||
if (container.value) {
|
||||
container.value.innerHTML = '';
|
||||
}
|
||||
});
|
||||
|
||||
watch(() => props.graphData, (newData, oldData) => {
|
||||
// 强制重新渲染图表
|
||||
if (newData !== oldData) {
|
||||
// console.log('GraphContainer: graphData changed, refreshing graph');
|
||||
refreshGraph();
|
||||
}
|
||||
}, { deep: true });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.graph-container {
|
||||
background: #F7F7F7;
|
||||
border-radius: 16px;
|
||||
width: 100%;
|
||||
min-height: 400px;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@ -9,7 +9,7 @@
|
||||
|
||||
<!-- 图谱可视化容器 -->
|
||||
<div class="graph-visualization" ref="graphContainerRef" v-if="totalNodes > 0 || totalRelations > 0">
|
||||
<GraphContainer :graph-data="graphData" ref="graphContainer" />
|
||||
<GraphCanvas :graph-data="graphData" ref="graphContainer" style="height: 360px;" />
|
||||
</div>
|
||||
|
||||
<!-- 详细信息展示 -->
|
||||
@ -55,7 +55,7 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch, nextTick, onMounted, onUpdated } from 'vue'
|
||||
import { DeploymentUnitOutlined } from '@ant-design/icons-vue'
|
||||
import GraphContainer from '../GraphContainer.vue'
|
||||
import GraphCanvas from '../GraphCanvas.vue'
|
||||
|
||||
const props = defineProps({
|
||||
data: {
|
||||
|
||||
@ -28,44 +28,54 @@
|
||||
</HeaderComponent>
|
||||
|
||||
<div class="container-outter">
|
||||
<div class="main" id="container" ref="container" v-show="graphData.nodes.length > 0">
|
||||
<div class="actions">
|
||||
<div class="actions-left">
|
||||
<a-input
|
||||
v-model:value="state.searchInput"
|
||||
placeholder="输入要查询的实体"
|
||||
style="width: 300px"
|
||||
@keydown.enter="onSearch"
|
||||
>
|
||||
<template #suffix>
|
||||
<component :is="state.searchLoading ? LoadingOutlined : SearchOutlined" @click="onSearch" />
|
||||
</template>
|
||||
</a-input>
|
||||
<GraphCanvas
|
||||
ref="graphRef"
|
||||
:graph-data="graphData"
|
||||
>
|
||||
<template #top>
|
||||
<div class="actions">
|
||||
<div class="actions-left">
|
||||
<a-input
|
||||
v-model:value="state.searchInput"
|
||||
placeholder="输入要查询的实体"
|
||||
style="width: 300px"
|
||||
@keydown.enter="onSearch"
|
||||
allow-clear
|
||||
>
|
||||
<template #suffix>
|
||||
<component :is="state.searchLoading ? LoadingOutlined : SearchOutlined" @click="onSearch" />
|
||||
</template>
|
||||
</a-input>
|
||||
</div>
|
||||
<div class="actions-right">
|
||||
<a-button type="default" @click="state.showInfoModal = true" :icon="h(InfoCircleOutlined)">
|
||||
说明
|
||||
</a-button>
|
||||
<a-input
|
||||
v-model:value="sampleNodeCount"
|
||||
placeholder="查询三元组数量"
|
||||
style="width: 100px"
|
||||
@keydown.enter="loadSampleNodes"
|
||||
:loading="state.fetching"
|
||||
>
|
||||
<template #suffix>
|
||||
<component :is="state.fetching ? LoadingOutlined : ReloadOutlined" @click="loadSampleNodes" />
|
||||
</template>
|
||||
</a-input>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions-right">
|
||||
<a-button type="default" @click="state.showInfoModal = true" :icon="h(InfoCircleOutlined)">
|
||||
说明
|
||||
</a-button>
|
||||
<a-input
|
||||
v-model:value="sampleNodeCount"
|
||||
placeholder="查询三元组数量"
|
||||
style="width: 100px"
|
||||
@keydown.enter="loadSampleNodes"
|
||||
:loading="state.fetching"
|
||||
>
|
||||
<template #suffix>
|
||||
<component :is="state.fetching ? LoadingOutlined : ReloadOutlined" @click="loadSampleNodes" />
|
||||
</template>
|
||||
</a-input>
|
||||
</template>
|
||||
<template #content>
|
||||
<a-empty v-show="graphData.nodes.length === 0" style="padding: 4rem 0;"/>
|
||||
</template>
|
||||
<template #bottom>
|
||||
<div class="footer">
|
||||
<div class="tags">
|
||||
<a-tag :bordered="false" v-for="tag in graphTags" :key="tag.key" :color="tag.type">{{ tag.text }}</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<div class="tags">
|
||||
<a-tag :bordered="false" v-for="tag in graphTags" :key="tag.key" :color="tag.type">{{ tag.text }}</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<a-empty v-show="graphData.nodes.length === 0" style="padding: 4rem 0;"/>
|
||||
</template>
|
||||
</GraphCanvas>
|
||||
</div>
|
||||
|
||||
<a-modal
|
||||
@ -130,22 +140,21 @@ LIMIT $num</code></pre>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Graph } from "@antv/g6";
|
||||
import { computed, onMounted, reactive, ref, h } from 'vue';
|
||||
import { message, Button as AButton } from 'ant-design-vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { useConfigStore } from '@/stores/config';
|
||||
import { UploadOutlined, SyncOutlined, GlobalOutlined, InfoCircleOutlined, SearchOutlined, ReloadOutlined, LoadingOutlined } from '@ant-design/icons-vue';
|
||||
import HeaderComponent from '@/components/HeaderComponent.vue';
|
||||
import { neo4jApi } from '@/apis/graph_api';
|
||||
import { useUserStore } from '@/stores/user';
|
||||
import GraphCanvas from '@/components/GraphCanvas.vue';
|
||||
|
||||
const configStore = useConfigStore();
|
||||
const cur_embed_model = computed(() => configStore.config?.embed_model);
|
||||
const modelMatched = computed(() => !graphInfo?.value?.embed_model_name || graphInfo.value.embed_model_name === cur_embed_model.value)
|
||||
|
||||
let graphInstance
|
||||
const graphRef = ref(null)
|
||||
const graphInfo = ref(null)
|
||||
const container = ref(null);
|
||||
const fileList = ref([]);
|
||||
const sampleNodeCount = ref(100);
|
||||
const graphData = reactive({
|
||||
@ -160,7 +169,7 @@ const state = reactive({
|
||||
searchLoading: false,
|
||||
showModal: false,
|
||||
showInfoModal: false,
|
||||
precessing: false,
|
||||
processing: false,
|
||||
indexing: false,
|
||||
showPage: true,
|
||||
})
|
||||
@ -185,48 +194,6 @@ const loadGraphInfo = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const getGraphData = () => {
|
||||
// 计算每个节点的度数(连接数)
|
||||
const nodeDegrees = {};
|
||||
|
||||
// 初始化所有节点的度数为0
|
||||
graphData.nodes.forEach(node => {
|
||||
nodeDegrees[node.id] = 0;
|
||||
});
|
||||
|
||||
// 计算每个节点的连接数
|
||||
graphData.edges.forEach(edge => {
|
||||
nodeDegrees[edge.source_id] = (nodeDegrees[edge.source_id] || 0) + 1;
|
||||
nodeDegrees[edge.target_id] = (nodeDegrees[edge.target_id] || 0) + 1;
|
||||
});
|
||||
|
||||
return {
|
||||
nodes: graphData.nodes.map(node => {
|
||||
// 计算节点大小,基础大小为15,每个连接增加5的大小,最小为15,最大为50
|
||||
const degree = nodeDegrees[node.id] || 0;
|
||||
const nodeSize = Math.min(15 + degree * 5, 50);
|
||||
|
||||
return {
|
||||
id: String(node.id),
|
||||
data: {
|
||||
label: node.name,
|
||||
degree: degree, // 存储度数信息
|
||||
},
|
||||
}
|
||||
}),
|
||||
edges: graphData.edges.map((edge, index) => {
|
||||
return {
|
||||
id: `edge-${index}`,
|
||||
source: edge.source_id,
|
||||
target: edge.target_id,
|
||||
data: {
|
||||
label: edge.type
|
||||
}
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
const addDocumentByFile = () => {
|
||||
state.processing = true
|
||||
const files = fileList.value.filter(file => file.status === 'done').map(file => file.response.file_path)
|
||||
@ -253,7 +220,8 @@ const loadSampleNodes = () => {
|
||||
graphData.nodes = data.result.nodes
|
||||
graphData.edges = data.result.edges
|
||||
console.log(graphData)
|
||||
setTimeout(() => renderGraph(), 500)
|
||||
// 初次加载后兜底刷新一次,避免容器初次可见尺寸未稳定
|
||||
setTimeout(() => graphRef.value?.refreshGraph?.(), 500)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error)
|
||||
@ -269,14 +237,7 @@ const onSearch = () => {
|
||||
}
|
||||
|
||||
if (graphInfo?.value?.embed_model_name !== cur_embed_model.value) {
|
||||
// if (!graphInfo?.value?.embed_model_name) {
|
||||
// message.error('请先上传文件(jsonl)')
|
||||
// return
|
||||
// }
|
||||
|
||||
// if (!confirm(`构建图数据库时向量模型为 ${graphInfo?.value?.embed_model_name},当前向量模型为 ${cur_embed_model.value},是否继续查询?`)) {
|
||||
// return
|
||||
// }
|
||||
// 可选:提示模型不一致
|
||||
}
|
||||
|
||||
if (!state.searchInput) {
|
||||
@ -297,7 +258,7 @@ const onSearch = () => {
|
||||
}
|
||||
console.log(data)
|
||||
console.log(graphData)
|
||||
renderGraph()
|
||||
graphRef.value?.refreshGraph?.()
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('查询错误:', error);
|
||||
@ -306,216 +267,11 @@ const onSearch = () => {
|
||||
.finally(() => state.searchLoading = false)
|
||||
};
|
||||
|
||||
const renderGraph = () => {
|
||||
|
||||
if (graphInstance) {
|
||||
graphInstance.destroy();
|
||||
}
|
||||
|
||||
initGraph();
|
||||
graphInstance.setData(getGraphData());
|
||||
graphInstance.render();
|
||||
}
|
||||
|
||||
const initGraph = () => {
|
||||
graphInstance = new Graph({
|
||||
container: container.value,
|
||||
width: container.value.offsetWidth,
|
||||
height: container.value.offsetHeight,
|
||||
autoFit: true,
|
||||
autoResize: true,
|
||||
layout: {
|
||||
type: 'd3-force',
|
||||
preventOverlap: true,
|
||||
// 优化布局性能参数
|
||||
alphaDecay: 0.1, // 增加衰减率,加快收敛速度
|
||||
alphaMin: 0.01, // 提高最小alpha值,更早停止模拟
|
||||
velocityDecay: 0.7, // 增加速度衰减,减少震荡
|
||||
iterations: 100, // 限制最大迭代次数
|
||||
// 力的配置
|
||||
force: {
|
||||
// 中心力 - 将节点拉向中心
|
||||
center: {
|
||||
x: 0.5,
|
||||
y: 0.5,
|
||||
strength: 0.1
|
||||
},
|
||||
// 排斥力 - 节点间相互排斥
|
||||
charge: {
|
||||
strength: -400, // 负值表示排斥力
|
||||
distanceMax: 400 // 限制力的作用距离
|
||||
},
|
||||
// 链接力 - 连接的节点相互吸引
|
||||
link: {
|
||||
distance: 100, // 理想链接距离
|
||||
strength: 0.8 // 链接强度
|
||||
}
|
||||
},
|
||||
collide: {
|
||||
radius: 40,
|
||||
strength: 0.8, // 增加碰撞强度,减少重叠
|
||||
iterations: 3 // 限制碰撞检测迭代次数
|
||||
},
|
||||
},
|
||||
node: {
|
||||
type: 'circle',
|
||||
style: {
|
||||
labelText: (d) => d.data.label,
|
||||
// 使用节点度数来决定大小
|
||||
size: (d) => {
|
||||
const degree = d.data.degree || 0;
|
||||
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: [
|
||||
'#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: '#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);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadGraphInfo();
|
||||
loadSampleNodes();
|
||||
});
|
||||
|
||||
|
||||
const handleFileUpload = (event) => {
|
||||
console.log(event)
|
||||
console.log(fileList.value)
|
||||
@ -664,20 +420,13 @@ const openLink = (url) => {
|
||||
overflow: hidden;
|
||||
background: var(--gray-50);
|
||||
|
||||
#container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.actions,
|
||||
.footer {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin: 20px 0;
|
||||
padding: 0 24px;
|
||||
width: 100%;
|
||||
z-index: 999;
|
||||
}
|
||||
}
|
||||
|
||||
@ -699,52 +448,4 @@ const openLink = (url) => {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.footer {
|
||||
bottom: 0;
|
||||
z-index: 2; // 显示在画布之上
|
||||
}
|
||||
|
||||
.database-empty {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
flex-direction: column;
|
||||
color: var(--gray-900);
|
||||
}
|
||||
|
||||
.info-content {
|
||||
line-height: 1.6;
|
||||
|
||||
ul {
|
||||
padding-left: 20px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
li {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
code {
|
||||
background-color: #f0f0f0;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
pre {
|
||||
background-color: #f8f8f8;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
margin: 15px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user