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:
@@ -5,6 +5,7 @@ import { LeftNav, type ViewKey } from "./shell/LeftNav";
|
|||||||
import { BottomDrawer } from "./shell/BottomDrawer";
|
import { BottomDrawer } from "./shell/BottomDrawer";
|
||||||
import { StudioView } from "./studio/StudioView";
|
import { StudioView } from "./studio/StudioView";
|
||||||
import { MemoryView } from "./views/MemoryView";
|
import { MemoryView } from "./views/MemoryView";
|
||||||
|
import { KbView } from "./views/KbView";
|
||||||
import { Placeholder } from "./views/Placeholder";
|
import { Placeholder } from "./views/Placeholder";
|
||||||
import { submitTask, streamTokens, type Identity } from "./lib/api";
|
import { submitTask, streamTokens, type Identity } from "./lib/api";
|
||||||
import type { TaskDsl } from "./lib/dsl";
|
import type { TaskDsl } from "./lib/dsl";
|
||||||
@@ -70,6 +71,8 @@ export default function App() {
|
|||||||
<main className="min-w-0 flex-1 overflow-hidden">
|
<main className="min-w-0 flex-1 overflow-hidden">
|
||||||
{view === "studio" ? (
|
{view === "studio" ? (
|
||||||
<StudioView onRun={onRun} phase={run.phase} />
|
<StudioView onRun={onRun} phase={run.phase} />
|
||||||
|
) : view === "kb" ? (
|
||||||
|
<KbView />
|
||||||
) : view === "memory" ? (
|
) : view === "memory" ? (
|
||||||
<MemoryView identity={identity} />
|
<MemoryView identity={identity} />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -47,6 +47,35 @@ export function streamTokens(
|
|||||||
return () => es.close();
|
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)。
|
// setMemory: PUT /api/v1/memory,登记一条用户偏好(→ mcp-go memory_upsert)。
|
||||||
export async function setMemory(
|
export async function setMemory(
|
||||||
id: Identity,
|
id: Identity,
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ interface Item {
|
|||||||
const ITEMS: Item[] = [
|
const ITEMS: Item[] = [
|
||||||
{ key: "home", label: "工作台", icon: "■" },
|
{ key: "home", label: "工作台", icon: "■" },
|
||||||
{ key: "studio", label: "编排", icon: "◆", group: "BUILD", ready: true },
|
{ 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: "report", label: "报告", icon: "▤", group: "BUILD" },
|
||||||
{ key: "runs", label: "运行", icon: "▸", group: "RUN" },
|
{ key: "runs", label: "运行", icon: "▸", group: "RUN" },
|
||||||
{ key: "memory", label: "记忆", icon: "◇", group: "MANAGE", ready: true },
|
{ 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -31,3 +32,33 @@ func (h *Handler) KbIngest(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"status": "ok", "message": res.Content})
|
c.JSON(http.StatusOK, gin.H{"status": "ok", "message": res.Content})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// KbSearch: POST /api/v1/kb/search —— 检索台:查某知识库,返回带分数的命中(→ mcp-go kb_search)。
|
||||||
|
func (h *Handler) KbSearch(c *gin.Context) {
|
||||||
|
var body struct {
|
||||||
|
KB string `json:"kb"`
|
||||||
|
Q string `json:"q"`
|
||||||
|
TopK int `json:"topK"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil || body.Q == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "q required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
args := map[string]any{"kb": body.KB, "q": body.Q}
|
||||||
|
if body.TopK > 0 {
|
||||||
|
args["topK"] = body.TopK
|
||||||
|
}
|
||||||
|
res, err := h.bus.CallTool(c.Request.Context(), contract.ToolSubjectGo("kb_search"),
|
||||||
|
&contract.ToolCall{Tool: "kb_search", Args: args})
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !res.OK {
|
||||||
|
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": res.Error})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var hits []map[string]any
|
||||||
|
_ = json.Unmarshal([]byte(res.Content), &hits)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"hits": hits})
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ 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.PUT("/memory", h.SetMemory) // 偏好记忆登记(→ mcp-go memory_upsert)
|
api.PUT("/memory", h.SetMemory) // 偏好记忆登记(→ mcp-go memory_upsert)
|
||||||
api.POST("/kb/ingest", h.KbIngest) // 知识库入库(→ mcp-go kb_ingest,RAG)
|
api.POST("/kb/ingest", h.KbIngest) // 知识库入库(→ mcp-go kb_ingest,RAG)
|
||||||
|
api.POST("/kb/search", h.KbSearch) // 知识库检索台(→ mcp-go kb_search)
|
||||||
api.GET("/billing", h.Billing)
|
api.GET("/billing", h.Billing)
|
||||||
|
|
||||||
// 运维控制面:LLM 模型配置(独立运维控制台调用)。
|
// 运维控制面:LLM 模型配置(独立运维控制台调用)。
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ func (g *Gateway) Serve(ctx context.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer func() { _ = unsub() }()
|
defer func() { _ = unsub() }()
|
||||||
log.Printf("[mcp_go] tools ready on %s (queue=%s): wiki_search, kb_ingest, memory_get, memory_upsert, history_get, history_append, echo",
|
log.Printf("[mcp_go] tools ready on %s (queue=%s): wiki_search, kb_ingest, kb_search, memory_get, memory_upsert, history_get, history_append, echo",
|
||||||
contract.SubjectToolsGoAll, contract.QueueToolsGo)
|
contract.SubjectToolsGoAll, contract.QueueToolsGo)
|
||||||
<-ctx.Done()
|
<-ctx.Done()
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
@@ -51,6 +51,8 @@ func (g *Gateway) dispatch(ctx context.Context, call *contract.ToolCall) *contra
|
|||||||
return g.wikiSearch(ctx, call)
|
return g.wikiSearch(ctx, call)
|
||||||
case "kb_ingest":
|
case "kb_ingest":
|
||||||
return g.kbIngest(ctx, call)
|
return g.kbIngest(ctx, call)
|
||||||
|
case "kb_search":
|
||||||
|
return g.kbSearch(ctx, call)
|
||||||
case "memory_get":
|
case "memory_get":
|
||||||
return g.memoryGet(ctx, call)
|
return g.memoryGet(ctx, call)
|
||||||
case "memory_upsert":
|
case "memory_upsert":
|
||||||
@@ -139,6 +141,25 @@ func (g *Gateway) wikiSearch(ctx context.Context, call *contract.ToolCall) *cont
|
|||||||
return &contract.ToolResult{OK: true, Content: strings.TrimRight(b.String(), "\n")}
|
return &contract.ToolResult{OK: true, Content: strings.TrimRight(b.String(), "\n")}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// kbSearch 检索台用:返回结构化命中 JSON [{text,score},...](供检索台展示分数)。
|
||||||
|
func (g *Gateway) kbSearch(ctx context.Context, call *contract.ToolCall) *contract.ToolResult {
|
||||||
|
q, _ := call.Args["q"].(string)
|
||||||
|
kb, _ := call.Args["kb"].(string)
|
||||||
|
topK := 5
|
||||||
|
if v, ok := call.Args["topK"].(float64); ok && v > 0 {
|
||||||
|
topK = int(v)
|
||||||
|
}
|
||||||
|
if !g.rag.Ready() {
|
||||||
|
return &contract.ToolResult{OK: true, Content: "[]"}
|
||||||
|
}
|
||||||
|
hits, err := g.rag.Search(ctx, kb, q, topK)
|
||||||
|
if err != nil {
|
||||||
|
return &contract.ToolResult{OK: false, Error: "kb_search: " + err.Error()}
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(hits)
|
||||||
|
return &contract.ToolResult{OK: true, Content: string(data)}
|
||||||
|
}
|
||||||
|
|
||||||
// kbIngest 把文本入库(切块→embedding→Milvus)。
|
// kbIngest 把文本入库(切块→embedding→Milvus)。
|
||||||
func (g *Gateway) kbIngest(ctx context.Context, call *contract.ToolCall) *contract.ToolResult {
|
func (g *Gateway) kbIngest(ctx context.Context, call *contract.ToolCall) *contract.ToolResult {
|
||||||
kb, _ := call.Args["kb"].(string)
|
kb, _ := call.Args["kb"].(string)
|
||||||
|
|||||||
@@ -120,8 +120,8 @@ func vectorDim(coll *entity.Collection) int {
|
|||||||
|
|
||||||
// Hit 是一条检索结果。
|
// Hit 是一条检索结果。
|
||||||
type Hit struct {
|
type Hit struct {
|
||||||
Text string
|
Text string `json:"text"`
|
||||||
Score float32
|
Score float32 `json:"score"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// search 用查询向量做 topK 向量检索(可按 kb 过滤)。
|
// search 用查询向量做 topK 向量检索(可按 kb 过滤)。
|
||||||
|
|||||||
Reference in New Issue
Block a user