feat(kb): 批量文件入库(文件列表) + 项目/案件知识库 + owner 作用域隔离

回应三点诉求:一次入一批文件、按文件夹/项目/案件组织、且只有我能查我的库。

隔离(核心):知识库实际分区键 = "owner/name",owner 由网关从 X-User-ID 注入,
客户端只发库名、发不了 owner —— 故任何人都只能查到自己 owner 前缀下的数据。
- gateway: scopedKB(owner/kb) 注入 ingest/search/graph;ingest/search/graph 全部带身份头。
- store: sundynix_kb 注册表(owner+name 唯一 + kind),ListKB/EnsureKB(OnConflict DoNothing)。

项目/案件组织:
- gateway: GET /kb/list(owner 隔离列表)、POST /kb/create(folder/project/case/general);
  入库时 EnsureKB 自动登记。
- 前端: KbView 顶部知识库下拉 + 新建(项目/案件/文件夹/通用),检索/图谱/入库都绑定所选库。

批量文件:
- 前端: 选择文件(multiple) + 选择文件夹(webkitdirectory) + 拖拽一批 → 每文件一个 job,
  文件列表实时显示各自状态(排队/解析/向量化/写入/抽取/完成/失败)+ 完成/失败计数。

验证:curl 证隔离 —— wt 入 default→可检索;alice 查同名 default→[] 空;alice 列表不含 wt 案件库。
Preview 证 UI —— 知识库下拉含 案件-2024-001(案件)+default(通用)、owner 隔离徽标、批量/文件夹按钮。
tsc+vite+gateway build 通过;重建 .app 重启窗口。

