270 lines
9.5 KiB
Vue
270 lines
9.5 KiB
Vue
<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>
|