feat: 知识库管理界面(入库监控 + 检索台)

桌面端「知识库」模块从占位变为可用:入库(切块/embedding/Milvus 监控) +
检索调试台(向量召回,带分数与来源)。

- mcp-go: 新工具 kb_search(返回结构化 JSON [{text,score}]);rag.Hit 加 json 标签
- gateway: POST /api/v1/kb/search → kb_search(结构化命中给检索台)
- desktop: lib/api ingestKb/searchKb;新 KbView(左 入库+监控日志 / 右 检索台命中列表
  带 Milvus 来源徽标+分数);App 接 kb 视图;LeftNav 知识库 ready
- 验证: gateway/mcp-go build✓ + e2e PASS + 前端 build✓;真实浏览器——入库3条→监控
  '已入库3块';语义查询'存储和搜索向量的组件'→Milvus(0.612)>Neo4j>NATS 排序正确,
  全走真实百炼 embedding(控制面下发)+Milvus

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-06-10 17:40:32 +08:00
parent 3b54e59ecf
commit 8ff68078b7
8 changed files with 233 additions and 4 deletions
+3
View File
@@ -5,6 +5,7 @@ import { LeftNav, type ViewKey } from "./shell/LeftNav";
import { BottomDrawer } from "./shell/BottomDrawer";
import { StudioView } from "./studio/StudioView";
import { MemoryView } from "./views/MemoryView";
import { KbView } from "./views/KbView";
import { Placeholder } from "./views/Placeholder";
import { submitTask, streamTokens, type Identity } from "./lib/api";
import type { TaskDsl } from "./lib/dsl";
@@ -70,6 +71,8 @@ export default function App() {
<main className="min-w-0 flex-1 overflow-hidden">
{view === "studio" ? (
<StudioView onRun={onRun} phase={run.phase} />
) : view === "kb" ? (
<KbView />
) : view === "memory" ? (
<MemoryView identity={identity} />
) : (
+29
View File
@@ -47,6 +47,35 @@ export function streamTokens(
return () => es.close();
}
// ingestKb: POST /api/v1/kb/ingest,把文本入库(→ mcp-go kb_ingest:切块/embedding/Milvus)。
export async function ingestKb(kb: string, text: string): Promise<string> {
const res = await fetch(`${GATEWAY}/api/v1/kb/ingest`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ kb, text }),
});
const data = (await res.json()) as { message?: string; error?: string };
if (!res.ok) throw new Error(data.error ?? `ingest failed: ${res.status}`);
return data.message ?? "ok";
}
export interface KbHit {
text: string;
score: number;
}
// searchKb: POST /api/v1/kb/search,检索台查询(→ mcp-go kb_search,带分数)。
export async function searchKb(kb: string, q: string, topK = 5): Promise<KbHit[]> {
const res = await fetch(`${GATEWAY}/api/v1/kb/search`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ kb, q, topK }),
});
const data = (await res.json()) as { hits?: KbHit[]; error?: string };
if (!res.ok) throw new Error(data.error ?? `search failed: ${res.status}`);
return data.hits ?? [];
}
// setMemory: PUT /api/v1/memory,登记一条用户偏好(→ mcp-go memory_upsert)。
export async function setMemory(
id: Identity,
@@ -19,7 +19,7 @@ interface Item {
const ITEMS: Item[] = [
{ key: "home", label: "工作台", icon: "■" },
{ key: "studio", label: "编排", icon: "◆", group: "BUILD", ready: true },
{ key: "kb", label: "知识库", icon: "▣", group: "BUILD" },
{ key: "kb", label: "知识库", icon: "▣", group: "BUILD", ready: true },
{ key: "report", label: "报告", icon: "▤", group: "BUILD" },
{ key: "runs", label: "运行", icon: "▸", group: "RUN" },
{ key: "memory", label: "记忆", icon: "◇", group: "MANAGE", ready: true },
@@ -0,0 +1,144 @@
import { useState } from "react";
import { ingestKb, searchKb, type KbHit } from "../lib/api";
interface IngestLog {
t: string;
msg: string;
ok: boolean;
}
// 知识库管理:入库监控(切块/embedding/Milvus+ 检索调试台(带分数与来源)。
export function KbView() {
const [kb, setKb] = useState("docs");
const [text, setText] = useState("");
const [logs, setLogs] = useState<IngestLog[]>([]);
const [ingesting, setIngesting] = useState(false);
const [q, setQ] = useState("");
const [topK, setTopK] = useState(5);
const [hits, setHits] = useState<KbHit[] | null>(null);
const [searching, setSearching] = useState(false);
const [err, setErr] = useState("");
const stamp = () => new Date().toLocaleTimeString();
const onIngest = async () => {
if (!text.trim()) return;
setIngesting(true);
try {
const msg = await ingestKb(kb, text);
setLogs((l) => [{ t: stamp(), msg, ok: true }, ...l]);
setText("");
} catch (e) {
setLogs((l) => [{ t: stamp(), msg: (e as Error).message, ok: false }, ...l]);
} finally {
setIngesting(false);
}
};
const onSearch = async () => {
if (!q.trim()) return;
setSearching(true);
setErr("");
try {
setHits(await searchKb(kb, q, topK));
} catch (e) {
setErr((e as Error).message);
setHits(null);
} finally {
setSearching(false);
}
};
return (
<div className="flex h-full flex-col">
<div className="flex items-center gap-2 border-b bg-white px-4 py-2">
<span className="text-sm font-semibold text-gray-700"></span>
<input
className="w-40 rounded border px-2 py-1 text-sm"
value={kb}
onChange={(e) => setKb(e.target.value)}
placeholder="知识库名"
title="知识库(Milvus kb 字段分区)"
/>
<span className="text-[11px] text-gray-400"> / embedding / Milvus </span>
</div>
<div className="flex min-h-0 flex-1">
{/* 左:入库 + 监控日志 */}
<section className="flex w-1/2 flex-col border-r p-4">
<h3 className="mb-2 text-xs font-semibold text-gray-600"></h3>
<textarea
className="h-40 w-full resize-none rounded border p-2 text-sm"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder={"每行一条知识,例如:\nsundynix 用 Milvus 做向量库\nsundynix 用 NATS 做消息总线"}
/>
<button
onClick={onIngest}
disabled={ingesting || !text.trim()}
className="mt-2 self-start rounded bg-emerald-600 px-3 py-1 text-sm text-white disabled:opacity-40"
>
{ingesting ? "入库中…" : "⬆ 入库"}
</button>
<h3 className="mb-1 mt-4 text-xs font-semibold text-gray-600"></h3>
<ul className="flex-1 space-y-1 overflow-auto">
{logs.length === 0 && <li className="text-xs text-gray-400"></li>}
{logs.map((l, i) => (
<li key={i} className={`text-xs ${l.ok ? "text-emerald-700" : "text-rose-600"}`}>
<span className="text-gray-400">{l.t}</span> {l.ok ? "✓" : "✗"} {l.msg}
</li>
))}
</ul>
</section>
{/* 右:检索调试台 */}
<section className="flex w-1/2 flex-col p-4">
<h3 className="mb-2 text-xs font-semibold text-gray-600"></h3>
<div className="flex gap-2">
<input
className="flex-1 rounded border px-2 py-1 text-sm"
value={q}
onChange={(e) => setQ(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && onSearch()}
placeholder="输入查询,语义召回相关片段…"
/>
<input
type="number"
className="w-16 rounded border px-2 py-1 text-sm"
value={topK}
min={1}
max={20}
onChange={(e) => setTopK(Number(e.target.value))}
title="TopK"
/>
<button
onClick={onSearch}
disabled={searching || !q.trim()}
className="rounded bg-violet-600 px-3 py-1 text-sm text-white disabled:opacity-40"
>
{searching ? "检索中…" : "检索"}
</button>
</div>
{err && <p className="mt-2 text-xs text-rose-600"> {err}</p>}
<ul className="mt-3 flex-1 space-y-2 overflow-auto">
{hits === null && <li className="text-xs text-gray-400"></li>}
{hits !== null && hits.length === 0 && (
<li className="text-xs text-gray-400"> RAG </li>
)}
{hits?.map((h, i) => (
<li key={i} className="rounded border bg-gray-50 p-2">
<div className="mb-1 flex items-center gap-2 text-[10px]">
<span className="rounded bg-sky-100 px-1.5 py-0.5 text-sky-700">Milvus </span>
<span className="text-gray-400">#{i + 1}</span>
<span className="ml-auto font-mono text-violet-600">{h.score.toFixed(3)}</span>
</div>
<div className="text-xs text-gray-800">{h.text}</div>
</li>
))}
</ul>
</section>
</div>
</div>
);
}