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:
@@ -126,7 +126,7 @@ export default function App() {
|
||||
) : view === "studio" ? (
|
||||
<StudioView onRun={onRun} phase={run.phase} />
|
||||
) : view === "kb" ? (
|
||||
<KbView />
|
||||
<KbView identity={identity} />
|
||||
) : view === "report" ? (
|
||||
<ReportView identity={identity} />
|
||||
) : view === "runs" ? (
|
||||
|
||||
@@ -91,11 +91,41 @@ export interface IngestEvent {
|
||||
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)。
|
||||
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`, {
|
||||
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<string> {
|
||||
}
|
||||
|
||||
// ingestFile: POST /api/v1/kb/ingest_file(multipart)—— 文件入库(异步,返回 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();
|
||||
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<Triple[]> {
|
||||
const res = await fetch(`${GATEWAY}/api/v1/kb/graph?kb=${encodeURIComponent(kb)}`);
|
||||
export async function graphKb(id: Identity, kb: string): Promise<Triple[]> {
|
||||
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<KbHit[]> {
|
||||
export async function searchKb(id: Identity, kb: string, q: string, topK = 5): Promise<KbHit[]> {
|
||||
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 };
|
||||
|
||||
@@ -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<string, { icon: LucideIcon; label: string }> = {
|
||||
解析: { icon: Upload, label: "解析文件" },
|
||||
解析完成: { icon: FileText, label: "解析完成" },
|
||||
@@ -52,6 +74,17 @@ const STAGE: Record<string, { icon: LucideIcon; label: string }> = {
|
||||
完成: { 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>();
|
||||
@@ -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<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 [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);
|
||||
@@ -82,22 +122,49 @@ export function KbView() {
|
||||
const [graph, setGraph] = useState<Triple[] | null>(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 (
|
||||
<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>
|
||||
<Input className="h-8 w-40" value={kb} onChange={(e) => setKb(e.target.value)} placeholder="知识库名" title="知识库(Milvus kb 字段分区)" />
|
||||
<span className="text-[11px] text-slate-500">入库 → 解析 / 切块 / 向量化 / 抽取知识 / 写入;检索 → 三路混合召回</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>
|
||||
|
||||
<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">
|
||||
<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
|
||||
onDragOver={(e) => {
|
||||
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")}
|
||||
>
|
||||
<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 && (
|
||||
<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 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 items-center gap-2">
|
||||
<Button variant="primary" size="sm" icon={Upload} onClick={onIngest} disabled={ingesting || !text.trim()}>
|
||||
{ingesting ? "入库中…" : "入库文本"}
|
||||
<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>
|
||||
<span className="text-[11px] text-slate-500">或</span>
|
||||
<Button size="sm" icon={FileUp} onClick={() => fileRef.current?.click()} disabled={ingesting}>
|
||||
<Button size="sm" icon={FileUp} onClick={() => fileRef.current?.click()} disabled={busy}>
|
||||
选择文件
|
||||
</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>
|
||||
<span className="mt-1 text-[10px] text-slate-500">支持 txt/md/csv/docx/xlsx/pdf(docx/xlsx/pdf 经 mcp-py 解析)</span>
|
||||
<span className="mt-1 text-[10px] text-slate-500">支持批量 txt/md/csv/docx/xlsx/pdf;可整文件夹拖入(docx/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} />}
|
||||
|
||||
<h3 className="mb-1 mt-4 text-xs font-semibold text-slate-400">入库历史</h3>
|
||||
<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) => (
|
||||
<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}
|
||||
@@ -233,7 +364,7 @@ export function KbView() {
|
||||
|
||||
{/* 右:检索台 + 知识图谱 */}
|
||||
<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">
|
||||
<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" />
|
||||
@@ -243,7 +374,7 @@ export function KbView() {
|
||||
</div>
|
||||
<ul className="mt-3 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 !== 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]">
|
||||
@@ -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 渲染入库各阶段:状态灯 + 标签 + 详情;解析预览、切块块、抽取的知识三元组逐步呈现。
|
||||
function Timeline({ prog, vecPct }: { prog: Progress; vecPct: number }) {
|
||||
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>}
|
||||
</div>
|
||||
|
||||
{/* 向量化进度条 */}
|
||||
{s.stage === "向量化" && prog.vecTotal ? (
|
||||
<div className="mt-1">
|
||||
<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>
|
||||
) : 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) => (
|
||||
@@ -329,7 +469,6 @@ function Timeline({ prog, vecPct }: { prog: Progress; vecPct: number }) {
|
||||
})}
|
||||
</ol>
|
||||
|
||||
{/* 抽取出的知识:三元组 chips + 实时小图谱 */}
|
||||
{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">
|
||||
|
||||
@@ -19,6 +19,52 @@ import (
|
||||
"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 看)。
|
||||
func (h *Handler) KbIngest(c *gin.Context) {
|
||||
var body struct {
|
||||
@@ -29,8 +75,9 @@ func (h *Handler) KbIngest(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "text required"})
|
||||
return
|
||||
}
|
||||
_ = h.db.EnsureKB(c.Request.Context(), userID(c), rawKB(body.KB), "general")
|
||||
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})
|
||||
}
|
||||
|
||||
@@ -54,8 +101,9 @@ func (h *Handler) KbIngestFile(c *gin.Context) {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
_ = h.db.EnsureKB(c.Request.Context(), userID(c), rawKB(kb), "general")
|
||||
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})
|
||||
}
|
||||
|
||||
@@ -223,7 +271,7 @@ func (h *Handler) KbSearch(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "q required"})
|
||||
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 {
|
||||
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_graph,Neo4j)。
|
||||
func (h *Handler) KbGraph(c *gin.Context) {
|
||||
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 {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
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/exec", h.StreamExec) // 4b. SSE 回流执行轨迹事件(运行·观测)
|
||||
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_file", h.KbIngestFile) // 文件入库(docx/xlsx/pdf… 异步)
|
||||
api.GET("/kb/ingest/:id/stream", h.KbIngestStream) // 入库进度 SSE(实时监控)
|
||||
|
||||
@@ -2,10 +2,49 @@ package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"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 是一个模型后端配置(控制面:管理员在此登记可用模型)。
|
||||
// 表名 sundynix_model(遵守前缀约定)。每个 kind 同一时刻仅一条 Active=true。
|
||||
type LLMModel struct {
|
||||
|
||||
@@ -34,7 +34,7 @@ func OpenPostgres(dsn string) *Postgres {
|
||||
log.Printf("[store] postgres 不可用,降级运行(不持久化): %v", err)
|
||||
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)
|
||||
return &Postgres{}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user