feat(kb): 批量文件入库(文件列表) + 项目/案件知识库 + owner 作用域隔离

回应三点诉求:一次入一批文件、按文件夹/项目/案件组织、且只有我能查我的库。

隔离(核心):知识库实际分区键 = "owner/name",owner 由网关从 X-User-ID 注入,
客户端只发库名、发不了 owner —— 故任何人都只能查到自己 owner 前缀下的数据。
- gateway: scopedKB(owner/kb) 注入 ingest/search/graph;ingest/search/graph 全部带身份头。
- store: sundynix_kb 注册表(owner+name 唯一 + kind),ListKB/EnsureKB(OnConflict DoNothing)。

项目/案件组织:
- gateway: GET /kb/list(owner 隔离列表)、POST /kb/create(folder/project/case/general);
  入库时 EnsureKB 自动登记。
- 前端: KbView 顶部知识库下拉 + 新建(项目/案件/文件夹/通用),检索/图谱/入库都绑定所选库。

批量文件:
- 前端: 选择文件(multiple) + 选择文件夹(webkitdirectory) + 拖拽一批 → 每文件一个 job,
  文件列表实时显示各自状态(排队/解析/向量化/写入/抽取/完成/失败)+ 完成/失败计数。

验证:curl 证隔离 —— wt 入 default→可检索;alice 查同名 default→[] 空;alice 列表不含 wt 案件库。
Preview 证 UI —— 知识库下拉含 案件-2024-001(案件)+default(通用)、owner 隔离徽标、批量/文件夹按钮。
tsc+vite+gateway build 通过;重建 .app 重启窗口。

