feat(kb): 入库可视化做厚 —— 文件解析/知识抽取过程 + 力导向知识图谱
把"进度条"升级成可观测的入库工作台,回应三点诉求:解析过程、知识抽取过程、丰富图谱。 - contract: IngestEvent 加 Preview(解析文本预览)+ Triples[]TripleView(抽出的三元组)。 - 后端回流:rag.Ingest 抽实体阶段把 LLM 抽出的三元组实时回流(边出现边渲染); gateway 解析完成回流文件类型 + 文本预览片段。 - 前端 GraphView.tsx:零依赖自建力导向布局(斥力+边弹簧+居中静态收敛),实体=节点 按度着色(枢纽紫/关联青/叶子)、关系=带标签边、hover 高亮邻域、节点过多按度裁剪。 - 前端 KbView 重做:入库从"阶段徽标+进度条"→竖向时间线(解析预览/切块块/向量化进度/ 抽取知识三元组 chips + 实时小图谱逐步浮现);右侧知识图谱从扁平列表→GraphView, 入库完成自动刷新整库图谱。 验证(Preview):入库一段多事实文本 → 时间线逐阶段点亮、抽出 17 条三元组实时浮现、 右侧力导向图渲染 sundynix-agentix/知识库 为枢纽 + 带标签关系边。tsc+vite+后端 build 通过。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Network } from "lucide-react";
|
||||
import type { Triple } from "../lib/api";
|
||||
import { EmptyState } from "../ui";
|
||||
|
||||
interface GNode {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
deg: number;
|
||||
}
|
||||
interface GEdge {
|
||||
s: string;
|
||||
o: string;
|
||||
p: string;
|
||||
}
|
||||
|
||||
// layout 用一个轻量力导向模拟(斥力 + 边弹簧 + 居中)把三元组排成图。
|
||||
// 静态收敛(useMemo 内跑固定迭代),零依赖;节点过多时按度裁剪。
|
||||
function layout(triples: Triple[], W: number, H: number): { nodes: GNode[]; edges: GEdge[] } {
|
||||
const deg = new Map<string, number>();
|
||||
for (const t of triples) {
|
||||
if (!t.s || !t.o) continue;
|
||||
deg.set(t.s, (deg.get(t.s) ?? 0) + 1);
|
||||
deg.set(t.o, (deg.get(t.o) ?? 0) + 1);
|
||||
}
|
||||
// 裁剪:实体过多只留度最高的 N 个,保留两端都在集合内的边。
|
||||
let names = [...deg.keys()];
|
||||
const CAP = 60;
|
||||
if (names.length > CAP) {
|
||||
names = names.sort((a, b) => (deg.get(b)! - deg.get(a)!)).slice(0, CAP);
|
||||
}
|
||||
const keep = new Set(names);
|
||||
const edges = triples.filter((t) => keep.has(t.s) && keep.has(t.o)).map((t) => ({ s: t.s, o: t.o, p: t.p }));
|
||||
|
||||
const nodes = new Map<string, GNode>();
|
||||
const R = Math.min(W, H) * 0.36;
|
||||
names.forEach((n, i) => {
|
||||
const a = (2 * Math.PI * i) / names.length;
|
||||
// 初始撒在圆周上(确定性,避免每次重排抖动)。
|
||||
nodes.set(n, { id: n, x: W / 2 + Math.cos(a) * R, y: H / 2 + Math.sin(a) * R, deg: deg.get(n)! });
|
||||
});
|
||||
|
||||
const arr = [...nodes.values()];
|
||||
for (let it = 0; it < 320; it++) {
|
||||
// 斥力(库仑)
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
for (let j = i + 1; j < arr.length; j++) {
|
||||
const a = arr[i],
|
||||
b = arr[j];
|
||||
let dx = a.x - b.x,
|
||||
dy = a.y - b.y;
|
||||
let d2 = dx * dx + dy * dy;
|
||||
if (d2 < 1) {
|
||||
d2 = 1;
|
||||
dx = 1;
|
||||
}
|
||||
const d = Math.sqrt(d2);
|
||||
const f = 2600 / d2;
|
||||
a.x += (dx / d) * f;
|
||||
a.y += (dy / d) * f;
|
||||
b.x -= (dx / d) * f;
|
||||
b.y -= (dy / d) * f;
|
||||
}
|
||||
}
|
||||
// 边弹簧(理想长度 ~96)
|
||||
for (const e of edges) {
|
||||
const a = nodes.get(e.s)!,
|
||||
b = nodes.get(e.o)!;
|
||||
const dx = b.x - a.x,
|
||||
dy = b.y - a.y;
|
||||
const d = Math.sqrt(dx * dx + dy * dy) || 1;
|
||||
const f = (d - 96) * 0.012;
|
||||
a.x += (dx / d) * f;
|
||||
a.y += (dy / d) * f;
|
||||
b.x -= (dx / d) * f;
|
||||
b.y -= (dy / d) * f;
|
||||
}
|
||||
// 轻微居中 + 边界约束
|
||||
for (const a of arr) {
|
||||
a.x += (W / 2 - a.x) * 0.004;
|
||||
a.y += (H / 2 - a.y) * 0.004;
|
||||
a.x = Math.max(20, Math.min(W - 20, a.x));
|
||||
a.y = Math.max(16, Math.min(H - 16, a.y));
|
||||
}
|
||||
}
|
||||
return { nodes: arr, edges };
|
||||
}
|
||||
|
||||
function nodeColor(deg: number): { fill: string; text: string } {
|
||||
if (deg >= 4) return { fill: "#8b5cf6", text: "#ede9fe" }; // 枢纽:brand
|
||||
if (deg >= 2) return { fill: "#22d3ee", text: "#083344" }; // 次枢纽:accent
|
||||
return { fill: "#1a1f2d", text: "#cbd5e1" }; // 叶子
|
||||
}
|
||||
|
||||
// GraphView 把知识三元组渲染为力导向图(实体=节点,关系=带标签的边),hover 高亮邻域。
|
||||
export function GraphView({ triples, height = 360 }: { triples: Triple[]; height?: number }) {
|
||||
const W = 560;
|
||||
const H = height;
|
||||
const [hover, setHover] = useState<string | null>(null);
|
||||
const { nodes, edges } = useMemo(() => layout(triples, W, H), [triples, H]);
|
||||
const pos = useMemo(() => new Map(nodes.map((n) => [n.id, n])), [nodes]);
|
||||
|
||||
if (triples.length === 0) {
|
||||
return <EmptyState icon={Network} title="暂无图谱" desc="入库文本后,LLM 会抽取实体与关系,这里渲染为可交互的知识图谱。" />;
|
||||
}
|
||||
|
||||
const neighbors = (id: string) => {
|
||||
const s = new Set<string>([id]);
|
||||
for (const e of edges) {
|
||||
if (e.s === id) s.add(e.o);
|
||||
if (e.o === id) s.add(e.s);
|
||||
}
|
||||
return s;
|
||||
};
|
||||
const active = hover ? neighbors(hover) : null;
|
||||
const nodeOn = (id: string) => !active || active.has(id);
|
||||
const edgeOn = (e: GEdge) => !hover || e.s === hover || e.o === hover;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<svg viewBox={`0 0 ${W} ${H}`} className="w-full rounded-md border border-line bg-ink-950/60" style={{ height }}>
|
||||
{edges.map((e, i) => {
|
||||
const a = pos.get(e.s)!,
|
||||
b = pos.get(e.o)!;
|
||||
if (!a || !b) return null;
|
||||
const on = edgeOn(e);
|
||||
const mx = (a.x + b.x) / 2,
|
||||
my = (a.y + b.y) / 2;
|
||||
return (
|
||||
<g key={i} opacity={on ? 1 : 0.12}>
|
||||
<line x1={a.x} y1={a.y} x2={b.x} y2={b.y} stroke="#39435a" strokeWidth={1} />
|
||||
{on && (
|
||||
<text x={mx} y={my - 2} fill="#7c8aa5" fontSize={8.5} textAnchor="middle">
|
||||
{e.p}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{nodes.map((n) => {
|
||||
const c = nodeColor(n.deg);
|
||||
const r = Math.min(7 + n.deg * 1.6, 16);
|
||||
const on = nodeOn(n.id);
|
||||
return (
|
||||
<g
|
||||
key={n.id}
|
||||
opacity={on ? 1 : 0.2}
|
||||
onMouseEnter={() => setHover(n.id)}
|
||||
onMouseLeave={() => setHover(null)}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<circle cx={n.x} cy={n.y} r={r} fill={c.fill} stroke={hover === n.id ? "#fff" : "#0b0d12"} strokeWidth={hover === n.id ? 2 : 1.5} />
|
||||
<text x={n.x} y={n.y + r + 9} fill="#cbd5e1" fontSize={9.5} textAnchor="middle">
|
||||
{n.id.length > 10 ? n.id.slice(0, 10) + "…" : n.id}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
<div className="flex items-center gap-3 px-1 text-[10px] text-slate-500">
|
||||
<span>{nodes.length} 实体 · {edges.length} 关系</span>
|
||||
<span className="flex items-center gap-1"><span className="h-2 w-2 rounded-full" style={{ background: "#8b5cf6" }} /> 枢纽</span>
|
||||
<span className="flex items-center gap-1"><span className="h-2 w-2 rounded-full" style={{ background: "#22d3ee" }} /> 关联</span>
|
||||
<span className="ml-auto">悬停高亮邻域</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user