注:身份目前来自 X-User-ID 头(可信前端),生产应换 JWT 鉴权中间件——隔离机制(owner 前缀)已就位。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-06-13 14:50:33 +08:00
parent 84efa0a11c
commit 3a175e46f3
7 changed files with 329 additions and 71 deletions
+1 -1
View File
@@ -126,7 +126,7 @@ export default function App() {
) : view === "studio" ? ( ) : view === "studio" ? (
<StudioView onRun={onRun} phase={run.phase} /> <StudioView onRun={onRun} phase={run.phase} />
) : view === "kb" ? ( ) : view === "kb" ? (
<KbView /> <KbView identity={identity} />
) : view === "report" ? ( ) : view === "report" ? (
<ReportView identity={identity} /> <ReportView identity={identity} />
) : view === "runs" ? ( ) : view === "runs" ? (
+38 -8
View File
@@ -91,11 +91,41 @@ export interface IngestEvent {
error?: string; error?: string;
} }
// idHeaders 把身份带进请求头 —— 网关据此把知识库锁进 owner 作用域(隔离)。
function idHeaders(id: Identity): Record<string, string> {
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<KbInfo[]> {
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<KbInfo> {
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)。 // ingestKb: POST /api/v1/kb/ingest —— 文本入库(异步,返回 job_id)。
export async function ingestKb(kb: string, text: string): Promise<string> { export async function ingestKb(id: Identity, kb: string, text: string): Promise<string> {
const res = await fetch(`${GATEWAY}/api/v1/kb/ingest`, { const res = await fetch(`${GATEWAY}/api/v1/kb/ingest`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json", ...idHeaders(id) },
body: JSON.stringify({ kb, text }), body: JSON.stringify({ kb, text }),
}); });
const data = (await res.json()) as { job_id?: string; error?: string }; const data = (await res.json()) as { job_id?: string; error?: string };
@@ -104,11 +134,11 @@ export async function ingestKb(kb: string, text: string): Promise<string> {
} }
// ingestFile: POST /api/v1/kb/ingest_filemultipart)—— 文件入库(异步,返回 job_id)。 // ingestFile: POST /api/v1/kb/ingest_filemultipart)—— 文件入库(异步,返回 job_id)。
export async function ingestFile(kb: string, file: File): Promise<string> { export async function ingestFile(id: Identity, kb: string, file: File): Promise<string> {
const fd = new FormData(); const fd = new FormData();
fd.append("kb", kb); fd.append("kb", kb);
fd.append("file", file); 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 }; 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}`); if (!res.ok || !data.job_id) throw new Error(data.error ?? `ingest file failed: ${res.status}`);
return data.job_id; return data.job_id;
@@ -146,18 +176,18 @@ export interface Triple {
} }
// graphKb: GET /api/v1/kb/graph —— 取某知识库的图谱三元组(→ mcp-go kb_graphNeo4j)。 // graphKb: GET /api/v1/kb/graph —— 取某知识库的图谱三元组(→ mcp-go kb_graphNeo4j)。
export async function graphKb(kb: string): Promise<Triple[]> { export async function graphKb(id: Identity, kb: string): Promise<Triple[]> {
const res = await fetch(`${GATEWAY}/api/v1/kb/graph?kb=${encodeURIComponent(kb)}`); 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 }; const data = (await res.json()) as { triples?: Triple[]; error?: string };
if (!res.ok) throw new Error(data.error ?? `graph failed: ${res.status}`); if (!res.ok) throw new Error(data.error ?? `graph failed: ${res.status}`);
return data.triples ?? []; return data.triples ?? [];
} }
// searchKb: POST /api/v1/kb/search,检索台查询(→ mcp-go kb_search,带分数)。 // searchKb: POST /api/v1/kb/search,检索台查询(→ mcp-go kb_search,带分数)。
export async function searchKb(kb: string, q: string, topK = 5): Promise<KbHit[]> { export async function searchKb(id: Identity, kb: string, q: string, topK = 5): Promise<KbHit[]> {
const res = await fetch(`${GATEWAY}/api/v1/kb/search`, { const res = await fetch(`${GATEWAY}/api/v1/kb/search`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json", ...idHeaders(id) },
body: JSON.stringify({ kb, q, topK }), body: JSON.stringify({ kb, q, topK }),
}); });
const data = (await res.json()) as { hits?: KbHit[]; error?: string }; const data = (await res.json()) as { hits?: KbHit[]; error?: string };
+196 -57
View File
@@ -1,7 +1,9 @@
import { useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { import {
Upload, Upload,
FileUp, FileUp,
FolderUp,
Plus,
Search, Search,
Network, Network,
Database, Database,
@@ -12,11 +14,26 @@ import {
CheckCircle2, CheckCircle2,
XCircle, XCircle,
Loader2, Loader2,
Trash2,
Lock,
type LucideIcon, type LucideIcon,
} from "lucide-react"; } 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 { 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 { interface IngestLog {
t: string; t: string;
@@ -38,8 +55,13 @@ interface Progress {
vecDone?: number; vecDone?: number;
vecTotal?: number; vecTotal?: number;
} }
interface FileJob {
id: string;
name: string;
status: string; // 排队/解析/向量化/写入/抽取/完成/失败
error?: string;
}
// 阶段元数据:图标 + 中文标签(与后端 IngestEvent.stage 对应)。
const STAGE: Record<string, { icon: LucideIcon; label: string }> = { const STAGE: Record<string, { icon: LucideIcon; label: string }> = {
: { icon: Upload, label: "解析文件" }, : { icon: Upload, label: "解析文件" },
: { icon: FileText, label: "解析完成" }, : { icon: FileText, label: "解析完成" },
@@ -52,6 +74,17 @@ const STAGE: Record<string, { icon: LucideIcon; label: string }> = {
: { icon: CheckCircle2, label: "完成" }, : { icon: CheckCircle2, label: "完成" },
: { icon: XCircle, 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[] { function dedupTriples(ts: Triple[]): Triple[] {
const seen = new Set<string>(); const seen = new Set<string>();
@@ -66,14 +99,21 @@ function dedupTriples(ts: Triple[]): Triple[] {
return out; return out;
} }
// 知识库管理:实时入库时间线(解析预览 / 切块 / 向量化 / 知识抽取实时浮现)+ 力导向知识图谱 + 混合检索。 // 知识库管理:按 owner 隔离 + 项目/案件/文件夹组织;批量文件入库(文件列表) + 实时时间线 + 力导向图谱 + 混合检索。
export function KbView() { export function KbView({ identity }: { identity: Identity }) {
const toast = useToast(); const toast = useToast();
const [kb, setKb] = useState("docs"); const [kbs, setKbs] = useState<KbInfo[]>([]);
const [kb, setKb] = useState("default");
const [creating, setCreating] = useState(false);
const [newName, setNewName] = useState("");
const [newKind, setNewKind] = useState("project");
const [text, setText] = useState(""); const [text, setText] = useState("");
const [logs, setLogs] = useState<IngestLog[]>([]); const [logs, setLogs] = useState<IngestLog[]>([]);
const [prog, setProg] = useState<Progress | null>(null); const [prog, setProg] = useState<Progress | null>(null);
const [files, setFiles] = useState<FileJob[]>([]);
const fileRef = useRef<HTMLInputElement>(null); const fileRef = useRef<HTMLInputElement>(null);
const folderRef = useRef<HTMLInputElement>(null);
const [q, setQ] = useState(""); const [q, setQ] = useState("");
const [topK, setTopK] = useState(5); const [topK, setTopK] = useState(5);
@@ -82,22 +122,49 @@ export function KbView() {
const [graph, setGraph] = useState<Triple[] | null>(null); const [graph, setGraph] = useState<Triple[] | null>(null);
const [dragOver, setDragOver] = useState(false); const [dragOver, setDragOver] = useState(false);
const onGraph = async () => { // 让文件夹输入框可选整个目录(标准类型无 webkitdirectory,挂在 DOM 上)。
useEffect(() => {
if (folderRef.current) folderRef.current.setAttribute("webkitdirectory", "");
}, []);
const refreshKbs = async () => {
try { 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) { } catch (e) {
toast.push("error", (e as Error).message); toast.push("error", (e as Error).message);
} }
}; };
const stamp = () => new Date().toLocaleTimeString(); 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 }] }); setProg({ active: true, stage: "提交", chunks: [], triples: [], steps: [{ stage: "提交", msg: label }] });
streamIngest( streamIngest(
job, 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) => { setProg((p) => {
const base: Progress = p ?? { active: true, stage: "提交", chunks: [], triples: [], steps: [] }; const base: Progress = p ?? { active: true, stage: "提交", chunks: [], triples: [], steps: [] };
const steps = [...base.steps]; const steps = [...base.steps];
@@ -116,17 +183,14 @@ export function KbView() {
vecDone: ev.stage === "向量化" ? ev.done : base.vecDone, vecDone: ev.stage === "向量化" ? ev.done : base.vecDone,
vecTotal: ev.stage === "向量化" ? ev.total : base.vecTotal, vecTotal: ev.stage === "向量化" ? ev.total : base.vecTotal,
}; };
}), });
},
() => { () => {
setProg((p) => { setProg((p) => {
const ok = p?.stage !== "失败"; const ok = p?.stage !== "失败";
setLogs((l) => [{ t: stamp(), msg: ok ? `${label}:入库完成` : `${label}${p?.error ?? "失败"}`, ok }, ...l]); if (fid) setFiles((fs) => fs.map((f) => (f.id === fid ? { ...f, status: ok ? "完成" : "失败" } : f)));
if (ok) { else setLogs((l) => [{ t: stamp(), msg: ok ? `${label}:入库完成` : `${label}${p?.error ?? "失败"}`, ok }, ...l]);
toast.push("success", `${label} 入库完成`); if (ok) void onGraph();
void onGraph(); // 刷新右侧知识图谱(含新抽取的实体关系)
} else {
toast.push("error", `${label} 入库失败`);
}
return p ? { ...p, active: false } : null; return p ? { ...p, active: false } : null;
}); });
}, },
@@ -137,7 +201,7 @@ export function KbView() {
const onIngest = async () => { const onIngest = async () => {
if (!text.trim()) return; if (!text.trim()) return;
try { try {
const job = await ingestKb(kb, text); const job = await ingestKb(identity, kb, text);
setText(""); setText("");
follow(job, "文本"); follow(job, "文本");
} catch (e) { } catch (e) {
@@ -145,23 +209,23 @@ export function KbView() {
} }
}; };
const onFile = async (file?: File) => { // 批量入库:每个文件起一个 job,行内显示各自状态。
if (!file) return; const ingestFiles = (list: FileList | File[] | null | undefined) => {
try { const arr = Array.from(list ?? []);
const job = await ingestFile(kb, file); arr.forEach((file, idx) => {
follow(job, file.name); const fid = `${file.name}-${idx}-${stamp()}-${Math.round(file.size)}`;
} catch (e) { setFiles((fs) => [...fs, { id: fid, name: file.name, status: "排队" }]);
setLogs((l) => [{ t: stamp(), msg: `${file.name}: ${(e as Error).message}`, ok: false }, ...l]); ingestFile(identity, kb, file)
} finally { .then((job) => follow(job, file.name, fid))
if (fileRef.current) fileRef.current.value = ""; .catch((e) => setFiles((fs) => fs.map((f) => (f.id === fid ? { ...f, status: "失败", error: (e as Error).message } : f))));
} });
}; };
const onSearch = async () => { const onSearch = async () => {
if (!q.trim()) return; if (!q.trim()) return;
setSearching(true); setSearching(true);
try { try {
setHits(await searchKb(kb, q, topK)); setHits(await searchKb(identity, kb, q, topK));
} catch (e) { } catch (e) {
toast.push("error", (e as Error).message); toast.push("error", (e as Error).message);
setHits(null); 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 vecPct = prog?.vecTotal ? Math.round(((prog.vecDone ?? 0) / prog.vecTotal) * 100) : 0;
const graphData = graph ?? prog?.triples ?? null; 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 ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<div className="flex items-center gap-2 border-b border-line bg-ink-900 px-4 py-2"> {/* 知识库选择 / 新建 */}
<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> <span className="text-sm font-semibold text-slate-300"></span>
<Input className="h-8 w-40" value={kb} onChange={(e) => setKb(e.target.value)} placeholder="知识库名" title="知识库(Milvus kb 字段分区)" /> <Select className="h-8 w-48" value={kb} onChange={(e) => setKb(e.target.value)}>
<span className="text-[11px] text-slate-500"> / / / / </span> {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> </div>
<div className="flex min-h-0 flex-1"> <div className="flex min-h-0 flex-1">
{/* 左:入库 + 实时时间线 */} {/* 左:入库 + 文件列表 + 时间线 */}
<section className="flex w-1/2 flex-col overflow-y-auto border-r border-line p-4"> <section className="flex w-1/2 flex-col overflow-y-auto border-r border-line p-4">
<h3 className="mb-2 text-xs font-semibold text-slate-400"></h3> <h3 className="mb-2 text-xs font-semibold text-slate-400">{kb}</h3>
<div <div
onDragOver={(e) => { onDragOver={(e) => {
e.preventDefault(); e.preventDefault();
@@ -194,35 +299,61 @@ export function KbView() {
onDrop={(e) => { onDrop={(e) => {
e.preventDefault(); e.preventDefault();
setDragOver(false); setDragOver(false);
const f = e.dataTransfer.files?.[0]; ingestFiles(e.dataTransfer.files);
if (f) onFile(f);
}} }}
className={cn("relative rounded-md", dragOver && "ring-2 ring-brand")} className={cn("relative rounded-md", dragOver && "ring-2 ring-brand")}
> >
<Textarea className="h-20 w-full resize-none" value={text} onChange={(e) => setText(e.target.value)} placeholder="每行一条知识,或把文件拖到这里 / 点选择文件" /> <Textarea className="h-20 w-full resize-none" value={text} onChange={(e) => setText(e.target.value)} placeholder="每行一条知识,或把一批文件 / 整个文件夹拖到这里" />
{dragOver && ( {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 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> </div>
<div className="mt-2 flex items-center gap-2"> <div className="mt-2 flex flex-wrap items-center gap-2">
<Button variant="primary" size="sm" icon={Upload} onClick={onIngest} disabled={ingesting || !text.trim()}> <Button variant="primary" size="sm" icon={Upload} onClick={onIngest} disabled={busy || !text.trim()}>
{ingesting ? "入库中…" : "入库文本"}
</Button> </Button>
<span className="text-[11px] text-slate-500"></span> <Button size="sm" icon={FileUp} onClick={() => fileRef.current?.click()} disabled={busy}>
<Button size="sm" icon={FileUp} onClick={() => fileRef.current?.click()} disabled={ingesting}>
</Button> </Button>
<input ref={fileRef} type="file" accept=".txt,.md,.csv,.docx,.xlsx,.pdf" onChange={(e) => onFile(e.target.files?.[0])} disabled={ingesting} className="hidden" /> <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> </div>
<span className="mt-1 text-[10px] text-slate-500"> txt/md/csv/docx/xlsx/pdfdocx/xlsx/pdf mcp-py </span> <span className="mt-1 text-[10px] text-slate-500"> txt/md/csv/docx/xlsx/pdfdocx/xlsx/pdf mcp-py </span>
{/* 批量文件列表 */}
{files.length > 0 && (
<div className="mt-3 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-40 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 && <Timeline prog={prog} vecPct={vecPct} />} {prog && <Timeline prog={prog} vecPct={vecPct} />}
<h3 className="mb-1 mt-4 text-xs font-semibold text-slate-400"></h3> <h3 className="mb-1 mt-4 text-xs font-semibold text-slate-400"></h3>
<ul className="space-y-1"> <ul className="space-y-1">
{logs.length === 0 && <li className="text-xs text-slate-600"></li>} {logs.length === 0 && <li className="text-xs text-slate-600"></li>}
{logs.map((l, i) => ( {logs.map((l, i) => (
<li key={i} className={cn("text-xs", l.ok ? "text-success" : "text-danger")}> <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} <span className="text-slate-600">{l.t}</span> {l.ok ? "✓" : "✗"} {l.msg}
@@ -233,7 +364,7 @@ export function KbView() {
{/* 右:检索台 + 知识图谱 */} {/* 右:检索台 + 知识图谱 */}
<section className="flex w-1/2 flex-col overflow-y-auto p-4"> <section className="flex w-1/2 flex-col overflow-y-auto p-4">
<h3 className="mb-2 text-xs font-semibold text-slate-400"> + rerank</h3> <h3 className="mb-2 text-xs font-semibold text-slate-400"> · {kb} + rerank</h3>
<div className="flex gap-2"> <div className="flex gap-2">
<Input className="flex-1" value={q} onChange={(e) => setQ(e.target.value)} onKeyDown={(e) => e.key === "Enter" && onSearch()} placeholder="输入查询,语义召回相关片段…" /> <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" /> <Input type="number" className="w-16" value={topK} min={1} max={20} onChange={(e) => setTopK(Number(e.target.value))} title="TopK" />
@@ -243,7 +374,7 @@ export function KbView() {
</div> </div>
<ul className="mt-3 space-y-2"> <ul className="mt-3 space-y-2">
{hits === null && <li className="text-xs text-slate-600"></li>} {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 !== null && hits.length === 0 && <li className="text-xs text-slate-600"> RAG </li>}
{hits?.map((h, i) => ( {hits?.map((h, i) => (
<li key={i} className="rounded-lg border border-line bg-ink-850 p-2"> <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]"> <div className="mb-1 flex items-center gap-2 text-[10px]">
@@ -271,6 +402,18 @@ export function KbView() {
); );
} }
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 渲染入库各阶段:状态灯 + 标签 + 详情;解析预览、切块块、抽取的知识三元组逐步呈现。 // Timeline 渲染入库各阶段:状态灯 + 标签 + 详情;解析预览、切块块、抽取的知识三元组逐步呈现。
function Timeline({ prog, vecPct }: { prog: Progress; vecPct: number }) { function Timeline({ prog, vecPct }: { prog: Progress; vecPct: number }) {
return ( return (
@@ -299,7 +442,6 @@ function Timeline({ prog, vecPct }: { prog: Progress; vecPct: number }) {
{s.msg && <span className="text-[11px] text-slate-500">{s.msg}</span>} {s.msg && <span className="text-[11px] text-slate-500">{s.msg}</span>}
</div> </div>
{/* 向量化进度条 */}
{s.stage === "向量化" && prog.vecTotal ? ( {s.stage === "向量化" && prog.vecTotal ? (
<div className="mt-1"> <div className="mt-1">
<div className="h-1.5 w-full overflow-hidden rounded-full bg-ink-700"> <div className="h-1.5 w-full overflow-hidden rounded-full bg-ink-700">
@@ -309,12 +451,10 @@ function Timeline({ prog, vecPct }: { prog: Progress; vecPct: number }) {
</div> </div>
) : null} ) : null}
{/* 解析预览 */}
{s.stage === "解析完成" && prog.preview ? ( {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> <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} ) : null}
{/* 切块预览 */}
{s.stage === "切块" && prog.chunks.length > 0 ? ( {s.stage === "切块" && prog.chunks.length > 0 ? (
<ul className="mt-1 max-h-20 space-y-0.5 overflow-auto"> <ul className="mt-1 max-h-20 space-y-0.5 overflow-auto">
{prog.chunks.map((c, j) => ( {prog.chunks.map((c, j) => (
@@ -329,7 +469,6 @@ function Timeline({ prog, vecPct }: { prog: Progress; vecPct: number }) {
})} })}
</ol> </ol>
{/* 抽取出的知识:三元组 chips + 实时小图谱 */}
{prog.triples.length > 0 && ( {prog.triples.length > 0 && (
<div className="mt-3 border-t border-line pt-2"> <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"> <div className="mb-1.5 flex items-center gap-1.5 text-[11px] font-medium text-slate-400">
+52 -4
View File
@@ -19,6 +19,52 @@ import (
"github.com/sundynix/sundynix-shared/contract" "github.com/sundynix/sundynix-shared/contract"
) )
// rawKB 规整知识库名(去空白,空则 default)—— 注册表里的展示名。
func rawKB(kb string) string {
kb = strings.TrimSpace(kb)
if kb == "" {
return "default"
}
return kb
}
// scopedKB 把知识库名锁进当前用户作用域:"owner/name"。
// owner 来自身份(X-User-ID),客户端只发库名、发不了 owner,故无法越权查到他人的库。
func scopedKB(c *gin.Context, kb string) string {
return userID(c) + "/" + rawKB(kb)
}
// KbList: GET /api/v1/kb/list —— 当前用户的知识库列表(按 owner 隔离)。
func (h *Handler) KbList(c *gin.Context) {
rows, err := h.db.ListKB(c.Request.Context(), userID(c))
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
out := make([]gin.H, 0, len(rows))
for _, r := range rows {
out = append(out, gin.H{"name": r.Name, "kind": r.Kind})
}
c.JSON(http.StatusOK, gin.H{"kbs": out})
}
// KbCreate: POST /api/v1/kb/create {name, kind} —— 新建知识库(folder/project/case/general)。
func (h *Handler) KbCreate(c *gin.Context) {
var body struct {
Name string `json:"name"`
Kind string `json:"kind"`
}
if err := c.ShouldBindJSON(&body); err != nil || strings.TrimSpace(body.Name) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "name required"})
return
}
if err := h.db.EnsureKB(c.Request.Context(), userID(c), rawKB(body.Name), body.Kind); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"name": rawKB(body.Name), "kind": body.Kind})
}
// KbIngest: POST /api/v1/kb/ingest —— 文本入库(异步,返回 job_id;进度经 SSE 看)。 // KbIngest: POST /api/v1/kb/ingest —— 文本入库(异步,返回 job_id;进度经 SSE 看)。
func (h *Handler) KbIngest(c *gin.Context) { func (h *Handler) KbIngest(c *gin.Context) {
var body struct { var body struct {
@@ -29,8 +75,9 @@ func (h *Handler) KbIngest(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "text required"}) c.JSON(http.StatusBadRequest, gin.H{"error": "text required"})
return return
} }
_ = h.db.EnsureKB(c.Request.Context(), userID(c), rawKB(body.KB), "general")
job := newJobID() job := newJobID()
go h.runIngest(job, body.KB, "", nil, body.Text) go h.runIngest(job, scopedKB(c, body.KB), "", nil, body.Text)
c.JSON(http.StatusAccepted, gin.H{"job_id": job}) c.JSON(http.StatusAccepted, gin.H{"job_id": job})
} }
@@ -54,8 +101,9 @@ func (h *Handler) KbIngestFile(c *gin.Context) {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return return
} }
_ = h.db.EnsureKB(c.Request.Context(), userID(c), rawKB(kb), "general")
job := newJobID() job := newJobID()
go h.runIngest(job, kb, fh.Filename, data, "") go h.runIngest(job, scopedKB(c, kb), fh.Filename, data, "")
c.JSON(http.StatusAccepted, gin.H{"job_id": job, "file": fh.Filename}) c.JSON(http.StatusAccepted, gin.H{"job_id": job, "file": fh.Filename})
} }
@@ -223,7 +271,7 @@ func (h *Handler) KbSearch(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "q required"}) c.JSON(http.StatusBadRequest, gin.H{"error": "q required"})
return return
} }
args := map[string]any{"kb": body.KB, "q": body.Q} args := map[string]any{"kb": scopedKB(c, body.KB), "q": body.Q}
if body.TopK > 0 { if body.TopK > 0 {
args["topK"] = body.TopK args["topK"] = body.TopK
} }
@@ -245,7 +293,7 @@ func (h *Handler) KbSearch(c *gin.Context) {
// KbGraph: GET /api/v1/kb/graph?kb= —— 某知识库的图谱三元组(→ mcp-go kb_graphNeo4j)。 // KbGraph: GET /api/v1/kb/graph?kb= —— 某知识库的图谱三元组(→ mcp-go kb_graphNeo4j)。
func (h *Handler) KbGraph(c *gin.Context) { func (h *Handler) KbGraph(c *gin.Context) {
res, err := h.bus.CallTool(c.Request.Context(), contract.ToolSubjectGo("kb_graph"), res, err := h.bus.CallTool(c.Request.Context(), contract.ToolSubjectGo("kb_graph"),
&contract.ToolCall{Tool: "kb_graph", Args: map[string]any{"kb": c.Query("kb"), "limit": 100}}) &contract.ToolCall{Tool: "kb_graph", Args: map[string]any{"kb": scopedKB(c, c.Query("kb")), "limit": 100}})
if err != nil { if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return return
@@ -24,6 +24,8 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus) *gin.Engine {
api.GET("/tasks/:id/stream", h.StreamTask) // 4. SSE/WS 回流 Token Stream api.GET("/tasks/:id/stream", h.StreamTask) // 4. SSE/WS 回流 Token Stream
api.GET("/tasks/:id/exec", h.StreamExec) // 4b. SSE 回流执行轨迹事件(运行·观测) api.GET("/tasks/:id/exec", h.StreamExec) // 4b. SSE 回流执行轨迹事件(运行·观测)
api.PUT("/memory", h.SetMemory) // 偏好记忆登记(→ mcp-go memory_upsert api.PUT("/memory", h.SetMemory) // 偏好记忆登记(→ mcp-go memory_upsert
api.GET("/kb/list", h.KbList) // 当前用户的知识库列表(owner 隔离)
api.POST("/kb/create", h.KbCreate) // 新建知识库(项目/案件/文件夹/通用)
api.POST("/kb/ingest", h.KbIngest) // 知识库入库(文本,→ mcp-go kb_ingest api.POST("/kb/ingest", h.KbIngest) // 知识库入库(文本,→ mcp-go kb_ingest
api.POST("/kb/ingest_file", h.KbIngestFile) // 文件入库(docx/xlsx/pdf… 异步) api.POST("/kb/ingest_file", h.KbIngestFile) // 文件入库(docx/xlsx/pdf… 异步)
api.GET("/kb/ingest/:id/stream", h.KbIngestStream) // 入库进度 SSE(实时监控) api.GET("/kb/ingest/:id/stream", h.KbIngestStream) // 入库进度 SSE(实时监控)
+39
View File
@@ -2,10 +2,49 @@ package store
import ( import (
"context" "context"
"time"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
) )
// KB 是一个知识库(按 owner 隔离 + 按 kind 组织:文件夹/项目/案件/通用)。
// 表名 sundynix_kb。(owner,name) 唯一 —— 同一用户下知识库名不重复。
// 向量/全文/图谱实际以 "owner/name" 作分区键,保证只有 owner 能查到自己的库。
type KB struct {
ID uint `gorm:"primaryKey"`
Owner string `gorm:"size:64;uniqueIndex:idx_kb_owner_name"`
Name string `gorm:"size:64;uniqueIndex:idx_kb_owner_name"`
Kind string `gorm:"size:16"` // folder / project / case / general
CreatedAt time.Time
}
func (KB) TableName() string { return "sundynix_kb" }
// ListKB 列出某 owner 的全部知识库(按创建时间)。
func (p *Postgres) ListKB(ctx context.Context, owner string) ([]KB, error) {
if p.db == nil {
return nil, nil
}
var rows []KB
err := p.db.WithContext(ctx).Where("owner = ?", owner).Order("id").Find(&rows).Error
return rows, err
}
// EnsureKB 幂等登记一个知识库(已存在则保持,不覆盖 kind)。
func (p *Postgres) EnsureKB(ctx context.Context, owner, name, kind string) error {
if p.db == nil {
return nil // 降级模式:不持久化注册表,不阻断入库
}
if kind == "" {
kind = "general"
}
return p.db.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "owner"}, {Name: "name"}},
DoNothing: true,
}).Create(&KB{Owner: owner, Name: name, Kind: kind}).Error
}
// LLMModel 是一个模型后端配置(控制面:管理员在此登记可用模型)。 // LLMModel 是一个模型后端配置(控制面:管理员在此登记可用模型)。
// 表名 sundynix_model(遵守前缀约定)。每个 kind 同一时刻仅一条 Active=true。 // 表名 sundynix_model(遵守前缀约定)。每个 kind 同一时刻仅一条 Active=true。
type LLMModel struct { type LLMModel struct {
+1 -1
View File
@@ -34,7 +34,7 @@ func OpenPostgres(dsn string) *Postgres {
log.Printf("[store] postgres 不可用,降级运行(不持久化): %v", err) log.Printf("[store] postgres 不可用,降级运行(不持久化): %v", err)
return &Postgres{} return &Postgres{}
} }
if err := db.AutoMigrate(&User{}, &Task{}, &LLMModel{}); err != nil { if err := db.AutoMigrate(&User{}, &Task{}, &LLMModel{}, &KB{}); err != nil {
log.Printf("[store] postgres AutoMigrate 失败,降级运行: %v", err) log.Printf("[store] postgres AutoMigrate 失败,降级运行: %v", err)
return &Postgres{} return &Postgres{}
} }