Files
sundynix-agentix/sundynix-desktop/frontend/src/views/KbView.tsx
T
Blizzard 5d76652bff refactor(kb): 文件主表 + 文档关联改用雪花ID(弃用按名关联)
把 sundynix_doc 明确为"文件主表",补齐文件基础信息字段;
文档间 [[双链]] 改为以 Doc.ID 关联,查询/渲染一律按文件 ID。

store:
- Doc 增 Ext(后缀)/MD5(内容指纹) 字段;ObjectKey 即"存放链接"
- DocLink 由 (FromName,ToName) 改为 (FromID,ToID,ToName)
  · FromID/ToID 关联 Doc.ID;ToName 保留用于悬空链接展示与回填
- SaveDoc 返回新建/更新文件的雪花 ID(供建链)
- 新增 GetDocByID(按 ID + owner 取正文,防越权)
- ReplaceDocLinks 以 fromID 重建出链,按 [[名称]] 解析目标 ID
- 新增 ResolveInboundLinks:目标入库后回填指向它的悬空链接
- ListLinks 只返回已解析(to_id 非空)的 ID→ID 边
- migrateDocLinkToID:旧按名双链表无 from_id 列则重建为按 ID 关联

gateway/handler:
- runIngest 计算 ext/md5,SaveDoc 取回 ID 后建链 + 回填悬空
- KbDoc 改为 GET ?id=(按文件 ID 取全文)
- KbVault 返回 id+ext;KbLinks 返回 from/to 为 ID

desktop:
- VaultDoc 增 id/ext;getDoc(docId) 按 ID 取正文
- VaultPanel 选中态/正文/反链/关系图改用 ID,名↔ID 双向映射
  渲染;保存笔记后按名定位回其新 ID

验证(gateway+PG+MinIO 实测):vault 带 id+ext;双链 ID→ID 且
A→B 悬空链接在 B 入库后成功回填;按 ID 取大文档(15006字)从
MinIO 完整取回;跨 owner 按 ID 取文档 404(隔离生效)。桌面端
文库 Tab 按 ID 选中/载入/反链渲染正常,无控制台报错。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 09:38:02 +08:00