注:身份目前来自 X-User-ID 头(可信前端),生产应换 JWT 鉴权中间件——隔离机制(owner 前缀)已就位。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-06-13 14:50:33 +08:00
parent 84efa0a11c
commit 3a175e46f3
7 changed files with 329 additions and 71 deletions
+1 -1
View File
@@ -126,7 +126,7 @@ export default function App() {
) : view === "studio" ? (
<StudioView onRun={onRun} phase={run.phase} />
) : view === "kb" ? (
<KbView />
<KbView identity={identity} />
) : view === "report" ? (
<ReportView identity={identity} />
) : view === "runs" ? (
+38 -8
View File
@@ -91,11 +91,41 @@ export interface IngestEvent {
error?: string;
}
// idHeaders 把身份带进请求头 —— 网关据此把知识库锁进 owner 作用域(隔离)。
function idHeaders(id: Identity): Record<string, string> {
return { "X-User-ID": id.userId, "X-Session-ID": id.sessionId };
}
export interface KbInfo {
name: string;
kind: string; // folder / project / case / general
}
// listKb: GET /api/v1/kb/list —— 当前用户的知识库列表(owner 隔离)。
export async function listKb(id: Identity): Promise<KbInfo[]> {
const res = await fetch(`${GATEWAY}/api/v1/kb/list`, { headers: idHeaders(id) });
const data = (await res.json()) as { kbs?: KbInfo[]; error?: string };
if (!res.ok) throw new Error(data.error ?? `list failed: ${res.status}`);
return data.kbs ?? [];
}
// createKb: POST /api/v1/kb/create —— 新建知识库(项目/案件/文件夹/通用)。
export async function createKb(id: Identity, name: string, kind: string): Promise<KbInfo> {
const res = await fetch(`${GATEWAY}/api/v1/kb/create`, {
method: "POST",
headers: { "Content-Type": "application/json", ...idHeaders(id) },
body: JSON.stringify({ name, kind }),
});
const data = (await res.json()) as { name?: string; kind?: string; error?: string };
if (!res.ok || !data.name) throw new Error(data.error ?? `create failed: ${res.status}`);
return { name: data.name, kind: data.kind ?? kind };
}
// ingestKb: POST /api/v1/kb/ingest —— 文本入库(异步,返回 job_id)。
export async function ingestKb(kb: string, text: string): Promise<string> {
export async function ingestKb(id: Identity, kb: string, text: string): Promise<string> {
const res = await fetch(`${GATEWAY}/api/v1/kb/ingest`, {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: { "Content-Type": "application/json", ...idHeaders(id) },
body: JSON.stringify({ kb, text }),
});
const data = (await res.json()) as { job_id?: string; error?: string };
@@ -104,11 +134,11 @@ export async function ingestKb(kb: string, text: string): Promise<string> {
}
// ingestFile: POST /api/v1/kb/ingest_filemultipart)—— 文件入库(异步,返回 job_id)。
export async function ingestFile(kb: string, file: File): Promise<string> {
export async function ingestFile(id: Identity, kb: string, file: File): Promise<string> {
const fd = new FormData();
fd.append("kb", kb);
fd.append("file", file);
const res = await fetch(`${GATEWAY}/api/v1/kb/ingest_file`, { method: "POST", body: fd });
const res = await fetch(`${GATEWAY}/api/v1/kb/ingest_file`, { method: "POST", headers: idHeaders(id), body: fd });
const data = (await res.json()) as { job_id?: string; error?: string };
if (!res.ok || !data.job_id) throw new Error(data.error ?? `ingest file failed: ${res.status}`);
return data.job_id;
@@ -146,18 +176,18 @@ export interface Triple {
}
// graphKb: GET /api/v1/kb/graph —— 取某知识库的图谱三元组(→ mcp-go kb_graphNeo4j)。
export async function graphKb(kb: string): Promise<Triple[]> {
const res = await fetch(`${GATEWAY}/api/v1/kb/graph?kb=${encodeURIComponent(kb)}`);
export async function graphKb(id: Identity, kb: string): Promise<Triple[]> {
const res = await fetch(`${GATEWAY}/api/v1/kb/graph?kb=${encodeURIComponent(kb)}`, { headers: idHeaders(id) });
const data = (await res.json()) as { triples?: Triple[]; error?: string };
if (!res.ok) throw new Error(data.error ?? `graph failed: ${res.status}`);
return data.triples ?? [];
}
// searchKb: POST /api/v1/kb/search,检索台查询(→ mcp-go kb_search,带分数)。
export async function searchKb(kb: string, q: string, topK = 5): Promise<KbHit[]> {
export async function searchKb(id: Identity, kb: string, q: string, topK = 5): Promise<KbHit[]> {
const res = await fetch(`${GATEWAY}/api/v1/kb/search`, {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: { "Content-Type": "application/json", ...idHeaders(id) },
body: JSON.stringify({ kb, q, topK }),
});
const data = (await res.json()) as { hits?: KbHit[]; error?: string };
+196 -57
View File
@@ -1,7 +1,9 @@
import { useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import {
Upload,
FileUp,
FolderUp,
Plus,
Search,
Network,
Database,
@@ -12,11 +14,26 @@ import {
CheckCircle2,
XCircle,
Loader2,
Trash2,
Lock,
type LucideIcon,
} from "lucide-react";
import { ingestKb, ingestFile, streamIngest, searchKb, graphKb, type IngestEvent, type KbHit, type Triple } from "../lib/api";
import {
ingestKb,
ingestFile,
streamIngest,
searchKb,
graphKb,
listKb,
createKb,
type IngestEvent,
type KbHit,
type Triple,
type KbInfo,
type Identity,
} from "../lib/api";
import { GraphView } from "../components/GraphView";
import { Button, Input, Textarea, Badge, cn, useToast } from "../ui";
import { Button, Input, Textarea, Select, Badge, cn, useToast } from "../ui";
interface IngestLog {
t: string;
@@ -38,8 +55,13 @@ interface Progress {
vecDone?: number;
vecTotal?: number;
}
interface FileJob {
id: string;
name: string;
status: string; // 排队/解析/向量化/写入/抽取/完成/失败
error?: string;
}
// 阶段元数据:图标 + 中文标签(与后端 IngestEvent.stage 对应)。
const STAGE: Record<string, { icon: LucideIcon; label: string }> = {
: { icon: Upload, label: "解析文件" },
: { icon: FileText, label: "解析完成" },
@@ -52,6 +74,17 @@ const STAGE: Record<string, { icon: LucideIcon; label: string }> = {
: { icon: CheckCircle2, label: "完成" },
: { icon: XCircle, label: "失败" },
};
const KIND_LABEL: Record<string, string> = { folder: "文件夹", project: "项目", case: "案件", general: "通用" };
function stageToStatus(stage: string): string {
if (stage === "解析" || stage === "解析完成") return "解析";
if (stage === "切块" || stage === "向量化") return "向量化";
if (stage === "写Milvus" || stage === "写Bleve") return "写入";
if (stage === "抽实体" || stage === "写Neo4j") return "抽取";
if (stage === "完成") return "完成";
if (stage === "失败" || stage === "连接中断") return "失败";
return "排队";
}
function dedupTriples(ts: Triple[]): Triple[] {
const seen = new Set<string>();
@@ -66,14 +99,21 @@ function dedupTriples(ts: Triple[]): Triple[] {
return out;
}
// 知识库管理:实时入库时间线(解析预览 / 切块 / 向量化 / 知识抽取实时浮现)+ 力导向知识图谱 + 混合检索。
export function KbView() {
// 知识库管理:按 owner 隔离 + 项目/案件/文件夹组织;批量文件入库(文件列表) + 实时时间线 + 力导向图谱 + 混合检索。
export function KbView({ identity }: { identity: Identity }) {
const toast = useToast();
const [kb, setKb] = useState("docs");
const [kbs, setKbs] = useState<KbInfo[]>([]);
const [kb, setKb] = useState("default");
const [creating, setCreating] = useState(false);
const [newName, setNewName] = useState("");
const [newKind, setNewKind] = useState("project");
const [text, setText] = useState("");
const [logs, setLogs] = useState<IngestLog[]>([]);
const [prog, setProg] = useState<Progress | null>(null);
const [files, setFiles] = useState<FileJob[]>([]);
const fileRef = useRef<HTMLInputElement>(null);
const folderRef = useRef<HTMLInputElement>(null);
const [q, setQ] = useState("");
const [topK, setTopK] = useState(5);
@@ -82,22 +122,49 @@ export function KbView() {
const [graph, setGraph] = useState<Triple[] | null>(null);
const [dragOver, setDragOver] = useState(false);
const onGraph = async () => {
// 让文件夹输入框可选整个目录(标准类型无 webkitdirectory,挂在 DOM 上)。
useEffect(() => {
if (folderRef.current) folderRef.current.setAttribute("webkitdirectory", "");
}, []);
const refreshKbs = async () => {
try {
setGraph(await graphKb(kb));
const list = await listKb(identity);
setKbs(list);
if (list.length && !list.some((k) => k.name === kb)) setKb(list[0].name);
} catch {
/* 降级:用默认库 */
}
};
useEffect(() => {
void refreshKbs();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [identity.userId]);
const onCreate = async () => {
const name = newName.trim();
if (!name) return;
try {
const info = await createKb(identity, name, newKind);
setKbs((ks) => [...ks.filter((k) => k.name !== info.name), info]);
setKb(info.name);
setCreating(false);
setNewName("");
toast.push("success", `已创建知识库「${info.name}`);
} catch (e) {
toast.push("error", (e as Error).message);
}
};
const stamp = () => new Date().toLocaleTimeString();
const ingesting = prog?.active ?? false;
const busy = (prog?.active ?? false) || files.some((f) => f.status !== "完成" && f.status !== "失败");
const follow = (job: string, label: string) => {
const follow = (job: string, label: string, fid?: string) => {
setProg({ active: true, stage: "提交", chunks: [], triples: [], steps: [{ stage: "提交", msg: label }] });
streamIngest(
job,
(ev: IngestEvent) =>
(ev: IngestEvent) => {
if (fid) setFiles((fs) => fs.map((f) => (f.id === fid ? { ...f, status: stageToStatus(ev.stage), error: ev.error } : f)));
setProg((p) => {
const base: Progress = p ?? { active: true, stage: "提交", chunks: [], triples: [], steps: [] };
const steps = [...base.steps];
@@ -116,17 +183,14 @@ export function KbView() {
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}:入库完成` : `${label}${p?.error ?? "失败"}`, ok }, ...l]);
if (ok) {
toast.push("success", `${label} 入库完成`);
void onGraph(); // 刷新右侧知识图谱(含新抽取的实体关系)
} else {
toast.push("error", `${label} 入库失败`);
}
if (fid) setFiles((fs) => fs.map((f) => (f.id === fid ? { ...f, status: ok ? "完成" : "失败" } : f)));
else setLogs((l) => [{ t: stamp(), msg: ok ? `${label}:入库完成` : `${label}${p?.error ?? "失败"}`, ok }, ...l]);
if (ok) void onGraph();
return p ? { ...p, active: false } : null;
});
},
@@ -137,7 +201,7 @@ export function KbView() {
const onIngest = async () => {
if (!text.trim()) return;
try {
const job = await ingestKb(kb, text);
const job = await ingestKb(identity, kb, text);
setText("");
follow(job, "文本");
} catch (e) {
@@ -145,23 +209,23 @@ export function KbView() {
}
};
const onFile = async (file?: File) => {
if (!file) return;
try {
const job = await ingestFile(kb, file);
follow(job, file.name);
} catch (e) {
setLogs((l) => [{ t: stamp(), msg: `${file.name}: ${(e as Error).message}`, ok: false }, ...l]);
} finally {
if (fileRef.current) fileRef.current.value = "";
}
// 批量入库:每个文件起一个 job,行内显示各自状态。
const ingestFiles = (list: FileList | File[] | null | undefined) => {
const arr = Array.from(list ?? []);
arr.forEach((file, idx) => {
const fid = `${file.name}-${idx}-${stamp()}-${Math.round(file.size)}`;
setFiles((fs) => [...fs, { id: fid, name: file.name, status: "排队" }]);
ingestFile(identity, kb, file)
.then((job) => follow(job, file.name, fid))
.catch((e) => setFiles((fs) => fs.map((f) => (f.id === fid ? { ...f, status: "失败", error: (e as Error).message } : f))));
});
};
const onSearch = async () => {
if (!q.trim()) return;
setSearching(true);
try {
setHits(await searchKb(kb, q, topK));
setHits(await searchKb(identity, kb, q, topK));
} catch (e) {
toast.push("error", (e as Error).message);
setHits(null);
@@ -170,21 +234,62 @@ export function KbView() {
}
};
const onGraph = async () => {
try {
setGraph(await graphKb(identity, kb));
} catch (e) {
toast.push("error", (e as Error).message);
}
};
const vecPct = prog?.vecTotal ? Math.round(((prog.vecDone ?? 0) / prog.vecTotal) * 100) : 0;
const graphData = graph ?? prog?.triples ?? null;
const kbOptions = kbs.some((k) => k.name === kb) ? kbs : [{ name: kb, kind: "general" }, ...kbs];
const doneCount = files.filter((f) => f.status === "完成").length;
const failCount = files.filter((f) => f.status === "失败").length;
return (
<div className="flex h-full flex-col">
<div className="flex items-center gap-2 border-b border-line bg-ink-900 px-4 py-2">
{/* 知识库选择 / 新建 */}
<div className="flex flex-wrap items-center gap-2 border-b border-line bg-ink-900 px-4 py-2">
<span className="text-sm font-semibold text-slate-300"></span>
<Input className="h-8 w-40" value={kb} onChange={(e) => setKb(e.target.value)} placeholder="知识库名" title="知识库(Milvus kb 字段分区)" />
<span className="text-[11px] text-slate-500"> / / / / </span>
<Select className="h-8 w-48" value={kb} onChange={(e) => setKb(e.target.value)}>
{kbOptions.map((k) => (
<option key={k.name} value={k.name}>
{k.name}{KIND_LABEL[k.kind] ?? k.kind}
</option>
))}
</Select>
{!creating ? (
<Button size="sm" icon={Plus} onClick={() => setCreating(true)}>
</Button>
) : (
<div className="flex items-center gap-1.5">
<Input className="h-8 w-36" value={newName} onChange={(e) => setNewName(e.target.value)} onKeyDown={(e) => e.key === "Enter" && onCreate()} placeholder="如 案件-2024-001" autoFocus />
<Select className="h-8 w-24" value={newKind} onChange={(e) => setNewKind(e.target.value)}>
<option value="project"></option>
<option value="case"></option>
<option value="folder"></option>
<option value="general"></option>
</Select>
<Button size="sm" variant="primary" onClick={onCreate}>
</Button>
<Button size="sm" variant="ghost" onClick={() => setCreating(false)}>
</Button>
</div>
)}
<span className="ml-auto flex items-center gap-1 text-[11px] text-slate-500">
<Lock className="h-3 w-3" /> {identity.userId} owner
</span>
</div>
<div className="flex min-h-0 flex-1">
{/* 左:入库 + 实时时间线 */}
{/* 左:入库 + 文件列表 + 时间线 */}
<section className="flex w-1/2 flex-col overflow-y-auto border-r border-line p-4">
<h3 className="mb-2 text-xs font-semibold text-slate-400"></h3>
<h3 className="mb-2 text-xs font-semibold text-slate-400">{kb}</h3>
<div
onDragOver={(e) => {
e.preventDefault();
@@ -194,35 +299,61 @@ export function KbView() {
onDrop={(e) => {
e.preventDefault();
setDragOver(false);
const f = e.dataTransfer.files?.[0];
if (f) onFile(f);
ingestFiles(e.dataTransfer.files);
}}
className={cn("relative rounded-md", dragOver && "ring-2 ring-brand")}
>
<Textarea className="h-20 w-full resize-none" value={text} onChange={(e) => setText(e.target.value)} placeholder="每行一条知识,或把文件拖到这里 / 点选择文件" />
<Textarea className="h-20 w-full resize-none" value={text} onChange={(e) => setText(e.target.value)} placeholder="每行一条知识,或把一批文件 / 整个文件夹拖到这里" />
{dragOver && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center rounded-md bg-ink-950/85 text-xs font-medium text-brand-400">
</div>
<div className="pointer-events-none absolute inset-0 flex items-center justify-center rounded-md bg-ink-950/85 text-xs font-medium text-brand-400"></div>
)}
</div>
<div className="mt-2 flex items-center gap-2">
<Button variant="primary" size="sm" icon={Upload} onClick={onIngest} disabled={ingesting || !text.trim()}>
{ingesting ? "入库中…" : "入库文本"}
<div className="mt-2 flex flex-wrap items-center gap-2">
<Button variant="primary" size="sm" icon={Upload} onClick={onIngest} disabled={busy || !text.trim()}>
</Button>
<span className="text-[11px] text-slate-500"></span>
<Button size="sm" icon={FileUp} onClick={() => fileRef.current?.click()} disabled={ingesting}>
<Button size="sm" icon={FileUp} onClick={() => fileRef.current?.click()} disabled={busy}>
</Button>
<input ref={fileRef} type="file" accept=".txt,.md,.csv,.docx,.xlsx,.pdf" onChange={(e) => onFile(e.target.files?.[0])} disabled={ingesting} className="hidden" />
<Button size="sm" icon={FolderUp} onClick={() => folderRef.current?.click()} disabled={busy}>
</Button>
<input ref={fileRef} type="file" multiple accept=".txt,.md,.csv,.docx,.xlsx,.pdf" onChange={(e) => ingestFiles(e.target.files)} className="hidden" />
<input ref={folderRef} type="file" multiple onChange={(e) => ingestFiles(e.target.files)} className="hidden" />
</div>
<span className="mt-1 text-[10px] text-slate-500"> txt/md/csv/docx/xlsx/pdfdocx/xlsx/pdf mcp-py </span>
<span className="mt-1 text-[10px] text-slate-500"> txt/md/csv/docx/xlsx/pdfdocx/xlsx/pdf mcp-py </span>
{/* 批量文件列表 */}
{files.length > 0 && (
<div className="mt-3 rounded-lg border border-line bg-ink-850 p-2.5">
<div className="mb-1.5 flex items-center text-[11px] text-slate-400">
<span> {files.length} </span>
<span className="ml-auto text-slate-500">
{doneCount} · {failCount}
</span>
<button className="ml-2 text-slate-600 hover:text-slate-400" onClick={() => setFiles([])} title="清空列表">
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
<ul className="max-h-40 space-y-1 overflow-auto">
{files.map((f) => (
<li key={f.id} className="flex items-center gap-2 rounded bg-ink-900 px-2 py-1 text-[11px]">
<FileText className="h-3.5 w-3.5 shrink-0 text-slate-500" />
<span className="truncate text-slate-300" title={f.name}>{f.name}</span>
<span className="ml-auto">
<FileStatus status={f.status} />
</span>
</li>
))}
</ul>
</div>
)}
{prog && <Timeline prog={prog} vecPct={vecPct} />}
<h3 className="mb-1 mt-4 text-xs font-semibold text-slate-400"></h3>
<ul className="space-y-1">
{logs.length === 0 && <li className="text-xs text-slate-600"></li>}
{logs.length === 0 && <li className="text-xs text-slate-600"></li>}
{logs.map((l, i) => (
<li key={i} className={cn("text-xs", l.ok ? "text-success" : "text-danger")}>
<span className="text-slate-600">{l.t}</span> {l.ok ? "✓" : "✗"} {l.msg}
@@ -233,7 +364,7 @@ export function KbView() {
{/* 右:检索台 + 知识图谱 */}
<section className="flex w-1/2 flex-col overflow-y-auto p-4">
<h3 className="mb-2 text-xs font-semibold text-slate-400"> + rerank</h3>
<h3 className="mb-2 text-xs font-semibold text-slate-400"> · {kb} + rerank</h3>
<div className="flex gap-2">
<Input className="flex-1" value={q} onChange={(e) => setQ(e.target.value)} onKeyDown={(e) => e.key === "Enter" && onSearch()} placeholder="输入查询,语义召回相关片段…" />
<Input type="number" className="w-16" value={topK} min={1} max={20} onChange={(e) => setTopK(Number(e.target.value))} title="TopK" />
@@ -243,7 +374,7 @@ export function KbView() {
</div>
<ul className="mt-3 space-y-2">
{hits === null && <li className="text-xs text-slate-600"></li>}
{hits !== null && hits.length === 0 && <li className="text-xs text-slate-600"> RAG </li>}
{hits !== null && hits.length === 0 && <li className="text-xs text-slate-600"> RAG </li>}
{hits?.map((h, i) => (
<li key={i} className="rounded-lg border border-line bg-ink-850 p-2">
<div className="mb-1 flex items-center gap-2 text-[10px]">
@@ -271,6 +402,18 @@ export function KbView() {
);
}
function FileStatus({ status }: { status: string }) {
if (status === "完成") return <Badge tone="success"></Badge>;
if (status === "失败") return <Badge tone="danger"></Badge>;
if (status === "排队") return <Badge tone="neutral"></Badge>;
return (
<span className="inline-flex items-center gap-1 rounded bg-accent/15 px-1.5 py-0.5 text-[9px] text-accent-400">
<Loader2 className="h-3 w-3 animate-spin" />
{status}
</span>
);
}
// Timeline 渲染入库各阶段:状态灯 + 标签 + 详情;解析预览、切块块、抽取的知识三元组逐步呈现。
function Timeline({ prog, vecPct }: { prog: Progress; vecPct: number }) {
return (
@@ -299,7 +442,6 @@ function Timeline({ prog, vecPct }: { prog: Progress; vecPct: number }) {
{s.msg && <span className="text-[11px] text-slate-500">{s.msg}</span>}
</div>
{/* 向量化进度条 */}
{s.stage === "向量化" && prog.vecTotal ? (
<div className="mt-1">
<div className="h-1.5 w-full overflow-hidden rounded-full bg-ink-700">
@@ -309,12 +451,10 @@ function Timeline({ prog, vecPct }: { prog: Progress; vecPct: number }) {
</div>
) : null}
{/* 解析预览 */}
{s.stage === "解析完成" && prog.preview ? (
<p className="mt-1 max-h-16 overflow-hidden rounded bg-ink-900 px-2 py-1 text-[11px] leading-relaxed text-slate-400">{prog.preview}</p>
) : null}
{/* 切块预览 */}
{s.stage === "切块" && prog.chunks.length > 0 ? (
<ul className="mt-1 max-h-20 space-y-0.5 overflow-auto">
{prog.chunks.map((c, j) => (
@@ -329,7 +469,6 @@ function Timeline({ prog, vecPct }: { prog: Progress; vecPct: number }) {
})}
</ol>
{/* 抽取出的知识:三元组 chips + 实时小图谱 */}
{prog.triples.length > 0 && (
<div className="mt-3 border-t border-line pt-2">
<div className="mb-1.5 flex items-center gap-1.5 text-[11px] font-medium text-slate-400">