diff --git a/sundynix-desktop/frontend/src/App.tsx b/sundynix-desktop/frontend/src/App.tsx index 081a1e1..df6c76b 100644 --- a/sundynix-desktop/frontend/src/App.tsx +++ b/sundynix-desktop/frontend/src/App.tsx @@ -126,7 +126,7 @@ export default function App() { ) : view === "studio" ? ( ) : view === "kb" ? ( - + ) : view === "report" ? ( ) : view === "runs" ? ( diff --git a/sundynix-desktop/frontend/src/lib/api.ts b/sundynix-desktop/frontend/src/lib/api.ts index f64f572..c1f5082 100644 --- a/sundynix-desktop/frontend/src/lib/api.ts +++ b/sundynix-desktop/frontend/src/lib/api.ts @@ -91,11 +91,41 @@ export interface IngestEvent { error?: string; } +// idHeaders 把身份带进请求头 —— 网关据此把知识库锁进 owner 作用域(隔离)。 +function idHeaders(id: Identity): Record { + 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 { + 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 { + 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 { +export async function ingestKb(id: Identity, kb: string, text: string): Promise { 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 { } // ingestFile: POST /api/v1/kb/ingest_file(multipart)—— 文件入库(异步,返回 job_id)。 -export async function ingestFile(kb: string, file: File): Promise { +export async function ingestFile(id: Identity, kb: string, file: File): Promise { 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_graph,Neo4j)。 -export async function graphKb(kb: string): Promise { - const res = await fetch(`${GATEWAY}/api/v1/kb/graph?kb=${encodeURIComponent(kb)}`); +export async function graphKb(id: Identity, kb: string): Promise { + 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 { +export async function searchKb(id: Identity, kb: string, q: string, topK = 5): Promise { 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 }; diff --git a/sundynix-desktop/frontend/src/views/KbView.tsx b/sundynix-desktop/frontend/src/views/KbView.tsx index 6fbace9..4a18f6a 100644 --- a/sundynix-desktop/frontend/src/views/KbView.tsx +++ b/sundynix-desktop/frontend/src/views/KbView.tsx @@ -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 = { 解析: { icon: Upload, label: "解析文件" }, 解析完成: { icon: FileText, label: "解析完成" }, @@ -52,6 +74,17 @@ const STAGE: Record = { 完成: { 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(); @@ -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([]); + 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([]); 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); @@ -82,22 +122,49 @@ export function KbView() { const [graph, setGraph] = useState(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 (
-
+ {/* 知识库选择 / 新建 */} +
知识库 - setKb(e.target.value)} placeholder="知识库名" title="知识库(Milvus kb 字段分区)" /> - 入库 → 解析 / 切块 / 向量化 / 抽取知识 / 写入;检索 → 三路混合召回 + + {!creating ? ( + + ) : ( +
+ setNewName(e.target.value)} onKeyDown={(e) => e.key === "Enter" && onCreate()} placeholder="如 案件-2024-001" autoFocus /> + + + +
+ )} + + 仅 {identity.userId} 可见(owner 隔离) +
- {/* 左:入库 + 实时时间线 */} + {/* 左:入库 + 文件列表 + 时间线 */}
-

入库

+

入库到「{kb}」

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