757 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useRef, useState } from "react";
import {
Upload,
FileUp,
FolderUp,
Plus,
Search,
Network,
Database,
FileText,
Scissors,
Sparkles,
Share2,
CheckCircle2,
XCircle,
Loader2,
Trash2,
Lock,
BookOpen,
Link2,
RefreshCw,
Pencil,
Save,
X,
Waypoints,
type LucideIcon,
} from "lucide-react";
import {
ingestKb,
ingestFile,
streamIngest,
searchKb,
graphKb,
listKb,
createKb,
listVault,
getDoc,
listLinks,
saveNote,
type IngestEvent,
type KbHit,
type Triple,
type KbInfo,
type VaultDoc,
type DocLink,
type Identity,
} from "../lib/api";
import { GraphView } from "../components/GraphView";
import { Markdown } from "../components/Markdown";
import { Button, Input, Textarea, Select, Badge, Tabs, EmptyState, cn, useToast, type TabDef } from "../ui";
interface IngestLog {
t: string;
msg: string;
ok: boolean;
}
interface Step {
stage: string;
msg: string;
}
interface Progress {
active: boolean;
stage: string;
chunks: string[];
preview?: string;
triples: Triple[];
error?: string;
steps: Step[];
vecDone?: number;
vecTotal?: number;
}
interface FileJob {
id: string;
name: string;
status: string;
error?: string;
}
type KbTab = "ingest" | "vault" | "search" | "graph";
const STAGE: Record<string, { icon: LucideIcon; label: string }> = {
: { 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: "失败" },
};
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>();
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;
}
// 知识库:owner 隔离 + 项目/案件/文件夹组织;Tab 分(入库 / 文库(Obsidian 式) / 检索 / 图谱)。
export function KbView({ identity }: { identity: Identity }) {
const toast = useToast();
const [kbs, setKbs] = useState<KbInfo[]>([]);
const [kb, setKb] = useState("default");
const [tab, setTab] = useState<KbTab>("ingest");
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);
const [hits, setHits] = useState<KbHit[] | null>(null);
const [searching, setSearching] = useState(false);
const [graph, setGraph] = useState<Triple[] | null>(null);
const [dragOver, setDragOver] = useState(false);
useEffect(() => {
if (folderRef.current) folderRef.current.setAttribute("webkitdirectory", "");
}, []);
const refreshKbs = useCallback(async () => {
try {
const list = await listKb(identity);
setKbs(list);
setKb((cur) => (list.length && !list.some((k) => k.name === cur) ? list[0].name : cur));
} catch {
/* 降级用默认库 */
}
}, [identity]);
useEffect(() => {
void refreshKbs();
}, [refreshKbs]);
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 busy = (prog?.active ?? false) || files.some((f) => f.status !== "完成" && f.status !== "失败");
const follow = (job: string, label: string, fid?: string) => {
setProg({ active: true, stage: "提交", chunks: [], triples: [], steps: [{ stage: "提交", msg: label }] });
streamIngest(
job,
(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];
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 !== "失败";
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;
});
},
() => setProg((p) => (p ? { ...p, active: false, stage: "连接中断" } : null)),
);
};
const onIngest = async () => {
if (!text.trim()) return;
try {
const job = await ingestKb(identity, kb, text);
setText("");
follow(job, "文本");
} catch (e) {
setLogs((l) => [{ t: stamp(), msg: (e as Error).message, ok: false }, ...l]);
}
};
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(identity, kb, q, topK));
} catch (e) {
toast.push("error", (e as Error).message);
setHits(null);
} finally {
setSearching(false);
}
};
const onGraph = useCallback(async () => {
try {
setGraph(await graphKb(identity, kb));
} catch (e) {
toast.push("error", (e as Error).message);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [identity, kb]);
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;
const tabs: TabDef<KbTab>[] = [
{ key: "ingest", label: "入库" },
{ key: "vault", label: "文库" },
{ key: "search", label: "检索" },
{ key: "graph", label: "图谱" },
];
return (
<div className="flex h-full flex-col">
{/* 知识库选择 / 新建 + 隔离徽标 */}
<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>
<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>
{/* Tab 条 */}
<div className="flex items-center border-b border-line px-3">
<Tabs tabs={tabs} value={tab} onChange={setTab} />
{busy && <span className="ml-2 flex items-center gap-1 text-[11px] text-accent-400"><Loader2 className="h-3 w-3 animate-spin" /> </span>}
</div>
<div className="min-h-0 flex-1 overflow-hidden">
{tab === "ingest" && (
<div className="h-full overflow-y-auto p-4">
<h3 className="mb-2 text-xs font-semibold text-slate-400">{kb}</h3>
<div
onDragOver={(e) => {
e.preventDefault();
setDragOver(true);
}}
onDragLeave={() => setDragOver(false)}
onDrop={(e) => {
e.preventDefault();
setDragOver(false);
ingestFiles(e.dataTransfer.files);
}}
className={cn("relative max-w-2xl rounded-md", dragOver && "ring-2 ring-brand")}
>
<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>
<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>
<Button size="sm" icon={FileUp} onClick={() => fileRef.current?.click()} disabled={busy}>
</Button>
<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 block text-[10px] text-slate-500"> txt/md/csv/docx/xlsx/pdfdocx/xlsx/pdf mcp-py </span>
<div className="mt-3 grid max-w-3xl gap-3 lg:grid-cols-2">
{files.length > 0 && (
<div className="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-48 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 && (
<div className={files.length > 0 ? "" : "lg:col-span-2"}>
<Timeline prog={prog} vecPct={vecPct} />
</div>
)}
</div>
{logs.length > 0 && (
<ul className="mt-3 max-w-2xl space-y-1">
{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}
</li>
))}
</ul>
)}
</div>
)}
{tab === "vault" && <VaultPanel identity={identity} kb={kb} />}
{tab === "search" && (
<div className="h-full overflow-y-auto p-4">
<h3 className="mb-2 text-xs font-semibold text-slate-400"> · {kb} + rerank</h3>
<div className="flex max-w-3xl 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" />
<Button variant="primary" size="md" icon={Search} onClick={onSearch} disabled={searching || !q.trim()}>
{searching ? "检索中…" : "检索"}
</Button>
</div>
<ul className="mt-3 max-w-3xl 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?.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]">
<Badge tone="accent"></Badge>
<span className="text-slate-600">#{i + 1}</span>
<span className="ml-auto font-mono text-brand-400">{h.score.toFixed(3)}</span>
</div>
<div className="text-xs text-slate-300">{h.text}</div>
</li>
))}
</ul>
</div>
)}
{tab === "graph" && (
<div className="h-full overflow-y-auto p-4">
<div className="mb-2 flex max-w-3xl items-center justify-between">
<h3 className="text-xs font-semibold text-slate-400"> · {kb}Neo4j · </h3>
<Button size="sm" icon={Network} onClick={onGraph}>
</Button>
</div>
<div className="max-w-3xl">
<GraphView triples={graphData ?? []} height={460} />
</div>
</div>
)}
</div>
</div>
);
}
// VaultPanelObsidian 式文库 —— 列表(仅元数据) / 正文按需加载 / [[双链]]可点 / 反链 / 笔记关系图。
// 列表与正文分离 + 链接走服务端索引,不再一次拉回整库正文,可扛十几万字大文件。
function VaultPanel({ identity, kb }: { identity: Identity; kb: string }) {
const toast = useToast();
const [docs, setDocs] = useState<VaultDoc[]>([]);
const [links, setLinks] = useState<DocLink[]>([]);
const [sel, setSel] = useState<string | null>(null);
const [content, setContent] = useState("");
const [loadingDoc, setLoadingDoc] = useState(false);
const [loading, setLoading] = useState(false);
const [mode, setMode] = useState<"read" | "graph">("read");
const [editing, setEditing] = useState(false);
const [creatingNew, setCreatingNew] = useState(false);
const [draft, setDraft] = useState("");
const [draftName, setDraftName] = useState("");
const [saving, setSaving] = useState(false);
// sel 为文件主表雪花 ID(不再是文件名)—— 取正文/反链/关系图一律用 ID 关联。
// preferName 给定时(如保存笔记后)按名定位到其新 ID。
const load = useCallback(async (preferName?: string) => {
setLoading(true);
try {
const [d, lk] = await Promise.all([listVault(identity, kb), listLinks(identity, kb)]);
setDocs(d);
setLinks(lk);
setSel((s) => {
if (preferName) {
const hit = d.find((x) => x.name === preferName);
if (hit) return hit.id;
}
return d.some((x) => x.id === s) ? s : d[0]?.id ?? null;
});
} catch {
setDocs([]);
setLinks([]);
} finally {
setLoading(false);
}
}, [identity, kb]);
useEffect(() => {
void load();
setEditing(false);
setMode("read");
}, [load]);
// 选中文档时按 ID 取全文(不随列表一起拉)。
useEffect(() => {
if (!sel || editing) return;
let alive = true;
setLoadingDoc(true);
getDoc(identity, sel)
.then((c) => alive && setContent(c))
.catch(() => alive && setContent(""))
.finally(() => alive && setLoadingDoc(false));
return () => {
alive = false;
};
}, [sel, identity, editing]);
// 名称↔ID 映射:[[双链]]按名书写、关系图按名展示,但选中/关联走 ID。
const idToName = new Map(docs.map((d) => [d.id, d.name] as const));
const nameToId = new Map(docs.map((d) => [d.name, d.id] as const));
const current = docs.find((d) => d.id === sel);
const open = (name: string) => {
const id = nameToId.get(name);
if (id) {
setSel(id);
setMode("read");
setEditing(false);
}
};
// 反链/笔记关系图来自服务端 [[双链]]索引(FromID→ToID),ID 关联、按名渲染,不扫全文。
const backlinks = sel
? [...new Set(links.filter((l) => l.to === sel).map((l) => idToName.get(l.from)).filter((n): n is string => !!n))]
: [];
const noteTriples: Triple[] = links
.map((l) => ({ s: idToName.get(l.from), o: idToName.get(l.to) }))
.filter((e): e is { s: string; o: string } => !!e.s && !!e.o)
.map((e) => ({ s: e.s, p: "链接", o: e.o }));
const startNew = () => {
setCreatingNew(true);
setEditing(true);
setDraftName("");
setDraft("");
};
const startEdit = () => {
if (!current) return;
setCreatingNew(false);
setEditing(true);
setDraftName(current.name);
setDraft(content);
};
const onSave = async () => {
const name = (creatingNew ? draftName : current?.name ?? "").trim();
if (!name || !draft.trim()) {
toast.push("error", "笔记名与内容不能为空");
return;
}
setSaving(true);
try {
await saveNote(identity, kb, name, draft);
toast.push("success", `已保存「${name}」(正在重建索引)`);
setEditing(false);
setCreatingNew(false);
setContent(draft);
setTimeout(() => void load(name), 400);
} catch (e) {
toast.push("error", (e as Error).message);
} finally {
setSaving(false);
}
};
const empty = !loading && docs.length === 0;
return (
<div className="flex h-full min-h-0">
<aside className="flex w-56 shrink-0 flex-col overflow-y-auto border-r border-line p-2">
<div className="mb-1 flex items-center gap-1 px-1 text-[11px] text-slate-500">
<span> {docs.length}</span>
<button onClick={startNew} className="ml-auto text-slate-500 hover:text-brand-400" title="新建笔记">
<Plus className="h-3.5 w-3.5" />
</button>
<button onClick={() => void load()} className="text-slate-600 hover:text-slate-300" title="刷新">
<RefreshCw className={cn("h-3.5 w-3.5", loading && "animate-spin")} />
</button>
</div>
<ul className="space-y-0.5">
{docs.map((d) => (
<li key={d.id}>
<button
onClick={() => {
setSel(d.id);
setMode("read");
setEditing(false);
}}
className={cn("flex w-full items-center gap-1.5 rounded px-2 py-1.5 text-left text-xs", d.id === sel ? "bg-brand/15 text-brand-400" : "text-slate-300 hover:bg-ink-800")}
>
<FileText className="h-3.5 w-3.5 shrink-0" />
<span className="truncate" title={d.name}>{d.name}</span>
{d.ext && <span className="ml-auto shrink-0 text-[9px] uppercase text-slate-600">{d.ext.replace(/^\./, "")}</span>}
</button>
</li>
))}
</ul>
</aside>
<div className="min-h-0 flex-1 overflow-y-auto p-5">
{/* 工具条:阅读/关系图 + 编辑 */}
<div className="mb-3 flex items-center gap-2">
<div className="flex rounded-md border border-line p-0.5 text-[11px]">
<button onClick={() => setMode("read")} className={cn("flex items-center gap-1 rounded px-2 py-1", mode === "read" ? "bg-brand/15 text-brand-400" : "text-slate-400")}>
<BookOpen className="h-3 w-3" />
</button>
<button onClick={() => setMode("graph")} className={cn("flex items-center gap-1 rounded px-2 py-1", mode === "graph" ? "bg-brand/15 text-brand-400" : "text-slate-400")}>
<Waypoints className="h-3 w-3" />
</button>
</div>
{mode === "read" && !editing && current && (
<Button size="sm" variant="ghost" icon={Pencil} onClick={startEdit}>
</Button>
)}
{editing && (
<>
<Button size="sm" variant="primary" icon={Save} onClick={onSave} disabled={saving}>
{saving ? "保存中…" : "保存"}
</Button>
<Button size="sm" variant="ghost" icon={X} onClick={() => setEditing(false)}>
</Button>
</>
)}
<span className="ml-auto text-[10px] text-slate-600"> [[]]</span>
</div>
{mode === "graph" ? (
noteTriples.length > 0 ? (
<GraphView triples={noteTriples} height={440} onNode={open} />
) : (
<EmptyState icon={Waypoints} title="暂无笔记关系" desc="在笔记里用 [[其它笔记名]] 互相引用,这里会连成关系图(点节点跳转)。" />
)
) : editing ? (
<div className="flex flex-col gap-2">
{creatingNew && <Input value={draftName} onChange={(e) => setDraftName(e.target.value)} placeholder="笔记名,如 项目A概述" autoFocus />}
{!creatingNew && <div className="text-sm font-semibold text-slate-100">{draftName}</div>}
<Textarea className="min-h-[360px] w-full font-mono" value={draft} onChange={(e) => setDraft(e.target.value)} placeholder={"# 标题\n正文支持 Markdown 与 [[双链]]…"} />
</div>
) : empty ? (
<EmptyState icon={BookOpen} title="文库为空" desc={`${kb}」还没有文档。点左上 + 新建笔记,或到「入库」拖入文件。`} action={<Button size="sm" icon={Plus} onClick={startNew}></Button>} />
) : current ? (
<>
<h2 className="mb-3 flex items-center gap-2 text-base font-semibold text-slate-100">
<FileText className="h-4 w-4 text-brand-400" />
{current.name}
{current.size != null && <span className="text-[11px] font-normal text-slate-600">{current.size} </span>}
</h2>
{loadingDoc ? (
<div className="flex items-center gap-1.5 text-xs text-slate-500"><Loader2 className="h-3.5 w-3.5 animate-spin" /> </div>
) : (
<Markdown text={content} className="text-sm" onLink={open} />
)}
<div className="mt-6 border-t border-line pt-3">
<div className="mb-2 flex items-center gap-1.5 text-xs font-medium text-slate-400">
<Link2 className="h-3.5 w-3.5" /> {backlinks.length}
</div>
{backlinks.length === 0 ? (
<p className="text-[11px] text-slate-600"> [[{current.name}]] </p>
) : (
<ul className="space-y-1">
{backlinks.map((b) => (
<li key={b}>
<button onClick={() => open(b)} className="text-xs text-brand-400 hover:underline">
{b}
</button>
</li>
))}
</ul>
)}
</div>
</>
) : (
<div className="text-sm text-slate-600"></div>
)}
</div>
</div>
);
}
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 (
<div className="rounded-lg border border-line bg-ink-850 p-3">
<ol className="relative ml-1 space-y-2 border-l border-line pl-4">
{prog.steps.map((s, i) => {
const meta = STAGE[s.stage] ?? { icon: Loader2, label: s.stage };
const Icon = meta.icon;
const isLast = i === prog.steps.length - 1;
const isErr = prog.error && s.stage === "失败";
const status = isErr ? "error" : isLast && prog.active ? "active" : "done";
return (
<li key={i} className="relative">
<span className="absolute -left-[21px] top-0.5 flex h-3.5 w-3.5 items-center justify-center rounded-full bg-ink-850">
{status === "active" ? (
<Loader2 className="h-3.5 w-3.5 animate-spin text-accent-400" />
) : status === "error" ? (
<XCircle className="h-3.5 w-3.5 text-danger" />
) : (
<CheckCircle2 className="h-3.5 w-3.5 text-success" />
)}
</span>
<div className="flex items-center gap-2">
<Icon className="h-3.5 w-3.5 text-slate-400" />
<span className="text-xs font-medium text-slate-200">{meta.label}</span>
{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">
<div className="h-full rounded-full bg-gradient-to-r from-brand to-accent transition-all" style={{ width: `${vecPct}%` }} />
</div>
<div className="mt-0.5 text-[10px] text-slate-500">{prog.vecDone ?? 0}/{prog.vecTotal} {vecPct}%</div>
</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) => (
<li key={j} className="truncate rounded bg-ink-900 px-1.5 py-0.5 text-[10px] text-slate-400">
<span className="text-slate-600">#{j + 1}</span> {c}
</li>
))}
</ul>
) : null}
</li>
);
})}
</ol>
{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">
<Network className="h-3.5 w-3.5 text-brand-400" /> {prog.triples.length}
</div>
<div className="flex flex-wrap gap-1">
{prog.triples.slice(0, 12).map((t, i) => (
<span key={i} className="inline-flex items-center gap-1 rounded bg-ink-900 px-1.5 py-0.5 text-[10px]">
<span className="text-brand-400">{t.s}</span>
<span className="text-slate-600">{t.p}</span>
<span className="text-accent-400">{t.o}</span>
</span>
))}
{prog.triples.length > 12 && <span className="px-1 text-[10px] text-slate-600">+{prog.triples.length - 12}</span>}
</div>
<div className="mt-2">
<GraphView triples={prog.triples} height={200} />
</div>
</div>
)}
</div>
);
}