diff --git a/sundynix-desktop/frontend/src/components/GraphView.tsx b/sundynix-desktop/frontend/src/components/GraphView.tsx new file mode 100644 index 0000000..0f7740b --- /dev/null +++ b/sundynix-desktop/frontend/src/components/GraphView.tsx @@ -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(); + 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(); + 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(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 ; + } + + const neighbors = (id: string) => { + const s = new Set([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 ( +
+ + {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 ( + + + {on && ( + + {e.p} + + )} + + ); + })} + {nodes.map((n) => { + const c = nodeColor(n.deg); + const r = Math.min(7 + n.deg * 1.6, 16); + const on = nodeOn(n.id); + return ( + setHover(n.id)} + onMouseLeave={() => setHover(null)} + style={{ cursor: "pointer" }} + > + + + {n.id.length > 10 ? n.id.slice(0, 10) + "…" : n.id} + + + ); + })} + +
+ {nodes.length} 实体 · {edges.length} 关系 + 枢纽 + 关联 + 悬停高亮邻域 +
+
+ ); +} diff --git a/sundynix-desktop/frontend/src/lib/api.ts b/sundynix-desktop/frontend/src/lib/api.ts index cdd4a53..f64f572 100644 --- a/sundynix-desktop/frontend/src/lib/api.ts +++ b/sundynix-desktop/frontend/src/lib/api.ts @@ -86,6 +86,8 @@ export interface IngestEvent { done?: number; total?: number; chunks?: string[]; + preview?: string; // 解析阶段:解析出的文本片段 + triples?: Triple[]; // 抽实体阶段:LLM 抽出的知识三元组 error?: string; } diff --git a/sundynix-desktop/frontend/src/views/KbView.tsx b/sundynix-desktop/frontend/src/views/KbView.tsx index 349960e..6fbace9 100644 --- a/sundynix-desktop/frontend/src/views/KbView.tsx +++ b/sundynix-desktop/frontend/src/views/KbView.tsx @@ -1,6 +1,21 @@ import { useRef, useState } from "react"; -import { Upload, Search, Network, FileUp } from "lucide-react"; +import { + Upload, + FileUp, + Search, + Network, + Database, + FileText, + Scissors, + Sparkles, + Share2, + CheckCircle2, + XCircle, + Loader2, + type LucideIcon, +} from "lucide-react"; import { ingestKb, ingestFile, streamIngest, searchKb, graphKb, type IngestEvent, type KbHit, type Triple } from "../lib/api"; +import { GraphView } from "../components/GraphView"; import { Button, Input, Textarea, Badge, cn, useToast } from "../ui"; interface IngestLog { @@ -8,17 +23,50 @@ interface IngestLog { msg: string; ok: boolean; } - +interface Step { + stage: string; + msg: string; +} interface Progress { active: boolean; stage: string; - done?: number; - total?: number; chunks: string[]; + preview?: string; + triples: Triple[]; error?: string; + steps: Step[]; + vecDone?: number; + vecTotal?: number; } -// 知识库管理:实时入库监控(解析→切块→向量化→写入 + 拆分可视化)+ 检索调试台。 +// 阶段元数据:图标 + 中文标签(与后端 IngestEvent.stage 对应)。 +const STAGE: Record = { + 解析: { icon: Upload, label: "解析文件" }, + 解析完成: { icon: FileText, label: "解析完成" }, + 切块: { icon: Scissors, label: "切块" }, + 向量化: { icon: Sparkles, label: "向量化" }, + 写Milvus: { icon: Database, label: "写入向量库" }, + 写Bleve: { icon: Search, label: "写入全文索引" }, + 抽实体: { icon: Network, label: "抽取知识" }, + 写Neo4j: { icon: Share2, label: "写入图谱" }, + 完成: { icon: CheckCircle2, label: "完成" }, + 失败: { icon: XCircle, label: "失败" }, +}; + +function dedupTriples(ts: Triple[]): Triple[] { + const seen = new Set(); + const out: Triple[] = []; + for (const t of ts) { + const k = `${t.s}|${t.p}|${t.o}`; + if (!seen.has(k)) { + seen.add(k); + out.push(t); + } + } + return out; +} + +// 知识库管理:实时入库时间线(解析预览 / 切块 / 向量化 / 知识抽取实时浮现)+ 力导向知识图谱 + 混合检索。 export function KbView() { const toast = useToast(); const [kb, setKb] = useState("docs"); @@ -46,25 +94,42 @@ export function KbView() { const ingesting = prog?.active ?? false; const follow = (job: string, label: string) => { - setProg({ active: true, stage: "提交", chunks: [] }); + setProg({ active: true, stage: "提交", chunks: [], triples: [], steps: [{ stage: "提交", msg: label }] }); streamIngest( job, (ev: IngestEvent) => - setProg((p) => ({ - active: ev.stage !== "完成" && ev.stage !== "失败", - stage: ev.stage, - done: ev.done ?? p?.done, - total: ev.total ?? p?.total, - chunks: ev.chunks ?? p?.chunks ?? [], - error: ev.error, - })), - () => + setProg((p) => { + const base: Progress = p ?? { active: true, stage: "提交", chunks: [], triples: [], steps: [] }; + const steps = [...base.steps]; + const i = steps.findIndex((s) => s.stage === ev.stage); + const msg = ev.msg ?? (i >= 0 ? steps[i].msg : ""); + if (i >= 0) steps[i] = { stage: ev.stage, msg }; + else steps.push({ stage: ev.stage, msg }); + return { + active: ev.stage !== "完成" && ev.stage !== "失败", + stage: ev.stage, + chunks: ev.chunks ?? base.chunks, + preview: ev.preview ?? base.preview, + triples: ev.triples?.length ? dedupTriples([...base.triples, ...ev.triples]) : base.triples, + error: ev.error, + steps, + vecDone: ev.stage === "向量化" ? ev.done : base.vecDone, + vecTotal: ev.stage === "向量化" ? ev.total : base.vecTotal, + }; + }), + () => { setProg((p) => { const ok = p?.stage !== "失败"; - setLogs((l) => [{ t: stamp(), msg: ok ? `${label}:${p?.total ?? 0} 块入库完成` : `${label}:${p?.error ?? "失败"}`, ok }, ...l]); - toast.push(ok ? "success" : "error", ok ? `${label} 入库完成` : `${label} 入库失败`); + setLogs((l) => [{ t: stamp(), msg: ok ? `${label}:入库完成` : `${label}:${p?.error ?? "失败"}`, ok }, ...l]); + if (ok) { + toast.push("success", `${label} 入库完成`); + void onGraph(); // 刷新右侧知识图谱(含新抽取的实体关系) + } else { + toast.push("error", `${label} 入库失败`); + } return p ? { ...p, active: false } : null; - }), + }); + }, () => setProg((p) => (p ? { ...p, active: false, stage: "连接中断" } : null)), ); }; @@ -105,19 +170,20 @@ export function KbView() { } }; - const pct = prog?.total ? Math.round(((prog.done ?? 0) / prog.total) * 100) : 0; + const vecPct = prog?.vecTotal ? Math.round(((prog.vecDone ?? 0) / prog.vecTotal) * 100) : 0; + const graphData = graph ?? prog?.triples ?? null; return (
知识库 setKb(e.target.value)} placeholder="知识库名" title="知识库(Milvus kb 字段分区)" /> - 入库 → 解析 / 切块 / 向量化 / 写入;检索 → 混合召回 + 入库 → 解析 / 切块 / 向量化 / 抽取知识 / 写入;检索 → 三路混合召回
- {/* 左:入库 + 实时监控 */} -
+ {/* 左:入库 + 实时时间线 */} +

入库

{ @@ -133,7 +199,7 @@ export function KbView() { }} className={cn("relative rounded-md", dragOver && "ring-2 ring-brand")} > -