refactor(graph): 重构知识图谱组件为GraphCanvas并替换GraphContainer

将原有的GraphContainer组件重构为更灵活的GraphCanvas组件,支持插槽布局和更多配置选项
更新KnowledgeGraphResult和GraphView视图使用新组件
删除旧的GraphContainer组件并更新相关文档
This commit is contained in:
Wenjie Zhang 2025-09-03 16:06:56 +08:00
parent 91fa5c903e
commit 2267c8634a
5 changed files with 439 additions and 644 deletions

View File

@ -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 中查看信息

View 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>

View File

@ -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>

View File

@ -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: {

View File

@ -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 => {
// 1551550
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>