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 = { 解析: { 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 = { 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(); 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([]); const [kb, setKb] = useState("default"); const [tab, setTab] = useState("ingest"); const [creating, setCreating] = useState(false); const [newName, setNewName] = useState(""); const [newKind, setNewKind] = useState("project"); const [text, setText] = useState(""); const [logs, setLogs] = useState([]); const [prog, setProg] = useState(null); const [files, setFiles] = useState([]); const fileRef = useRef(null); const folderRef = useRef(null); const [q, setQ] = useState(""); const [topK, setTopK] = useState(5); const [hits, setHits] = useState(null); const [searching, setSearching] = useState(false); const [graph, setGraph] = useState(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[] = [ { key: "ingest", label: "入库" }, { key: "vault", label: "文库" }, { key: "search", label: "检索" }, { key: "graph", label: "图谱" }, ]; return (
{/* 知识库选择 / 新建 + 隔离徽标 */}
知识库 {!creating ? ( ) : (
setNewName(e.target.value)} onKeyDown={(e) => e.key === "Enter" && onCreate()} placeholder="如 案件-2024-001" autoFocus />
)} 仅 {identity.userId} 可见(owner 隔离)
{/* Tab 条 */}
{busy && 入库中}
{tab === "ingest" && (

入库到「{kb}」

{ 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")} >