feat(admin): 数据源页转 RAG 运维台 + 支付/模型菜单重组 + 概览升级为仪表盘
后端: - 新增 POST /admin/kb/search:管理端跨租户检索,支持 mode 指定单路 (vector/fulltext/graph/hybrid),不走 scopedKB(否则会被强制锁到调用者 自己的 space,跨租户排障就没法做了) - KB 清单补 space_id(检索键是 <space_id>/<name>,缺它前端拼不出 key) 前端: - 数据源&RAG 页补「检索试验台」:同一 query 并排跑生产链路 + 四路诊断, 召回不准时能直接定位是向量/分词/图谱哪一环挂了 - 支付拆成「配置 / 订单与对账」两个子页,挂到运维 > 支付 下; 导航支持二级菜单(NavParent 命中子路由自动展开) - SettingsPage → ModelConfigPage「模型配置」,模型参数与计费规则合一 - 概览 → 仪表盘:并入计费与用量(UsagePage → UsageSection), 去掉系统健康拓扑(与服务状态页重复,同一份 /admin/status 数据) - 全局隐藏滚动条(保留滚动) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
import { useState } from "react";
|
||||
import { adminKbSearch, type DatasourceKB, type KbHit, type SearchMode } from "../api";
|
||||
|
||||
// 检索试验台:对同一个 query 同时跑「生产链路」与「三路 + RRF 融合」,并排看各自召回。
|
||||
// 用途:线上召回不准时定位是哪一环 —— 向量路空=embedding/切块问题;全文路空=分词/索引问题;
|
||||
// 图谱路空=Neo4j 未起或未抽三元组;融合有而生产为空=rerank 把结果滤掉了。
|
||||
//
|
||||
// ⚠️ 分数不可跨路比较:向量是余弦相似度(0~1),RRF 是 1/(k+rank) 的融合分(通常 <0.05)。
|
||||
|
||||
const ROUTES: Array<{ mode: SearchMode; label: string; hint: string }> = [
|
||||
{ mode: "vector", label: "向量", hint: "Milvus · 语义相似" },
|
||||
{ mode: "fulltext", label: "全文", hint: "Bleve · 关键词倒排" },
|
||||
{ mode: "graph", label: "图谱", hint: "Neo4j · 实体关系" },
|
||||
{ mode: "hybrid", label: "RRF 融合", hint: "三路融合 · 不含 rerank" },
|
||||
];
|
||||
|
||||
type Results = Partial<Record<string, { hits: KbHit[]; err?: string }>>;
|
||||
|
||||
export function RetrievalBench({ kbs }: { kbs: DatasourceKB[] }) {
|
||||
const withDocs = kbs.filter((k) => k.doc_count > 0);
|
||||
const [kbKey, setKbKey] = useState("");
|
||||
const [q, setQ] = useState("");
|
||||
const [topK, setTopK] = useState(5);
|
||||
const [res, setRes] = useState<Results>({});
|
||||
const [running, setRunning] = useState(false);
|
||||
const [ran, setRan] = useState(false);
|
||||
|
||||
const run = async () => {
|
||||
if (!kbKey || !q.trim()) return;
|
||||
setRunning(true);
|
||||
setRan(true);
|
||||
// 生产链路 + 四路诊断并行跑,各自独立成败(一路挂不影响其它路展示)。
|
||||
const modes: SearchMode[] = ["", ...ROUTES.map((r) => r.mode)];
|
||||
const settled = await Promise.all(
|
||||
modes.map(async (m) => {
|
||||
try {
|
||||
return [m, { hits: await adminKbSearch(kbKey, q.trim(), topK, m) }] as const;
|
||||
} catch (e) {
|
||||
return [m, { hits: [], err: (e as Error).message }] as const;
|
||||
}
|
||||
}),
|
||||
);
|
||||
setRes(Object.fromEntries(settled));
|
||||
setRunning(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<h4 className="text-sm font-semibold text-gray-700">检索试验台</h4>
|
||||
<span className="text-[11px] text-gray-400">同一 query 跑通所有检索路,定位召回问题出在哪一环</span>
|
||||
</div>
|
||||
|
||||
{/* 查询条件 */}
|
||||
<div className="mt-4 flex flex-wrap items-end gap-2">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-[11px] text-gray-400">知识库</span>
|
||||
<select
|
||||
value={kbKey}
|
||||
onChange={(e) => setKbKey(e.target.value)}
|
||||
className="w-64 rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-sm text-gray-700 focus:border-violet-400 focus:outline-none"
|
||||
>
|
||||
<option value="">选择一个知识库…</option>
|
||||
{withDocs.map((k) => (
|
||||
<option key={k.id} value={`${k.space_id}/${k.name}`}>
|
||||
{k.name} · {k.tenant_name || k.tenant_id} ({k.doc_count} 篇)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-1 flex-col gap-1" style={{ minWidth: 220 }}>
|
||||
<span className="text-[11px] text-gray-400">查询</span>
|
||||
<input
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && void run()}
|
||||
placeholder="输入一个真实用户会问的问题…"
|
||||
className="rounded-lg border border-gray-200 px-3 py-1.5 text-sm text-gray-700 focus:border-violet-400 focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-[11px] text-gray-400">topK</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={20}
|
||||
value={topK}
|
||||
onChange={(e) => setTopK(Math.max(1, Math.min(20, Number(e.target.value) || 5)))}
|
||||
className="w-20 rounded-lg border border-gray-200 px-3 py-1.5 text-sm text-gray-700 focus:border-violet-400 focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button
|
||||
onClick={() => void run()}
|
||||
disabled={running || !kbKey || !q.trim()}
|
||||
className="rounded-lg bg-violet-600 px-4 py-1.5 text-xs font-medium text-white hover:bg-violet-700 disabled:opacity-40"
|
||||
>
|
||||
{running ? "检索中…" : "跑一遍"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{withDocs.length === 0 && (
|
||||
<p className="mt-3 text-xs text-amber-600">还没有含文档的知识库,先去桌面端建库并入库再来试。</p>
|
||||
)}
|
||||
|
||||
{ran && (
|
||||
<div className="mt-5 space-y-4">
|
||||
{/* 生产链路:用户实际拿到的结果 */}
|
||||
<RouteCard
|
||||
label="生产链路"
|
||||
hint="混合检索 + rerank —— Agent 实际拿到的就是这个"
|
||||
data={res[""]}
|
||||
highlight
|
||||
/>
|
||||
|
||||
{/* 诊断四路 */}
|
||||
<div>
|
||||
<div className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-gray-300">
|
||||
分路诊断(分数体系不同,勿跨路比大小)
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
{ROUTES.map((r) => (
|
||||
<RouteCard key={r.mode} label={r.label} hint={r.hint} data={res[r.mode]} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RouteCard({
|
||||
label,
|
||||
hint,
|
||||
data,
|
||||
highlight,
|
||||
}: {
|
||||
label: string;
|
||||
hint: string;
|
||||
data?: { hits: KbHit[]; err?: string };
|
||||
highlight?: boolean;
|
||||
}) {
|
||||
const hits = data?.hits ?? [];
|
||||
return (
|
||||
<div className={`rounded-lg border p-3 ${highlight ? "border-violet-200 bg-violet-50/40" : "border-gray-100 bg-gray-50/40"}`}>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="text-xs font-semibold text-gray-700">{label}</span>
|
||||
<span className={`text-[11px] tabular-nums ${hits.length ? "text-emerald-600" : "text-gray-400"}`}>
|
||||
{hits.length} 命中
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 text-[10px] text-gray-400">{hint}</div>
|
||||
|
||||
{data?.err ? (
|
||||
<p className="mt-2 text-[11px] text-rose-500">{data.err}</p>
|
||||
) : hits.length === 0 ? (
|
||||
<p className="mt-2 text-[11px] text-gray-400">这一路没召回</p>
|
||||
) : (
|
||||
<ol className="mt-2 space-y-1.5">
|
||||
{hits.map((h, i) => (
|
||||
<li key={i} className="text-[11px] leading-relaxed">
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="shrink-0 text-gray-300">#{i + 1}</span>
|
||||
<span className="shrink-0 tabular-nums text-violet-600">{h.score.toFixed(4)}</span>
|
||||
</div>
|
||||
<p className="mt-0.5 line-clamp-3 text-gray-600" title={h.text}>
|
||||
{h.text}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user