feat: 新增风险规则生成引擎与知识图谱可视化
后端新增风险规则自动生成和模板执行服务,支持从规则资产 批量生成并持久化风险规则文件;知识库入库日志增强图谱 查询和本地 RAG 回退,前端审计页面增加风险规则模型和流 程图组件,知识入库面板拆分为图谱可视化子组件,报销创 建页面增加引导式流程模型,更新知识库索引数据。
This commit is contained in:
643
web/src/components/logs/KnowledgeIngestGraphView.vue
Normal file
643
web/src/components/logs/KnowledgeIngestGraphView.vue
Normal file
@@ -0,0 +1,643 @@
|
||||
<template>
|
||||
<section class="knowledge-graph-space">
|
||||
<header class="graph-head">
|
||||
<div>
|
||||
<span class="graph-eyebrow">LightRAG 知识图谱</span>
|
||||
<h4>实体关系空间</h4>
|
||||
</div>
|
||||
<div class="graph-toolbar">
|
||||
<label class="graph-search">
|
||||
<i class="mdi mdi-magnify"></i>
|
||||
<input
|
||||
v-model.trim="graphQuery"
|
||||
type="search"
|
||||
placeholder="搜索实体"
|
||||
@keydown.enter.prevent="focusMatchedNode"
|
||||
/>
|
||||
</label>
|
||||
<button type="button" title="定位匹配实体" @click="focusMatchedNode">
|
||||
<i class="mdi mdi-crosshairs-gps"></i>
|
||||
</button>
|
||||
<button type="button" title="缩小" @click="zoomGraph(0.86)">
|
||||
<i class="mdi mdi-magnify-minus-outline"></i>
|
||||
</button>
|
||||
<button type="button" title="放大" @click="zoomGraph(1.16)">
|
||||
<i class="mdi mdi-magnify-plus-outline"></i>
|
||||
</button>
|
||||
<button type="button" title="适配画布" @click="fitGraph">
|
||||
<i class="mdi mdi-fit-to-page-outline"></i>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="graph-stats">
|
||||
<span>{{ graphSummary.entityCount }} 实体</span>
|
||||
<span>{{ graphSummary.relationCount }} 关系</span>
|
||||
<span>{{ graphSummary.visibleNodeCount }} 节点可见</span>
|
||||
<span>{{ graphSummary.visibleEdgeCount }} 连线可见</span>
|
||||
</div>
|
||||
|
||||
<div class="graph-body" :class="{ empty: !graphData.nodes.length }">
|
||||
<div class="graph-theater">
|
||||
<div v-if="graphData.nodes.length" ref="graphContainer" class="g6-canvas"></div>
|
||||
<div v-else class="graph-empty">
|
||||
<strong>暂无图谱数据</strong>
|
||||
<span>本次归集还没有返回可展示的实体关系。</span>
|
||||
</div>
|
||||
|
||||
<div v-if="graphData.nodes.length" class="graph-hud">
|
||||
<span>G6 Force</span>
|
||||
<span>拖拽节点</span>
|
||||
<span>滚轮缩放</span>
|
||||
<span>点击查看详情</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="graph-inspector">
|
||||
<template v-if="selectedNode">
|
||||
<div class="inspector-title">
|
||||
<span>节点详情</span>
|
||||
<h5>{{ selectedNode.name }}</h5>
|
||||
</div>
|
||||
|
||||
<div class="node-facts">
|
||||
<div>
|
||||
<span>关系数</span>
|
||||
<strong>{{ selectedNode.degree }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>入边</span>
|
||||
<strong>{{ selectedNodeIncoming.length }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>出边</span>
|
||||
<strong>{{ selectedNodeOutgoing.length }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="node-meta">
|
||||
<span>类型</span>
|
||||
<strong>{{ selectedNode.type || '实体' }}</strong>
|
||||
</div>
|
||||
|
||||
<KnowledgeIngestNodeDetails
|
||||
:node="selectedNode"
|
||||
:incoming="selectedNodeIncoming"
|
||||
:outgoing="selectedNodeOutgoing"
|
||||
:relations="selectedNodeRelations"
|
||||
@focus-peer="focusRelationPeer"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<div v-else class="inspector-empty">
|
||||
<strong>选择一个实体</strong>
|
||||
<span>点击图谱中的节点,查看它的入边、出边和关系明细。</span>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Graph, NodeEvent } from '@antv/g6'
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import KnowledgeIngestNodeDetails from './KnowledgeIngestNodeDetails.vue'
|
||||
import { useKnowledgeIngestGraph } from './useKnowledgeIngestGraph.js'
|
||||
|
||||
const props = defineProps({
|
||||
graph: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
documents: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
})
|
||||
|
||||
const graphContainer = ref(null)
|
||||
let graphInstance = null
|
||||
let resizeObserver = null
|
||||
|
||||
const {
|
||||
activeNodeId,
|
||||
graphData,
|
||||
graphQuery,
|
||||
graphSummary,
|
||||
selectNodeById,
|
||||
focusFirstMatch,
|
||||
selectRelationPeer,
|
||||
selectedNode,
|
||||
selectedNodeIncoming,
|
||||
selectedNodeOutgoing,
|
||||
selectedNodeRelations
|
||||
} = useKnowledgeIngestGraph(props)
|
||||
|
||||
onMounted(() => {
|
||||
initGraph()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
resizeObserver?.disconnect()
|
||||
graphInstance?.destroy()
|
||||
resizeObserver = null
|
||||
graphInstance = null
|
||||
})
|
||||
|
||||
watch(
|
||||
graphData,
|
||||
() => {
|
||||
renderGraph()
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
watch(activeNodeId, (nodeId) => {
|
||||
syncGraphSelection(nodeId)
|
||||
})
|
||||
|
||||
async function initGraph() {
|
||||
if (!graphData.value.nodes.length) return
|
||||
await nextTick()
|
||||
if (!graphContainer.value || graphInstance) return
|
||||
|
||||
graphInstance = new Graph(buildGraphOptions())
|
||||
graphInstance.on(NodeEvent.CLICK, async (event) => {
|
||||
const nodeId = String(event?.target?.id || '').trim()
|
||||
if (!nodeId) return
|
||||
selectNodeById(nodeId)
|
||||
await focusGraphNode(nodeId, { focus: false })
|
||||
})
|
||||
await graphInstance.render()
|
||||
await syncGraphSelection(activeNodeId.value)
|
||||
observeResize()
|
||||
await fitGraph()
|
||||
}
|
||||
|
||||
function buildGraphOptions() {
|
||||
return {
|
||||
container: graphContainer.value,
|
||||
autoResize: true,
|
||||
background: 'transparent',
|
||||
zoomRange: [0.22, 2.8],
|
||||
autoFit: {
|
||||
type: 'view',
|
||||
options: { padding: [86, 122, 86, 122] },
|
||||
animation: { duration: 520, easing: 'ease-in-out' }
|
||||
},
|
||||
data: graphData.value,
|
||||
layout: {
|
||||
type: 'd3-force',
|
||||
preventOverlap: true,
|
||||
centerStrength: 0.34,
|
||||
linkDistance: (edge) => (edge?.data?.weight > 2 ? 155 : 210),
|
||||
edgeStrength: 0.22,
|
||||
nodeStrength: (node) => (node?.data?.degree >= 5 ? -520 : -360),
|
||||
collide: {
|
||||
radius: (node) => 40 + Math.sqrt(Math.max(node?.data?.degree || 1, 1)) * 12,
|
||||
strength: 0.88,
|
||||
iterations: 3
|
||||
},
|
||||
manyBody: {
|
||||
strength: (node) => (node?.data?.degree >= 5 ? -620 : -420),
|
||||
distanceMax: 620
|
||||
},
|
||||
center: {
|
||||
strength: 0.24
|
||||
},
|
||||
alpha: 0.88,
|
||||
alphaDecay: 0.035,
|
||||
velocityDecay: 0.32,
|
||||
iterations: 360
|
||||
},
|
||||
node: {
|
||||
type: 'circle',
|
||||
style: (datum) => datum.style,
|
||||
state: {
|
||||
selected: {
|
||||
lineWidth: 4,
|
||||
stroke: '#1d4ed8',
|
||||
halo: true,
|
||||
haloStroke: '#60a5fa',
|
||||
haloLineWidth: 24,
|
||||
haloStrokeOpacity: 0.24,
|
||||
shadowBlur: 22
|
||||
},
|
||||
active: {
|
||||
lineWidth: 3,
|
||||
halo: true,
|
||||
haloLineWidth: 16,
|
||||
haloStrokeOpacity: 0.28
|
||||
},
|
||||
inactive: {
|
||||
opacity: 0.2
|
||||
},
|
||||
dimmed: {
|
||||
opacity: 0.18
|
||||
}
|
||||
}
|
||||
},
|
||||
edge: {
|
||||
type: 'line',
|
||||
style: (datum) => datum.style,
|
||||
state: {
|
||||
selected: {
|
||||
stroke: '#2563eb',
|
||||
lineWidth: 2.8,
|
||||
opacity: 0.96,
|
||||
halo: true,
|
||||
haloStroke: '#93c5fd',
|
||||
haloLineWidth: 8,
|
||||
haloStrokeOpacity: 0.2
|
||||
},
|
||||
active: {
|
||||
stroke: '#60a5fa',
|
||||
lineWidth: 2.4,
|
||||
opacity: 0.9
|
||||
},
|
||||
inactive: {
|
||||
opacity: 0.14
|
||||
}
|
||||
}
|
||||
},
|
||||
behaviors: [
|
||||
{ type: 'drag-canvas', key: 'drag-canvas' },
|
||||
{ type: 'zoom-canvas', key: 'zoom-canvas', sensitivity: 1.15 },
|
||||
{ type: 'drag-element-force', key: 'drag-element-force', fixed: true }
|
||||
],
|
||||
animation: { duration: 420, easing: 'ease-out' }
|
||||
}
|
||||
}
|
||||
|
||||
async function renderGraph() {
|
||||
if (!graphData.value.nodes.length) return
|
||||
if (!graphInstance) {
|
||||
await initGraph()
|
||||
return
|
||||
}
|
||||
graphInstance.setData(graphData.value)
|
||||
await graphInstance.render()
|
||||
await syncGraphSelection(activeNodeId.value)
|
||||
await fitGraph()
|
||||
}
|
||||
|
||||
async function syncGraphSelection(nodeId) {
|
||||
if (!graphInstance || graphInstance.destroyed || !nodeId) return
|
||||
const stateMap = {}
|
||||
for (const node of graphData.value.nodes) {
|
||||
if (node.id === nodeId) {
|
||||
stateMap[node.id] = ['selected']
|
||||
} else if (node.states?.includes('dimmed')) {
|
||||
stateMap[node.id] = ['dimmed']
|
||||
} else {
|
||||
stateMap[node.id] = []
|
||||
}
|
||||
}
|
||||
for (const edge of graphData.value.edges) {
|
||||
stateMap[edge.id] = edge.source === nodeId || edge.target === nodeId ? ['selected'] : ['inactive']
|
||||
}
|
||||
await graphInstance.setElementState(stateMap, false)
|
||||
}
|
||||
|
||||
async function fitGraph() {
|
||||
if (!graphInstance || graphInstance.destroyed) return
|
||||
await graphInstance.fitView(
|
||||
{ padding: [84, 118, 84, 118] },
|
||||
{ duration: 420, easing: 'ease-in-out' }
|
||||
)
|
||||
}
|
||||
|
||||
async function zoomGraph(ratio) {
|
||||
if (!graphInstance || graphInstance.destroyed) return
|
||||
await graphInstance.zoomBy(ratio, { duration: 220, easing: 'ease-out' })
|
||||
}
|
||||
|
||||
async function focusMatchedNode() {
|
||||
const nodeId = focusFirstMatch()
|
||||
if (nodeId) await focusGraphNode(nodeId)
|
||||
}
|
||||
|
||||
async function focusRelationPeer(relation) {
|
||||
const nodeId = selectRelationPeer(relation)
|
||||
if (nodeId) await focusGraphNode(nodeId)
|
||||
}
|
||||
|
||||
async function focusGraphNode(nodeId, options = {}) {
|
||||
if (!graphInstance || graphInstance.destroyed) return
|
||||
await syncGraphSelection(nodeId)
|
||||
if (options.focus === false) return
|
||||
await graphInstance.focusElement(nodeId, { duration: 360, easing: 'ease-in-out' })
|
||||
}
|
||||
|
||||
function observeResize() {
|
||||
if (!graphContainer.value || typeof ResizeObserver === 'undefined') return
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
if (!graphInstance || graphInstance.destroyed) return
|
||||
graphInstance.setSize(graphContainer.value.clientWidth, graphContainer.value.clientHeight)
|
||||
})
|
||||
resizeObserver.observe(graphContainer.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.knowledge-graph-space {
|
||||
min-height: 1080px;
|
||||
display: grid;
|
||||
grid-template-rows: auto auto minmax(1020px, 1fr);
|
||||
gap: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.graph-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.graph-eyebrow {
|
||||
color: #0f766e;
|
||||
font-size: 12px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.graph-head h4 {
|
||||
margin: 4px 0 0;
|
||||
color: #0f172a;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.graph-toolbar,
|
||||
.graph-stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.graph-search {
|
||||
min-width: 230px;
|
||||
min-height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 9px;
|
||||
border: 1px solid #dbe6ef;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.graph-search input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: #0f172a;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.graph-toolbar button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid #dbe6ef;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #475569;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease, color 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.graph-toolbar button:hover {
|
||||
border-color: #2563eb;
|
||||
color: #2563eb;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.graph-stats {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.graph-stats span {
|
||||
min-height: 26px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 9px;
|
||||
border: 1px solid #dbe6ef;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
color: #475569;
|
||||
font-size: 12px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.graph-body {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 360px;
|
||||
gap: 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.graph-theater {
|
||||
position: relative;
|
||||
min-height: 1020px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(148, 163, 184, 0.24);
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 54%, #eef4fb 100%);
|
||||
box-shadow: inset 0 0 0 1px rgba(226, 232, 240, 0.82);
|
||||
}
|
||||
|
||||
.graph-theater::before {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
content: '';
|
||||
pointer-events: none;
|
||||
background:
|
||||
linear-gradient(rgba(148, 163, 184, 0.18) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(148, 163, 184, 0.18) 1px, transparent 1px);
|
||||
background-size: 34px 34px;
|
||||
mask-image: radial-gradient(circle at center, black 0%, black 58%, transparent 100%);
|
||||
}
|
||||
|
||||
.g6-canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.g6-canvas:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.graph-hud {
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
right: 14px;
|
||||
bottom: 12px;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.graph-hud span {
|
||||
min-height: 24px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
border: 1px solid rgba(203, 213, 225, 0.86);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.76);
|
||||
color: #334155;
|
||||
font-size: 11px;
|
||||
font-weight: 750;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.graph-inspector {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 12px;
|
||||
overflow: auto;
|
||||
padding: 12px;
|
||||
border: 1px solid #dbe6ef;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.inspector-title span {
|
||||
color: #0f766e;
|
||||
font-size: 12px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.inspector-title h5 {
|
||||
margin: 4px 0 0;
|
||||
color: #0f172a;
|
||||
font-size: 15px;
|
||||
line-height: 1.35;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.node-facts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.node-facts div,
|
||||
.node-meta {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 9px;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.node-meta {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.node-facts span,
|
||||
.node-meta span {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.node-facts strong,
|
||||
.node-meta strong {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: #0f172a;
|
||||
font-size: 15px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.graph-empty,
|
||||
.inspector-empty {
|
||||
min-height: 120px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: 6px;
|
||||
color: #64748b;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.graph-empty {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.graph-empty strong,
|
||||
.inspector-empty strong {
|
||||
color: #0f172a;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.inspector-empty strong {
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.graph-empty span,
|
||||
.inspector-empty span {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.graph-toolbar button {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.knowledge-graph-space {
|
||||
min-height: 980px;
|
||||
grid-template-rows: auto auto minmax(900px, 1fr);
|
||||
}
|
||||
|
||||
.graph-head {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.graph-toolbar {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.graph-body {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: minmax(780px, 1fr) minmax(220px, auto);
|
||||
}
|
||||
|
||||
.graph-theater {
|
||||
min-height: 780px;
|
||||
}
|
||||
|
||||
.graph-inspector {
|
||||
max-height: 240px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.graph-search {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.graph-toolbar {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
386
web/src/components/logs/KnowledgeIngestNodeDetails.vue
Normal file
386
web/src/components/logs/KnowledgeIngestNodeDetails.vue
Normal file
@@ -0,0 +1,386 @@
|
||||
<template>
|
||||
<div class="node-detail-panel">
|
||||
<section class="detail-section">
|
||||
<div class="detail-section-head">
|
||||
<strong>节点说明</strong>
|
||||
<span>{{ safeNode.type || '实体' }}</span>
|
||||
</div>
|
||||
<div v-if="descriptionItems.length" class="description-list">
|
||||
<p v-for="(description, index) in descriptionItems" :key="index">
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-else class="detail-empty compact">
|
||||
当前节点暂无 LightRAG 描述,完成新的归集后会从图谱属性中补充。
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="detail-section">
|
||||
<div class="detail-section-head">
|
||||
<strong>节点属性</strong>
|
||||
<span>{{ propertyItems.length }} 项</span>
|
||||
</div>
|
||||
<dl class="property-grid">
|
||||
<div>
|
||||
<dt>类型</dt>
|
||||
<dd>{{ safeNode.type || '实体' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>关系数</dt>
|
||||
<dd>{{ safeNode.degree || 0 }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>入边</dt>
|
||||
<dd>{{ incoming.length }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>出边</dt>
|
||||
<dd>{{ outgoing.length }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div v-if="labelItems.length" class="label-row">
|
||||
<span v-for="label in labelItems" :key="label">{{ label }}</span>
|
||||
</div>
|
||||
|
||||
<dl v-if="propertyItems.length" class="raw-property-list">
|
||||
<div v-for="item in propertyItems" :key="item.key">
|
||||
<dt>{{ item.label }}</dt>
|
||||
<dd>{{ item.value }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="detail-section relation-section">
|
||||
<div class="detail-section-head">
|
||||
<strong>关系语境</strong>
|
||||
<span>{{ relationRows.length }} 条</span>
|
||||
</div>
|
||||
<div v-if="relationRows.length" class="relation-detail-list">
|
||||
<button
|
||||
v-for="relation in relationRows"
|
||||
:key="relation.key"
|
||||
type="button"
|
||||
@click="$emit('focus-peer', relation.raw)"
|
||||
>
|
||||
<span class="relation-direction">{{ relation.directionLabel }}</span>
|
||||
<span class="relation-peer">{{ relation.peerName }}</span>
|
||||
<strong>{{ relation.type }}</strong>
|
||||
<p v-if="relation.description">{{ relation.description }}</p>
|
||||
<div v-if="relation.keywords.length" class="keyword-row">
|
||||
<span v-for="keyword in relation.keywords" :key="keyword">{{ keyword }}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="detail-empty compact">暂无关联关系。</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
node: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
incoming: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
outgoing: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
relations: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(['focus-peer'])
|
||||
|
||||
const safeNode = computed(() => (props.node && typeof props.node === 'object' ? props.node : {}))
|
||||
|
||||
const descriptionItems = computed(() => {
|
||||
const descriptions = Array.isArray(safeNode.value.descriptions)
|
||||
? safeNode.value.descriptions
|
||||
: []
|
||||
const fallback = String(safeNode.value.description || '').trim()
|
||||
return dedupeTextItems(descriptions.length ? descriptions : [fallback]).slice(0, 6)
|
||||
})
|
||||
|
||||
const labelItems = computed(() => dedupeTextItems(safeNode.value.labels).slice(0, 8))
|
||||
|
||||
const propertyItems = computed(() => {
|
||||
const properties =
|
||||
safeNode.value.properties && typeof safeNode.value.properties === 'object'
|
||||
? safeNode.value.properties
|
||||
: {}
|
||||
return Object.entries(properties)
|
||||
.filter(([key, value]) => !hiddenPropertyKeys.has(key) && String(value || '').trim())
|
||||
.map(([key, value]) => ({
|
||||
key,
|
||||
label: formatPropertyKey(key),
|
||||
value: String(value).trim()
|
||||
}))
|
||||
.slice(0, 10)
|
||||
})
|
||||
|
||||
const relationRows = computed(() => {
|
||||
const nodeName = String(safeNode.value.name || '').trim()
|
||||
if (!nodeName || !Array.isArray(props.relations)) return []
|
||||
return props.relations.map((relation, index) => {
|
||||
const isOutgoing = relation.source === nodeName
|
||||
const peerName = isOutgoing ? relation.target : relation.source
|
||||
return {
|
||||
key: `${relation.source}-${relation.target}-${relation.type}-${index}`,
|
||||
raw: relation,
|
||||
peerName,
|
||||
type: String(relation.type || '关联').trim(),
|
||||
directionLabel: isOutgoing ? '指向' : '来自',
|
||||
description: String(relation.description || '').trim(),
|
||||
keywords: dedupeTextItems(relation.keywords).slice(0, 6)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const hiddenPropertyKeys = new Set(['source_id', 'file_path', 'truncate'])
|
||||
|
||||
function dedupeTextItems(items) {
|
||||
const sourceItems = Array.isArray(items)
|
||||
? items
|
||||
: String(items || '')
|
||||
.split('<SEP>')
|
||||
.filter(Boolean)
|
||||
const result = []
|
||||
const seen = new Set()
|
||||
for (const item of sourceItems) {
|
||||
const text = String(item || '').trim()
|
||||
if (!text || seen.has(text)) continue
|
||||
seen.add(text)
|
||||
result.push(text)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function formatPropertyKey(key) {
|
||||
const labels = {
|
||||
entity_id: '实体ID',
|
||||
entity_type: '实体类型',
|
||||
created_at: '创建时间',
|
||||
weight: '权重'
|
||||
}
|
||||
return labels[key] || key
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.node-detail-panel,
|
||||
.detail-section,
|
||||
.description-list,
|
||||
.relation-detail-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.detail-section {
|
||||
min-width: 0;
|
||||
padding: 11px;
|
||||
border: 1px solid #e5edf5;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.detail-section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.detail-section-head strong {
|
||||
color: #0f172a;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.detail-section-head span {
|
||||
max-width: 56%;
|
||||
overflow: hidden;
|
||||
color: #475569;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.description-list {
|
||||
max-height: 190px;
|
||||
overflow: auto;
|
||||
padding-right: 3px;
|
||||
}
|
||||
|
||||
.description-list p {
|
||||
margin: 0;
|
||||
padding: 9px;
|
||||
border: 1px solid #dbeafe;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #1e293b;
|
||||
font-size: 12px;
|
||||
line-height: 1.62;
|
||||
}
|
||||
|
||||
.property-grid,
|
||||
.raw-property-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.property-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.property-grid div,
|
||||
.raw-property-list div {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.property-grid div {
|
||||
padding: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.property-grid dt,
|
||||
.raw-property-list dt {
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.property-grid dd,
|
||||
.raw-property-list dd {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
color: #0f172a;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.label-row,
|
||||
.keyword-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.label-row span,
|
||||
.keyword-row span,
|
||||
.relation-direction {
|
||||
min-height: 22px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 7px;
|
||||
border-radius: 999px;
|
||||
background: #e0f2fe;
|
||||
color: #075985;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.raw-property-list {
|
||||
max-height: 160px;
|
||||
overflow: auto;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.relation-section {
|
||||
max-height: 360px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.relation-detail-list {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding-right: 3px;
|
||||
}
|
||||
|
||||
.relation-detail-list button {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
gap: 7px;
|
||||
padding: 9px;
|
||||
border: 1px solid #dbeafe;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: border-color 0.18s ease, background 0.18s ease, transform 0.18s ease;
|
||||
}
|
||||
|
||||
.relation-detail-list button:hover {
|
||||
border-color: #60a5fa;
|
||||
background: #eff6ff;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.relation-peer,
|
||||
.relation-detail-list strong {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: #0f172a;
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.relation-detail-list strong {
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.relation-detail-list p,
|
||||
.detail-empty {
|
||||
grid-column: 1 / -1;
|
||||
margin: 0;
|
||||
color: #334155;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.keyword-row {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.keyword-row span {
|
||||
background: #ecfdf5;
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
.detail-empty {
|
||||
min-height: 82px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.detail-empty.compact {
|
||||
min-height: 52px;
|
||||
padding: 8px;
|
||||
border: 1px dashed #cbd5e1;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.relation-detail-list button {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
165
web/src/components/logs/KnowledgeIngestNodeEvidence.vue
Normal file
165
web/src/components/logs/KnowledgeIngestNodeEvidence.vue
Normal file
@@ -0,0 +1,165 @@
|
||||
<template>
|
||||
<div class="evidence-panel">
|
||||
<div class="evidence-head">
|
||||
<strong>关联切片正文</strong>
|
||||
<span>{{ safeEvidence.chunkCount }} 段</span>
|
||||
</div>
|
||||
|
||||
<div v-if="safeEvidence.items.length" class="evidence-list">
|
||||
<article
|
||||
v-for="item in safeEvidence.items"
|
||||
:key="item.documentId"
|
||||
class="evidence-document"
|
||||
>
|
||||
<div class="evidence-document-head">
|
||||
<div>
|
||||
<strong>{{ item.name }}</strong>
|
||||
<small>{{ item.matchType }}</small>
|
||||
</div>
|
||||
<span>{{ item.chunks.length }} 段</span>
|
||||
</div>
|
||||
|
||||
<div v-if="item.chunks.length" class="evidence-chunks">
|
||||
<div v-for="chunk in item.chunks" :key="chunk.id" class="evidence-chunk">
|
||||
<span>#{{ chunk.order + 1 }}</span>
|
||||
<p>{{ chunk.excerpt || chunk.summary || '暂无正文片段' }}</p>
|
||||
<small>{{ chunk.tokens }} tokens</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="evidence-empty compact">
|
||||
命中了该实体,但当前日志只返回了 chunk id,未返回对应正文片段。
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div v-else class="evidence-empty">
|
||||
当前日志没有返回该实体和切片的映射;新归纳日志会优先显示精确切片,旧日志按文档实体降级匹配。
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
evidence: {
|
||||
type: Object,
|
||||
default: () => ({ items: [], chunkCount: 0 })
|
||||
}
|
||||
})
|
||||
|
||||
const safeEvidence = computed(() => ({
|
||||
items: Array.isArray(props.evidence?.items) ? props.evidence.items : [],
|
||||
chunkCount: Number(props.evidence?.chunkCount || 0)
|
||||
}))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.evidence-panel,
|
||||
.evidence-list,
|
||||
.evidence-chunks {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.evidence-head,
|
||||
.evidence-document-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.evidence-head strong {
|
||||
color: #0f172a;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.evidence-head span,
|
||||
.evidence-document-head small,
|
||||
.evidence-document-head span {
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.evidence-document {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
border: 1px solid #e5edf5;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.evidence-document-head {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.evidence-document-head div {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.evidence-document-head strong {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: #0f172a;
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.evidence-chunk {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
border: 1px solid #dbeafe;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.evidence-chunk span {
|
||||
color: #2563eb;
|
||||
font-size: 11px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.evidence-chunk p {
|
||||
margin: 0;
|
||||
color: #334155;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.evidence-chunk small {
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.evidence-empty {
|
||||
min-height: 96px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: 6px;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.evidence-empty.compact {
|
||||
min-height: 56px;
|
||||
padding: 8px;
|
||||
border: 1px dashed #cbd5e1;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
</style>
|
||||
195
web/src/components/logs/KnowledgeIngestRunInfo.vue
Normal file
195
web/src/components/logs/KnowledgeIngestRunInfo.vue
Normal file
@@ -0,0 +1,195 @@
|
||||
<template>
|
||||
<section class="ingest-run-info">
|
||||
<div class="info-title">
|
||||
<div>
|
||||
<span>基本信息</span>
|
||||
<h4>{{ model.folder || '未指定知识目录' }}</h4>
|
||||
</div>
|
||||
<strong :class="model.statusTone">{{ model.statusLabel }}</strong>
|
||||
</div>
|
||||
|
||||
<div class="info-grid">
|
||||
<div v-for="item in infoItems" :key="item.label" class="info-item">
|
||||
<span>{{ item.label }}</span>
|
||||
<strong>{{ item.value }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
run: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
model: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const infoItems = computed(() => {
|
||||
const routeJson = props.run?.route_json || {}
|
||||
const currentDocument = props.model.documents?.find(
|
||||
(item) => item.documentId === props.model.currentDocumentId
|
||||
)
|
||||
return [
|
||||
{ label: 'Trace ID', value: props.run?.run_id || '-' },
|
||||
{ label: '任务类型', value: resolveJobType(routeJson.job_type) },
|
||||
{ label: '触发来源', value: resolveSource(props.run?.source) },
|
||||
{ label: '当前阶段', value: props.model.phaseLabel || '-' },
|
||||
{ label: '开始时间', value: formatDateTime(props.run?.started_at) },
|
||||
{ label: '结束时间', value: formatDateTime(props.run?.finished_at) },
|
||||
{ label: '执行耗时', value: formatElapsed(props.run?.started_at, props.run?.finished_at) },
|
||||
{ label: '当前文件', value: currentDocument?.name || '-' }
|
||||
]
|
||||
})
|
||||
|
||||
function resolveJobType(value) {
|
||||
const jobType = String(value || '').trim()
|
||||
if (jobType === 'knowledge_index_sync') return 'LightRAG 知识归纳'
|
||||
if (jobType === 'llm_wiki_sync') return 'LLM Wiki 知识归纳'
|
||||
return jobType || '-'
|
||||
}
|
||||
|
||||
function resolveSource(value) {
|
||||
const source = String(value || '').trim()
|
||||
if (source === 'manual') return '手动触发'
|
||||
if (source === 'scheduled') return '定时任务'
|
||||
if (source === 'system') return '系统任务'
|
||||
return source || '-'
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) return '-'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return String(value)
|
||||
return date.toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
})
|
||||
}
|
||||
|
||||
function formatElapsed(startedAt, finishedAt) {
|
||||
const started = new Date(startedAt || '')
|
||||
const finished = finishedAt ? new Date(finishedAt) : new Date()
|
||||
if (Number.isNaN(started.getTime()) || Number.isNaN(finished.getTime())) return '-'
|
||||
const seconds = Math.max(0, Math.round((finished.getTime() - started.getTime()) / 1000))
|
||||
if (seconds < 60) return `${seconds}s`
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const restSeconds = seconds % 60
|
||||
if (minutes < 60) return `${minutes}m ${restSeconds}s`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
return `${hours}h ${minutes % 60}m`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ingest-run-info {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
border: 1px solid #dbe6ef;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.info-title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.info-title span {
|
||||
color: #0f766e;
|
||||
font-size: 12px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.info-title h4 {
|
||||
margin: 4px 0 0;
|
||||
color: #0f172a;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.info-title > strong {
|
||||
align-self: flex-start;
|
||||
min-height: 26px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.info-title > strong.success {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.info-title > strong.warning {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.info-title > strong.danger {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.info-title > strong.muted {
|
||||
background: #eef2f7;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.info-item span {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.info-item strong {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: #0f172a;
|
||||
font-size: 13px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.info-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.info-title {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -16,184 +16,18 @@
|
||||
<span :style="{ width: `${model.progress.percent}%` }"></span>
|
||||
</div>
|
||||
|
||||
<div class="metric-strip">
|
||||
<div v-for="metric in model.metrics" :key="metric.label" class="metric-tile">
|
||||
<span>{{ metric.label }}</span>
|
||||
<strong>{{ metric.value }}</strong>
|
||||
<small>{{ metric.hint }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<KnowledgeIngestRunInfo :run="props.run" :model="model" />
|
||||
|
||||
<div class="ingest-workspace">
|
||||
<aside class="file-rail">
|
||||
<button
|
||||
v-for="document in model.documents"
|
||||
:key="document.documentId"
|
||||
type="button"
|
||||
class="file-item"
|
||||
:class="{ active: selectedDocumentId === document.documentId }"
|
||||
@click="selectDocument(document.documentId)"
|
||||
>
|
||||
<i :class="documentIcon(document)"></i>
|
||||
<span class="file-copy">
|
||||
<strong>{{ document.name }}</strong>
|
||||
<small>
|
||||
{{ document.phaseLabel }} · {{ document.chunkCount }} chunk
|
||||
</small>
|
||||
</span>
|
||||
<span class="mini-status" :class="document.statusTone">
|
||||
{{ document.statusLabel }}
|
||||
</span>
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<section v-if="selectedDocument" class="file-detail">
|
||||
<div class="detail-topline">
|
||||
<div>
|
||||
<h4>{{ selectedDocument.name }}</h4>
|
||||
<p>
|
||||
{{ selectedDocument.folder || '根目录' }}
|
||||
<span v-if="selectedDocument.extension"> · {{ selectedDocument.extension }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<span class="status-chip" :class="selectedDocument.statusTone">
|
||||
{{ selectedDocument.statusLabel }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="detail-stats">
|
||||
<div>
|
||||
<span>原文字符</span>
|
||||
<strong>{{ formatKnowledgeMetric(selectedDocument.textChars) }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>索引字符</span>
|
||||
<strong>{{ formatKnowledgeMetric(selectedDocument.indexedTextChars) }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Chunk</span>
|
||||
<strong>{{ formatKnowledgeMetric(selectedDocument.chunkCount) }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>实体 / 关系</span>
|
||||
<strong>
|
||||
{{ formatKnowledgeMetric(selectedDocument.entityCount) }}
|
||||
/
|
||||
{{ formatKnowledgeMetric(selectedDocument.relationCount) }}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="selectedDocument.error" class="error-note">
|
||||
{{ selectedDocument.error }}
|
||||
</p>
|
||||
|
||||
<div class="detail-section-grid">
|
||||
<section class="detail-section">
|
||||
<div class="section-head">
|
||||
<h5>Chunk 信息</h5>
|
||||
<span>{{ selectedDocument.chunks.length }} 条</span>
|
||||
</div>
|
||||
<div v-if="selectedDocument.chunks.length" class="chunk-list">
|
||||
<div v-for="chunk in selectedDocument.chunks" :key="chunk.id" class="chunk-row">
|
||||
<span class="chunk-index">#{{ chunk.order + 1 }}</span>
|
||||
<div>
|
||||
<strong>{{ compactId(chunk.id) }}</strong>
|
||||
<p>{{ chunk.summary || '暂无摘要' }}</p>
|
||||
</div>
|
||||
<small>{{ chunk.tokens }} tokens</small>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="compact-empty">暂无 chunk 明细</div>
|
||||
</section>
|
||||
|
||||
<section class="detail-section">
|
||||
<div class="section-head">
|
||||
<h5>章节提取</h5>
|
||||
<span>{{ selectedDocument.sectionCount }} 条</span>
|
||||
</div>
|
||||
<div v-if="selectedDocument.sections.length" class="section-list">
|
||||
<div
|
||||
v-for="section in selectedDocument.sections"
|
||||
:key="section.title"
|
||||
class="section-row"
|
||||
>
|
||||
<strong>{{ section.title }}</strong>
|
||||
<p>{{ section.excerpt || '暂无章节摘要' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="compact-empty">暂无章节信息</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="detail-section">
|
||||
<div class="section-head">
|
||||
<h5>处理事件</h5>
|
||||
<span>{{ selectedDocument.events.length }} 条</span>
|
||||
</div>
|
||||
<div v-if="selectedDocument.events.length" class="event-list">
|
||||
<div
|
||||
v-for="event in selectedDocument.events"
|
||||
:key="`${event.at}-${event.message}`"
|
||||
class="event-row"
|
||||
:class="event.level"
|
||||
>
|
||||
<span></span>
|
||||
<div>
|
||||
<strong>{{ formatEventTime(event.at) }}</strong>
|
||||
<p>{{ event.message }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="compact-empty">暂无处理事件</div>
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="graph-section">
|
||||
<div class="section-head">
|
||||
<h4>图谱形成</h4>
|
||||
<span>
|
||||
{{ formatKnowledgeMetric(model.graph.entityCount) }} 实体 ·
|
||||
{{ formatKnowledgeMetric(model.graph.relationCount) }} 关系
|
||||
</span>
|
||||
</div>
|
||||
<div class="graph-grid">
|
||||
<div class="graph-pane">
|
||||
<h5>实体</h5>
|
||||
<div v-if="model.graph.entities.length" class="entity-cloud">
|
||||
<span v-for="entity in model.graph.entities" :key="entity">{{ entity }}</span>
|
||||
</div>
|
||||
<div v-else class="compact-empty">暂无实体</div>
|
||||
</div>
|
||||
<div class="graph-pane">
|
||||
<h5>关系</h5>
|
||||
<div v-if="model.graph.relations.length" class="relation-list">
|
||||
<div
|
||||
v-for="relation in model.graph.relations"
|
||||
:key="`${relation.source}-${relation.target}-${relation.type}`"
|
||||
class="relation-row"
|
||||
>
|
||||
<strong>{{ relation.source }}</strong>
|
||||
<i class="mdi mdi-arrow-right-thin"></i>
|
||||
<strong>{{ relation.target }}</strong>
|
||||
<span>{{ relation.type }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="compact-empty">暂无关系</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<KnowledgeIngestGraphView :graph="model.graph" />
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import {
|
||||
buildKnowledgeIngestLogModel,
|
||||
formatKnowledgeMetric
|
||||
} from '../../utils/knowledgeIngestLogModel.js'
|
||||
import KnowledgeIngestGraphView from './KnowledgeIngestGraphView.vue'
|
||||
import KnowledgeIngestRunInfo from './KnowledgeIngestRunInfo.vue'
|
||||
import { buildKnowledgeIngestLogModel } from '../../utils/knowledgeIngestLogModel.js'
|
||||
|
||||
const props = defineProps({
|
||||
run: {
|
||||
@@ -202,63 +36,18 @@ const props = defineProps({
|
||||
}
|
||||
})
|
||||
|
||||
const selectedDocumentId = ref('')
|
||||
const model = computed(() => buildKnowledgeIngestLogModel(props.run))
|
||||
const selectedDocument = computed(
|
||||
() => model.value.documents.find((item) => item.documentId === selectedDocumentId.value) || null
|
||||
)
|
||||
|
||||
watch(
|
||||
() => model.value.selectedDocumentId,
|
||||
(nextDocumentId) => {
|
||||
if (!nextDocumentId) {
|
||||
selectedDocumentId.value = ''
|
||||
return
|
||||
}
|
||||
if (!selectedDocumentId.value || !model.value.documents.some((item) => item.documentId === selectedDocumentId.value)) {
|
||||
selectedDocumentId.value = nextDocumentId
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function selectDocument(documentId) {
|
||||
selectedDocumentId.value = documentId
|
||||
}
|
||||
|
||||
function documentIcon(document) {
|
||||
const extension = String(document?.extension || '').toLowerCase()
|
||||
if (extension === 'pdf') return 'mdi mdi-file-pdf-box'
|
||||
if (['doc', 'docx'].includes(extension)) return 'mdi mdi-file-word-box'
|
||||
if (['xls', 'xlsx', 'csv'].includes(extension)) return 'mdi mdi-file-excel-box'
|
||||
if (['ppt', 'pptx'].includes(extension)) return 'mdi mdi-file-powerpoint-box'
|
||||
return 'mdi mdi-file-document-outline'
|
||||
}
|
||||
|
||||
function compactId(value) {
|
||||
const text = String(value || '').trim()
|
||||
if (text.length <= 18) return text || 'chunk'
|
||||
return `${text.slice(0, 8)}...${text.slice(-6)}`
|
||||
}
|
||||
|
||||
function formatEventTime(value) {
|
||||
if (!value) return '刚刚'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return String(value)
|
||||
return date.toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.knowledge-ingest-panel {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto auto minmax(1080px, 1fr);
|
||||
gap: 14px;
|
||||
height: clamp(1500px, calc(100dvh + 860px), 1880px);
|
||||
min-height: 1500px;
|
||||
padding: 18px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ingest-head {
|
||||
@@ -328,381 +117,15 @@ function formatEventTime(value) {
|
||||
transition: width 0.24s ease;
|
||||
}
|
||||
|
||||
.metric-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.metric-tile {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 11px 12px;
|
||||
border: 1px solid #e5edf5;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.metric-tile span,
|
||||
.metric-tile small {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.metric-tile strong {
|
||||
color: #0f172a;
|
||||
font-size: 18px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.ingest-workspace {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(230px, 0.85fr) minmax(0, 2fr);
|
||||
gap: 14px;
|
||||
min-height: 360px;
|
||||
}
|
||||
|
||||
.file-rail {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.file-item {
|
||||
width: 100%;
|
||||
min-height: 58px;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
border: 1px solid #e5edf5;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.file-item.active {
|
||||
border-color: rgba(15, 118, 110, 0.38);
|
||||
background: #f0fdfa;
|
||||
box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.08);
|
||||
}
|
||||
|
||||
.file-item > i {
|
||||
color: #334155;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.file-copy {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.file-copy strong,
|
||||
.file-copy small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.file-copy strong {
|
||||
color: #0f172a;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.file-copy small {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.mini-status,
|
||||
.status-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 24px;
|
||||
padding: 0 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mini-status.success,
|
||||
.status-chip.success {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.mini-status.warning,
|
||||
.status-chip.warning {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.mini-status.danger,
|
||||
.status-chip.danger {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.mini-status.muted,
|
||||
.status-chip.muted {
|
||||
background: #eef2f7;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.file-detail {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.detail-topline {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid #e5edf5;
|
||||
}
|
||||
|
||||
.detail-topline h4 {
|
||||
margin: 0;
|
||||
color: #0f172a;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.detail-topline p {
|
||||
margin: 5px 0 0;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.detail-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.detail-stats div {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.detail-stats span {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.detail-stats strong {
|
||||
color: #0f172a;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.error-note {
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 8px;
|
||||
background: #fff1f2;
|
||||
color: #991b1b;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.detail-section-grid,
|
||||
.graph-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.detail-section,
|
||||
.graph-section,
|
||||
.graph-pane {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.section-head h4,
|
||||
.section-head h5,
|
||||
.graph-pane h5 {
|
||||
margin: 0;
|
||||
color: #0f172a;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.section-head span {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.chunk-list,
|
||||
.section-list,
|
||||
.event-list,
|
||||
.relation-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chunk-row,
|
||||
.section-row,
|
||||
.event-row,
|
||||
.relation-row {
|
||||
min-width: 0;
|
||||
border: 1px solid #e5edf5;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.chunk-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.chunk-index {
|
||||
color: #2563eb;
|
||||
font-size: 12px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.chunk-row strong,
|
||||
.section-row strong,
|
||||
.event-row strong,
|
||||
.relation-row strong {
|
||||
color: #0f172a;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.chunk-row p,
|
||||
.section-row p,
|
||||
.event-row p {
|
||||
margin: 4px 0 0;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.chunk-row small {
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.section-row {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.event-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.event-row > span {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin-top: 5px;
|
||||
border-radius: 999px;
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.event-row.error > span {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
.entity-cloud {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.entity-cloud span {
|
||||
max-width: 100%;
|
||||
padding: 5px 9px;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 999px;
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
font-size: 12px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.relation-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 9px 10px;
|
||||
}
|
||||
|
||||
.relation-row strong {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.relation-row i {
|
||||
color: #0f766e;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.relation-row span {
|
||||
padding: 3px 7px;
|
||||
border-radius: 999px;
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.compact-empty {
|
||||
min-height: 42px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px dashed #cbd5e1;
|
||||
border-radius: 8px;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.metric-strip,
|
||||
.detail-stats,
|
||||
.detail-section-grid,
|
||||
.graph-grid,
|
||||
.ingest-workspace {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.file-rail {
|
||||
max-height: 260px;
|
||||
overflow: auto;
|
||||
.knowledge-ingest-panel {
|
||||
height: clamp(1460px, calc(100dvh + 840px), 1840px);
|
||||
min-height: 1460px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.ingest-head,
|
||||
.detail-topline {
|
||||
.ingest-head {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@@ -711,17 +134,9 @@ function formatEventTime(value) {
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.file-item {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.mini-status {
|
||||
grid-column: 2;
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.relation-row {
|
||||
grid-template-columns: 1fr;
|
||||
.knowledge-ingest-panel {
|
||||
height: clamp(1520px, calc(100dvh + 900px), 1900px);
|
||||
min-height: 1520px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
415
web/src/components/logs/useKnowledgeIngestGraph.js
Normal file
415
web/src/components/logs/useKnowledgeIngestGraph.js
Normal file
@@ -0,0 +1,415 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
const MAX_VISIBLE_NODES = 72
|
||||
const MAX_VISIBLE_EDGES = 180
|
||||
const MAX_RELATION_PREVIEW = 40
|
||||
|
||||
const NODE_TONES = {
|
||||
hub: {
|
||||
fill: '#2563eb',
|
||||
stroke: '#dbeafe',
|
||||
halo: '#93c5fd',
|
||||
shadow: 'rgba(37, 99, 235, 0.20)'
|
||||
},
|
||||
strong: {
|
||||
fill: '#0f766e',
|
||||
stroke: '#ccfbf1',
|
||||
halo: '#5eead4',
|
||||
shadow: 'rgba(15, 118, 110, 0.18)'
|
||||
},
|
||||
accent: {
|
||||
fill: '#d97706',
|
||||
stroke: '#fef3c7',
|
||||
halo: '#fbbf24',
|
||||
shadow: 'rgba(217, 119, 6, 0.16)'
|
||||
},
|
||||
normal: {
|
||||
fill: '#4f46e5',
|
||||
stroke: '#e0e7ff',
|
||||
halo: '#a5b4fc',
|
||||
shadow: 'rgba(79, 70, 229, 0.16)'
|
||||
},
|
||||
muted: {
|
||||
fill: '#64748b',
|
||||
stroke: '#e2e8f0',
|
||||
halo: '#cbd5e1',
|
||||
shadow: 'rgba(100, 116, 139, 0.12)'
|
||||
}
|
||||
}
|
||||
|
||||
export function useKnowledgeIngestGraph(props) {
|
||||
const graphQuery = ref('')
|
||||
const activeNodeId = ref('')
|
||||
|
||||
const allRelations = computed(() => normalizeRelations(props.graph?.relations))
|
||||
const rankedNodes = computed(() => buildRankedNodes(props.graph, allRelations.value))
|
||||
const visibleNodes = computed(() => {
|
||||
const query = graphQuery.value.toLowerCase()
|
||||
return rankedNodes.value.slice(0, MAX_VISIBLE_NODES).map((node, index) => ({
|
||||
...node,
|
||||
rank: index + 1,
|
||||
matchesQuery: query ? node.name.toLowerCase().includes(query) : true
|
||||
}))
|
||||
})
|
||||
const visibleNodeNameSet = computed(() => new Set(visibleNodes.value.map((node) => node.name)))
|
||||
const visibleRelations = computed(() =>
|
||||
allRelations.value
|
||||
.filter(
|
||||
(relation) =>
|
||||
visibleNodeNameSet.value.has(relation.source) && visibleNodeNameSet.value.has(relation.target)
|
||||
)
|
||||
.slice(0, MAX_VISIBLE_EDGES)
|
||||
)
|
||||
const nodeIdByName = computed(() =>
|
||||
new Map(visibleNodes.value.map((node) => [node.name, node.id]))
|
||||
)
|
||||
const selectedNode = computed(() => {
|
||||
if (!visibleNodes.value.length) return null
|
||||
return visibleNodes.value.find((node) => node.id === activeNodeId.value) || visibleNodes.value[0]
|
||||
})
|
||||
const selectedNodeRelations = computed(() => {
|
||||
if (!selectedNode.value) return []
|
||||
return allRelations.value
|
||||
.filter(
|
||||
(relation) =>
|
||||
relation.source === selectedNode.value.name || relation.target === selectedNode.value.name
|
||||
)
|
||||
.slice(0, MAX_RELATION_PREVIEW)
|
||||
})
|
||||
const selectedNodeIncoming = computed(() =>
|
||||
selectedNodeRelations.value.filter((relation) => relation.target === selectedNode.value?.name)
|
||||
)
|
||||
const selectedNodeOutgoing = computed(() =>
|
||||
selectedNodeRelations.value.filter((relation) => relation.source === selectedNode.value?.name)
|
||||
)
|
||||
const graphData = computed(() => ({
|
||||
nodes: visibleNodes.value.map((node) => toG6Node(node)),
|
||||
edges: visibleRelations.value
|
||||
.map((relation, index) => toG6Edge(relation, index, nodeIdByName.value))
|
||||
.filter(Boolean)
|
||||
}))
|
||||
const graphSummary = computed(() => ({
|
||||
entityCount: formatNumber(resolveCount(props.graph?.entityCount, props.graph?.entity_count, rankedNodes.value.length)),
|
||||
relationCount: formatNumber(resolveCount(props.graph?.relationCount, props.graph?.relation_count, allRelations.value.length)),
|
||||
visibleNodeCount: formatNumber(visibleNodes.value.length),
|
||||
visibleEdgeCount: formatNumber(visibleRelations.value.length)
|
||||
}))
|
||||
|
||||
watch(
|
||||
visibleNodes,
|
||||
(nextNodes) => {
|
||||
if (!nextNodes.length) {
|
||||
activeNodeId.value = ''
|
||||
return
|
||||
}
|
||||
if (!nextNodes.some((node) => node.id === activeNodeId.value)) {
|
||||
activeNodeId.value = nextNodes[0].id
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function selectNodeById(nodeId) {
|
||||
const matchedNode = visibleNodes.value.find((node) => node.id === nodeId)
|
||||
if (!matchedNode) return ''
|
||||
activeNodeId.value = matchedNode.id
|
||||
return matchedNode.id
|
||||
}
|
||||
|
||||
function focusFirstMatch() {
|
||||
const matchedNode = visibleNodes.value.find((node) => node.matchesQuery)
|
||||
if (!matchedNode) return ''
|
||||
activeNodeId.value = matchedNode.id
|
||||
return matchedNode.id
|
||||
}
|
||||
|
||||
function selectRelationPeer(relation) {
|
||||
if (!selectedNode.value) return ''
|
||||
const peerName = relation.source === selectedNode.value.name ? relation.target : relation.source
|
||||
const peerId = nodeIdByName.value.get(peerName)
|
||||
if (!peerId) return ''
|
||||
activeNodeId.value = peerId
|
||||
return peerId
|
||||
}
|
||||
|
||||
return {
|
||||
activeNodeId,
|
||||
graphData,
|
||||
graphQuery,
|
||||
graphSummary,
|
||||
selectNodeById,
|
||||
focusFirstMatch,
|
||||
selectRelationPeer,
|
||||
selectedNode,
|
||||
selectedNodeIncoming,
|
||||
selectedNodeOutgoing,
|
||||
selectedNodeRelations,
|
||||
truncateText,
|
||||
visibleNodes,
|
||||
visibleRelations
|
||||
}
|
||||
}
|
||||
|
||||
function buildRankedNodes(graph, relations) {
|
||||
const degreeMap = new Map()
|
||||
for (const relation of relations) {
|
||||
degreeMap.set(relation.source, (degreeMap.get(relation.source) || 0) + 1)
|
||||
degreeMap.set(relation.target, (degreeMap.get(relation.target) || 0) + 1)
|
||||
}
|
||||
|
||||
const byName = new Map()
|
||||
for (const entity of normalizeEntities(graph?.entities)) {
|
||||
byName.set(entity.name, entity)
|
||||
}
|
||||
for (const relation of relations) {
|
||||
if (!byName.has(relation.source)) byName.set(relation.source, { name: relation.source, type: '关系实体' })
|
||||
if (!byName.has(relation.target)) byName.set(relation.target, { name: relation.target, type: '关系实体' })
|
||||
}
|
||||
|
||||
return [...byName.values()]
|
||||
.map((entity) => ({
|
||||
...entity,
|
||||
id: toNodeId(entity.name),
|
||||
degree: degreeMap.get(entity.name) || 0
|
||||
}))
|
||||
.sort((left, right) => {
|
||||
const degreeDelta = right.degree - left.degree
|
||||
if (degreeDelta !== 0) return degreeDelta
|
||||
return left.name.localeCompare(right.name, 'zh-CN')
|
||||
})
|
||||
}
|
||||
|
||||
function toG6Node(node) {
|
||||
const tone = resolveNodeTone(node)
|
||||
const palette = NODE_TONES[tone]
|
||||
const size = clamp(34 + Math.sqrt(Math.max(node.degree, 1)) * 13, 38, node.rank === 1 ? 82 : 70)
|
||||
const opacity = node.matchesQuery ? 1 : 0.24
|
||||
|
||||
return {
|
||||
id: node.id,
|
||||
data: {
|
||||
name: node.name,
|
||||
type: node.type || '实体',
|
||||
description: node.description || '',
|
||||
descriptions: node.descriptions || [],
|
||||
properties: node.properties || {},
|
||||
labels: node.labels || [],
|
||||
degree: node.degree,
|
||||
rank: node.rank,
|
||||
matchesQuery: node.matchesQuery,
|
||||
tone
|
||||
},
|
||||
style: {
|
||||
size,
|
||||
fill: palette.fill,
|
||||
stroke: palette.stroke,
|
||||
lineWidth: 2,
|
||||
opacity,
|
||||
shadowColor: palette.shadow,
|
||||
shadowBlur: 14,
|
||||
label: true,
|
||||
labelText: truncateText(node.name, node.rank === 1 ? 16 : 12),
|
||||
labelFill: '#0f172a',
|
||||
labelFontSize: node.rank === 1 ? 13 : 12,
|
||||
labelFontWeight: 850,
|
||||
labelPlacement: 'bottom',
|
||||
labelOffsetY: 10,
|
||||
labelBackground: true,
|
||||
labelBackgroundFill: 'rgba(255, 255, 255, 0.94)',
|
||||
labelBackgroundStroke: 'rgba(203, 213, 225, 0.95)',
|
||||
labelBackgroundLineWidth: 1,
|
||||
labelBackgroundRadius: 4,
|
||||
labelBackgroundPadding: [2, 6],
|
||||
halo: true,
|
||||
haloStroke: palette.halo,
|
||||
haloLineWidth: 10,
|
||||
haloStrokeOpacity: 0.16,
|
||||
badge: node.degree > 0,
|
||||
badges: [
|
||||
{
|
||||
text: String(node.degree),
|
||||
placement: 'right-top',
|
||||
fill: 'rgba(255, 255, 255, 0.94)',
|
||||
stroke: 'rgba(37, 99, 235, 0.28)',
|
||||
color: '#1e293b',
|
||||
fontSize: 10,
|
||||
padding: [2, 5]
|
||||
}
|
||||
]
|
||||
},
|
||||
states: node.matchesQuery ? [] : ['dimmed']
|
||||
}
|
||||
}
|
||||
|
||||
function toG6Edge(relation, index, nodeIdByName) {
|
||||
const sourceId = nodeIdByName.get(relation.source)
|
||||
const targetId = nodeIdByName.get(relation.target)
|
||||
if (!sourceId || !targetId || sourceId === targetId) return null
|
||||
const weight = clamp(Number(relation.weight || relation.confidence || 1), 1, 6)
|
||||
|
||||
return {
|
||||
id: `edge-${index}-${sourceId}-${targetId}`,
|
||||
source: sourceId,
|
||||
target: targetId,
|
||||
data: {
|
||||
sourceName: relation.source,
|
||||
targetName: relation.target,
|
||||
type: relation.type,
|
||||
description: relation.description || '',
|
||||
keywords: relation.keywords || [],
|
||||
properties: relation.properties || {},
|
||||
weight
|
||||
},
|
||||
style: {
|
||||
stroke: 'rgba(37, 99, 235, 0.34)',
|
||||
lineWidth: clamp(1 + weight * 0.22, 1.2, 2.6),
|
||||
opacity: 0.9,
|
||||
endArrow: true,
|
||||
endArrowSize: 8,
|
||||
label: index < 42,
|
||||
labelText: truncateText(relation.type, 10),
|
||||
labelFill: '#0f172a',
|
||||
labelFontSize: 10,
|
||||
labelFontWeight: 800,
|
||||
labelBackground: true,
|
||||
labelBackgroundFill: 'rgba(255, 255, 255, 0.94)',
|
||||
labelBackgroundStroke: 'rgba(203, 213, 225, 0.95)',
|
||||
labelBackgroundLineWidth: 1,
|
||||
labelBackgroundRadius: 5,
|
||||
labelBackgroundPadding: [2, 5]
|
||||
},
|
||||
states: []
|
||||
}
|
||||
}
|
||||
|
||||
function resolveNodeTone(node) {
|
||||
if (node.rank === 1 || node.degree >= 6) return 'hub'
|
||||
if (node.degree >= 4) return 'strong'
|
||||
if (node.degree >= 2 && node.rank % 3 === 0) return 'accent'
|
||||
if (node.degree === 0) return 'muted'
|
||||
return 'normal'
|
||||
}
|
||||
|
||||
function normalizeRelations(rawRelations) {
|
||||
if (!Array.isArray(rawRelations)) return []
|
||||
const seen = new Set()
|
||||
return rawRelations
|
||||
.map((relation) => ({
|
||||
source: String(relation?.source || relation?.from || relation?.head || '').trim(),
|
||||
target: String(relation?.target || relation?.to || relation?.tail || '').trim(),
|
||||
type: String(relation?.type || relation?.relation || relation?.label || '关联').trim(),
|
||||
description: String(relation?.description || '').trim(),
|
||||
keywords: dedupeTextList(relation?.keywords || []),
|
||||
properties: normalizeProperties(relation?.properties),
|
||||
weight: relation?.weight ?? relation?.confidence ?? 1
|
||||
}))
|
||||
.filter((relation) => relation.source && relation.target)
|
||||
.filter((relation) => {
|
||||
const key = `${relation.source}\u0000${relation.target}\u0000${relation.type}`
|
||||
if (seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeEntities(rawEntities) {
|
||||
if (!Array.isArray(rawEntities)) return []
|
||||
const seen = new Set()
|
||||
return rawEntities
|
||||
.map((entity) => {
|
||||
if (typeof entity === 'string') {
|
||||
return {
|
||||
name: entity.trim(),
|
||||
type: '实体',
|
||||
description: '',
|
||||
descriptions: [],
|
||||
properties: {},
|
||||
labels: []
|
||||
}
|
||||
}
|
||||
return {
|
||||
name: String(
|
||||
entity?.name || entity?.entity || entity?.entity_id || entity?.title || entity?.id || ''
|
||||
).trim(),
|
||||
type: String(entity?.type || entity?.entity_type || entity?.category || entity?.kind || '实体').trim(),
|
||||
description: String(entity?.description || '').trim(),
|
||||
descriptions: normalizeDescriptions(entity),
|
||||
properties: normalizeProperties(entity?.properties),
|
||||
labels: dedupeTextList(entity?.labels || [])
|
||||
}
|
||||
})
|
||||
.filter((entity) => entity.name)
|
||||
.filter((entity) => {
|
||||
if (seen.has(entity.name)) return false
|
||||
seen.add(entity.name)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function toNodeId(value) {
|
||||
const text = String(value || '').trim()
|
||||
let hash = 0
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
hash = (hash << 5) - hash + text.charCodeAt(index)
|
||||
hash |= 0
|
||||
}
|
||||
return `node-${Math.abs(hash).toString(36)}`
|
||||
}
|
||||
|
||||
function normalizeDescriptions(entity) {
|
||||
const descriptions = dedupeTextList(entity?.descriptions)
|
||||
if (descriptions.length) return descriptions
|
||||
const description = String(entity?.description || '').trim()
|
||||
return description ? [description] : []
|
||||
}
|
||||
|
||||
function normalizeProperties(rawProperties) {
|
||||
if (!rawProperties || typeof rawProperties !== 'object' || Array.isArray(rawProperties)) {
|
||||
return {}
|
||||
}
|
||||
const hiddenKeys = new Set(['source_id', 'file_path', 'truncate'])
|
||||
return Object.fromEntries(
|
||||
Object.entries(rawProperties)
|
||||
.filter(([key, value]) => !hiddenKeys.has(key) && String(value || '').trim())
|
||||
.map(([key, value]) => [key, String(value).trim()])
|
||||
)
|
||||
}
|
||||
|
||||
function truncateText(value, maxLength) {
|
||||
const text = String(value || '').trim()
|
||||
if (text.length <= maxLength) return text
|
||||
return `${text.slice(0, maxLength - 1)}...`
|
||||
}
|
||||
|
||||
function resolveCount(...values) {
|
||||
for (const value of values) {
|
||||
const number = Number(value)
|
||||
if (Number.isFinite(number) && number >= 0) return number
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
return new Intl.NumberFormat('zh-CN').format(resolveCount(value))
|
||||
}
|
||||
|
||||
function dedupeTextList(items) {
|
||||
const result = []
|
||||
const seen = new Set()
|
||||
const sourceItems = Array.isArray(items)
|
||||
? items
|
||||
: String(items || '')
|
||||
.split('<SEP>')
|
||||
.filter(Boolean)
|
||||
for (const item of sourceItems) {
|
||||
const text = String(item || '').trim()
|
||||
if (!text || seen.has(text)) continue
|
||||
seen.add(text)
|
||||
result.push(text)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.max(min, Math.min(max, value))
|
||||
}
|
||||
Reference in New Issue
Block a user