a8c0bb42a7
把 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>
762 lines
32 KiB
TypeScript
762 lines
32 KiB
TypeScript
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/pdf;可整文件夹拖入(docx/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>
|
||
);
|
||
}
|
||
|
||
// VaultPanel:Obsidian 式文库 —— 列表(仅元数据) / 正文按需加载 / [[双链]]可点 / 反链 / 笔记关系图。
|
||
// 列表与正文分离 + 链接走服务端索引,不再一次拉回整库正文,可扛十几万字大文件。
|
||
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>
|
||
);
|
||
}
|