Files
rag-local/ui-src/src/views/KgGraph.vue
T
2026-08-11 11:19:04 +08:00

270 lines
9.5 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="kg-page">
<div class="kg-head">
<el-select v-model="datasetId" placeholder="选择知识库" style="width: 260px" @change="load">
<el-option v-for="d in datasets" :key="d.id" :label="d.name" :value="d.id" />
</el-select>
<span class="kg-tip">实体与关系由解析流水线中的 LLM 抽取生成问答时命中实体自动注入一跳邻居点击节点高亮其关联实体</span>
</div>
<el-row :gutter="12">
<el-col :span="15">
<el-card shadow="never" class="kg-card">
<template #header>
<div class="kg-graph-head">
<span>关系图谱{{ graphCountText }}</span>
<el-tag v-if="truncated" size="small" type="warning">实体过多仅展示度数最高的前 {{ MAX_NODES }} </el-tag>
</div>
</template>
<div ref="graphEl" class="kg-graph" v-loading="loading"></div>
<el-empty v-if="!loading && graphNodes.length === 0" description="暂无图谱数据,上传文档并完成解析后自动生成" />
</el-card>
</el-col>
<el-col :span="9">
<el-card shadow="never" class="kg-card">
<el-tabs v-model="sideTab">
<el-tab-pane :label="`实体(${entityTotal}`" name="entity">
<el-table :data="entities" size="small" height="440" v-loading="loading">
<el-table-column prop="name" label="实体" min-width="120" show-overflow-tooltip />
<el-table-column prop="entity_type" label="类型" width="100" />
<el-table-column label="来源文件" min-width="150" show-overflow-tooltip>
<template #default="{ row }">{{ srcText(row.name) }}</template>
</el-table-column>
</el-table>
<el-pagination class="kg-page-bar" small layout="prev, pager, next" :total="entityTotal"
:page-size="entityPageSize" v-model:current-page="entityPage" @current-change="loadEntities" />
</el-tab-pane>
<el-tab-pane :label="`关系(${relationTotal}`" name="relation">
<el-table :data="relations" size="small" height="440" v-loading="loading">
<el-table-column prop="head" label="主体" min-width="110" show-overflow-tooltip />
<el-table-column prop="relation" label="关系" min-width="90" show-overflow-tooltip />
<el-table-column prop="tail" label="客体" min-width="110" show-overflow-tooltip />
</el-table>
<el-pagination class="kg-page-bar" small layout="prev, pager, next" :total="relationTotal"
:page-size="relationPageSize" v-model:current-page="relationPage" @current-change="loadRelations" />
</el-tab-pane>
</el-tabs>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import * as echarts from 'echarts/core'
import { GraphChart } from 'echarts/charts'
import { TooltipComponent, LegendComponent } from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'
import { listDatasets } from '../api/dataset.js'
import { listEntities, listRelations, listEntitySources } from '../api/kg.js'
echarts.use([GraphChart, TooltipComponent, LegendComponent, CanvasRenderer])
const MAX_NODES = 400 // 力导向图节点上限,超过取度数最高者,避免布局卡顿
const datasets = ref([])
const datasetId = ref(null)
const loading = ref(false)
const graphEl = ref(null)
const truncated = ref(false)
const entities = ref([])
const entityTotal = ref(0)
const entityPage = ref(1)
const entityPageSize = 20
const relations = ref([])
const relationTotal = ref(0)
const relationPage = ref(1)
const relationPageSize = 20
const sideTab = ref('entity')
const srcMap = ref({}) // 实体名 → 来源文件列表(/kg-entity/sources
let chart = null
let resizeObserver = null
function srcText(name) {
const files = srcMap.value[name] || []
return files.length ? files.join('、') : '-'
}
const graphCountText = computed(() => `${truncated.value ? '≈' : ''}${graphNodes.value.length} 实体 / ${graphLinks.value.length} 关系`)
const graphNodes = ref([])
const graphLinks = ref([])
onMounted(async () => {
try {
const d = await listDatasets()
if (d && d.list) datasets.value = d.list
if (datasets.value.length) datasetId.value = datasets.value[0].id
} catch { /* 忽略 */ }
await load()
})
onBeforeUnmount(() => {
resizeObserver?.disconnect()
chart?.dispose()
})
async function load() {
entityPage.value = 1
relationPage.value = 1
loading.value = true
try {
const [allEntities, allRelations, allSources] = await Promise.all([
listEntities({ dataset_id: datasetId.value, page: 1, page_size: 100000 }),
listRelations({ dataset_id: datasetId.value, page: 1, page_size: 100000 }),
listEntitySources({ dataset_id: datasetId.value }),
])
const map = {}
for (const s of allSources?.list || []) map[s.name] = s.files || []
srcMap.value = map
buildGraph(allEntities?.list || [], allRelations?.list || [])
await Promise.all([loadEntities(), loadRelations()])
} finally {
loading.value = false
}
}
// 实体按名字去重合并(同一实体可能出现在多个分块),按出入度决定节点大小
function buildGraph(entitiesList, relationsList) {
const byName = new Map()
for (const e of entitiesList) {
const prev = byName.get(e.name)
if (prev) {
prev.chunks = [...new Set([...prev.chunks, e.chunk_id])]
} else {
byName.set(e.name, { name: e.name, entity_type: e.entity_type, chunks: [e.chunk_id] })
}
}
const degree = new Map()
const edges = []
const seenEdges = new Set()
for (const r of relationsList) {
if (!byName.has(r.head) || !byName.has(r.tail)) continue
const key = `${r.head}|${r.relation}|${r.tail}`
if (seenEdges.has(key)) continue
seenEdges.add(key)
edges.push({ source: r.head, target: r.tail, relation: r.relation })
degree.set(r.head, (degree.get(r.head) || 0) + 1)
degree.set(r.tail, (degree.get(r.tail) || 0) + 1)
}
let nodes = [...byName.values()]
truncated.value = nodes.length > MAX_NODES
if (truncated.value) {
nodes.sort((a, b) => (degree.get(b.name) || 0) - (degree.get(a.name) || 0))
const keep = new Set(nodes.slice(0, MAX_NODES).map(n => n.name))
nodes = nodes.filter(n => keep.has(n.name))
for (const e of [...edges]) {
if (!keep.has(e.source) || !keep.has(e.target)) edges.splice(edges.indexOf(e), 1)
}
}
const minD = Math.min(...nodes.map(n => degree.get(n.name) || 0))
const maxD = Math.max(...nodes.map(n => degree.get(n.name) || 0))
const sizeFor = d => (maxD === minD ? 30 : 20 + ((d - minD) / (maxD - minD)) * 36)
graphNodes.value = nodes.map(n => ({
id: n.name,
name: n.name,
category: n.entity_type || '未分类',
symbolSize: sizeFor(degree.get(n.name) || 0),
files: srcMap.value[n.name] || [],
}))
graphLinks.value = edges
renderGraph()
}
function renderGraph() {
if (!graphEl.value) return
chart ??= echarts.init(graphEl.value)
const types = [...new Set(graphNodes.value.map(n => n.category))]
chart.setOption({
legend: { bottom: 0, type: 'scroll', data: types, textStyle: { fontSize: 11 } },
tooltip: {
trigger: 'item',
formatter: p => {
if (p.dataType === 'edge') {
const e = graphLinks.value[p.dataIndex]
return `${e.source}${e.target}<br/>关系:${e.relation}`
}
const files = p.data.files || []
const src = files.length ? files.join('、') : '未知'
return `${p.name}<br/>类型:${p.data.category}<br/>来源:${src}`
},
},
series: [{
type: 'graph',
layout: 'force',
roam: true,
draggable: true,
data: graphNodes.value,
links: graphLinks.value,
categories: types.map(t => ({ name: t })),
force: { repulsion: 260, edgeLength: [40, 110], gravity: 0.08 },
label: { show: true, position: 'right', fontSize: 10, color: '#606266' },
edgeLabel: { show: false },
emphasis: {
focus: 'adjacency', // 点击节点自动高亮一跳邻居并淡化其余
label: { fontSize: 12, fontWeight: 600 },
lineStyle: { width: 2 },
},
}],
}, true)
}
async function loadEntities() {
if (!datasetId.value) return
loading.value = true
try {
const d = await listEntities({ dataset_id: datasetId.value, page: entityPage.value, page_size: entityPageSize })
entities.value = d.list
entityTotal.value = d.total
} finally {
loading.value = false
}
}
async function loadRelations() {
if (!datasetId.value) return
loading.value = true
try {
const d = await listRelations({ dataset_id: datasetId.value, page: relationPage.value, page_size: relationPageSize })
relations.value = d.list
relationTotal.value = d.total
} finally {
loading.value = false
}
}
onMounted(() => {
// 布局稳定后渲染一次(图表容器尺寸依赖 el-row 布局),并监听容器尺寸变化
resizeObserver = new ResizeObserver(() => nextTick(renderGraph))
resizeObserver.observe(graphEl.value)
})
</script>
<style scoped>
.kg-head {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.kg-tip {
font-size: 13px;
color: #909399;
}
.kg-graph {
height: 620px;
}
.kg-graph-head {
display: flex;
align-items: center;
gap: 10px;
}
.kg-page-bar {
margin-top: 10px;
justify-content: flex-end;
}
</style>