Files
sundynix-agentix/sundynix-desktop/frontend/src/views/KbView.tsx
T
Blizzard a8c0bb42a7 feat(space): 共享工作区增量3b —— KB 知识库按 Space 共享(存储 re-key)
把 3a 的 Space 作用域推到 KB 层:KB/Doc/DocLink owner→space_id,作用域键
owner/name→space_id/name,同空间成员共享知识库(检索/入库/文库/双链/图谱)。

后端:
- store: KB/Doc/DocLink 加 space_id,唯一索引 (owner,*)→(space_id,*),owner 降级创建人;
  查询全改 space 作用域;SaveDoc/ListVault/GetDocByID/DeleteDocByID/ReplaceDocLinks/
  ResolveInboundLinks/ListLinks 改 space;tenantIDForSpace 补异步入库租户
- MigrateKBSpaces 启动迁移(space_id 回填 + 唯一索引换新,同 Agent 顺序坑规避)
- scopedKB owner/name→space_id/name;IngestJob 契约加 SpaceID;enqueueIngest/runIngest
  穿 space;MinIO 对象键改 space/kb/doc(避免跨空间同名撞键,老键不透明不迁)
- KB 写路由(create/ingest/ingest_file/note/delete)挂 RequireSpaceRole(member):viewer 只读
- 存储层重灌迁移端点 POST /admin/migrate-kb-storage(异步):为存量文档入队新 space
  作用域的重灌作业(复用 JetStream 入库 worker 池),先删旧键;避免同步重嵌撑爆 HTTP 超时

桌面端:
- KbView 收 spaceId(变则重拉库)+spaceReadOnly(viewer 禁建库/入库/文件/笔记);VaultPanel 同

验证(gateway+mcp-go+Milvus/Neo4j/embedding 全栈):
- PG 迁移: 20/21 KB + 50/54 doc 回填 space_id(4 未迁=pre-多租户 owner='wt' 空租户遗留,
  正确跳过),唯一索引 idx_kb_sn/idx_doc_skn 换新、旧索引删除
- KB 共享: member 见共享库 / viewer 建库·入库 403 / 切回个人空间隔离(看不到)
- 全向量链路: RagA 入库(真 dashscope embedding)→ RagB(空间member)检索命中 RagA 内容
- 存储重灌: 端点异步入队 49 作业(worker 池背压处理),重灌后老文档在新 space 键可检索

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 13:00:34 +08:00

762 lines
32 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, spaceId = "", spaceReadOnly = false }: { identity: Identity; spaceId?: string; spaceReadOnly?: boolean }) {
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]);
// 切换工作区(spaceId 变)后重拉该空间的知识库(共享工作区隔离)。
useEffect(() => {
void refreshKbs();
}, [refreshKbs, spaceId]);
const onCreate = async () => {
if (spaceReadOnly) return toast.push("error", "当前工作区你是只读成员(viewer),无权新建知识库");
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 (spaceReadOnly) return toast.push("error", "当前工作区你是只读成员(viewer),无权入库");
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) => {
if (spaceReadOnly) return toast.push("error", "当前工作区你是只读成员(viewer),无权入库");
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} spaceReadOnly={spaceReadOnly} />}
{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, spaceReadOnly = false }: { identity: Identity; kb: string; spaceReadOnly?: boolean }) {
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 () => {
if (spaceReadOnly) return toast.push("error", "当前工作区你是只读成员(viewer),无权编辑笔记");
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>
);
}