Files
sundynix-agentix/sundynix-admin/src/pages/DatasourcesPage.tsx
T
Blizzard 79b110afd0 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>
2026-07-20 11:17:54 +08:00

323 lines
17 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { RetrievalBench } from "../components/RetrievalBench";
import { adminDatasources, listModels, migrateKbStorage, type DatasourceKB, type MigrateKbResult } from "../api";
// 数据源 & RAG:真数据(全平台知识库清单,来自 sundynix_kb/sundynix_doc)。
const KIND_LABEL: Record<string, string> = { general: "通用", folder: "文件夹", project: "项目", case: "案例" };
const fmtWords = (n: number) => (n >= 1e4 ? `${(n / 1e4).toFixed(1)}` : `${n}`);
export function DatasourcesPage() {
const [counts, setCounts] = useState({ users: 0, kbs: 0, docs: 0 });
const [rows, setRows] = useState<DatasourceKB[]>([]);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState("");
// 运行时的 active embedding 模型展示
const [activeEmbedding, setActiveEmbedding] = useState<string>("加载中…");
// 运维工具折叠状态
const [opsOpen, setOpsOpen] = useState(false);
// 迁移 Modal 状态
const [migrationModal, setMigrationModal] = useState<"none" | "confirm" | "running" | "done">("none");
const [migrationResult, setMigrationResult] = useState<MigrateKbResult | null>(null);
const [migrationErr, setMigrationErr] = useState("");
useEffect(() => {
adminDatasources()
.then((r) => {
setCounts(r.counts);
setRows(r.datasources);
setErr("");
})
.catch((e) => setErr((e as Error).message))
.finally(() => setLoading(false));
// 获取激活的 embedding 模型
listModels("embedding")
.then((models) => {
const active = models.find((m) => m.active);
if (active) {
setActiveEmbedding(`${active.model} (${active.provider})`);
} else {
setActiveEmbedding("未配置/未激活 (将回退代码默认)");
}
})
.catch(() => setActiveEmbedding("获取失败"));
}, []);
const startMigration = async () => {
setMigrationModal("running");
setMigrationErr("");
try {
const res = await migrateKbStorage();
setMigrationResult(res);
setMigrationModal("done");
} catch (e) {
setMigrationErr((e as Error).message);
setMigrationModal("confirm"); // 退回到确认态展示错误
}
};
const totalWords = rows.reduce((a, r) => a + r.total_words, 0);
return (
<div className="flex flex-col gap-6">
{/* 顶部激活模型只读展示 & 引导 */}
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm flex items-center justify-between">
<div>
<h4 className="text-sm font-semibold text-gray-700"> (Embedding)</h4>
<div className="mt-1 flex items-center gap-2">
<span className="h-2 w-2 rounded-full bg-emerald-500 animate-pulse"></span>
<code className="text-xs font-mono font-medium text-gray-800">{activeEmbedding}</code>
</div>
</div>
<Link
to="/admin/models"
className="flex items-center gap-1 text-xs font-medium text-violet-600 hover:text-violet-700 hover:underline"
>
</Link>
</div>
{/* 运维工具 (RAG 迁移) */}
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
<button
onClick={() => setOpsOpen(!opsOpen)}
className="flex w-full items-center justify-between font-semibold text-gray-700 focus:outline-none"
>
<div className="flex items-center gap-2">
<svg className="h-4 w-4 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span className="text-sm font-semibold text-gray-700"></span>
</div>
<span className="text-xs text-gray-400">{opsOpen ? "收起 ▲" : "展开 ▼"}</span>
</button>
{opsOpen && (
<div className="mt-4 border-t border-gray-100 pt-4 space-y-4">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-3 text-xs bg-amber-50/50 border border-amber-100 p-4 rounded-lg">
<div className="space-y-1">
<div className="font-semibold text-amber-800 flex items-center gap-1.5">
<span></span>
<span className="bg-amber-100 text-amber-700 px-1.5 py-0.5 rounded text-[9px] font-bold"></span>
</div>
<p className="text-gray-500 leading-relaxed max-w-xl">
<code className="bg-gray-100 px-1 rounded">owner/kb_id/doc</code> <code className="bg-gray-100 px-1 rounded">space_id/kb_id/doc</code>
</p>
</div>
<button
onClick={() => setMigrationModal("confirm")}
className="self-start md:self-center shrink-0 bg-amber-600 hover:bg-amber-700 text-white text-xs font-semibold py-2 px-4 rounded-lg shadow-sm transition"
>
</button>
</div>
</div>
)}
</div>
{/* 迁移 Modal */}
{migrationModal !== "none" && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="relative mx-4 w-full max-w-md overflow-hidden rounded-2xl bg-white shadow-2xl">
<div className="flex items-center justify-between border-b border-gray-100 px-6 py-5">
<div>
<h3 className="text-base font-semibold text-gray-900">
{migrationModal === "confirm" && "确认开始存储迁移?"}
{migrationModal === "running" && "正在迁移中…"}
{migrationModal === "done" && "迁移完成 🎉"}
</h3>
<p className="mt-0.5 text-xs text-gray-400"> KB space_id</p>
</div>
{migrationModal !== "running" && (
<button
onClick={() => setMigrationModal("none")}
className="text-xl leading-none text-gray-400 hover:text-gray-600"
>
×
</button>
)}
</div>
<div className="space-y-4 px-6 py-5 text-sm">
{migrationModal === "confirm" && (
<>
<p className="text-gray-600 leading-relaxed">
Space
</p>
<div className="rounded-lg border border-amber-100 bg-amber-50 p-3 text-xs text-amber-700 space-y-1">
<div className="font-semibold"> </div>
<div>· </div>
<div>· <code className="font-mono bg-amber-100/50 px-1">sundynix_doc</code> </div>
<div>· </div>
</div>
{migrationErr && (
<div className="rounded-lg border border-rose-100 bg-rose-50 px-3 py-2 text-xs text-rose-600">
{migrationErr}
</div>
)}
</>
)}
{migrationModal === "running" && (
<div className="flex flex-col items-center justify-center py-6 space-y-3">
<svg className="animate-spin h-8 w-8 text-amber-600" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<span className="text-xs text-gray-500 font-medium animate-pulse"></span>
</div>
)}
{migrationModal === "done" && migrationResult && (
<div className="space-y-3">
<div className="rounded-lg border border-emerald-100 bg-emerald-50 px-3 py-2 text-xs text-emerald-700">
RRF
</div>
<div className="divide-y divide-gray-100 border rounded-xl overflow-hidden bg-gray-50/50">
<div className="flex justify-between p-3 text-xs">
<span className="text-gray-500"></span>
<span className="font-semibold text-gray-800 font-mono">{migrationResult.total}</span>
</div>
<div className="flex justify-between p-3 text-xs">
<span className="text-gray-500"></span>
<span className="font-semibold text-amber-600 font-mono">{migrationResult.enqueued}</span>
</div>
<div className="flex justify-between p-3 text-xs">
<span className="text-gray-500"></span>
<span className="font-semibold text-gray-600 font-mono">{migrationResult.skipped}</span>
</div>
</div>
</div>
)}
</div>
<div className="flex items-center justify-end gap-2 border-t border-gray-100 px-6 py-4">
{migrationModal === "confirm" && (
<>
<button
onClick={() => setMigrationModal("none")}
className="rounded-lg border border-gray-200 px-4 py-2 text-xs text-gray-500 hover:bg-gray-50"
>
</button>
<button
onClick={() => void startMigration()}
className="rounded-lg bg-amber-600 hover:bg-amber-700 px-4 py-2 text-xs font-semibold text-white shadow-sm transition"
>
</button>
</>
)}
{migrationModal === "done" && (
<button
onClick={() => setMigrationModal("none")}
className="rounded-lg bg-violet-600 hover:bg-violet-700 px-4 py-2 text-xs font-semibold text-white shadow-sm transition"
>
</button>
)}
</div>
</div>
</div>
)}
{/* 检索管线说明(诚实:三路 + RRF 等权融合,非可调权重) */}
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
<h3 className="mb-3 text-sm font-semibold text-gray-700">线</h3>
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
<Route color="violet" name="向量检索" impl="Milvus" desc="Embedding 相似度召回语义相关块" />
<Route color="cyan" name="全文检索" impl="Bleve" desc="倒排索引召回关键词精确命中" />
<Route color="emerald" name="图谱检索" impl="Neo4j" desc="实体三元组召回结构化关联" />
</div>
<p className="mt-3 text-[11px] leading-relaxed text-gray-400">
<span className="font-medium text-gray-600">RRF </span>Reciprocal Rank Fusion k=60 rerank
<code className="rounded bg-gray-100 px-1">SearchByMode</code> mcp-go
</p>
</div>
{/* 检索试验台:紧跟管线说明 —— 讲完原理就能当场验证,不用只看文字 */}
<RetrievalBench kbs={rows} />
{/* 平台数据源计数 */}
<div className="grid grid-cols-3 gap-4">
<Stat label="知识库" value={String(counts.kbs)} tone="violet" />
<Stat label="文档总数" value={String(counts.docs)} tone="cyan" sub={`${fmtWords(totalWords)}`} />
<Stat label="平台用户" value={String(counts.users)} tone="emerald" />
</div>
{/* 知识库清单(真数据) */}
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
<h3 className="mb-3 text-sm font-semibold text-gray-700"></h3>
{loading ? (
<div className="py-8 text-center text-xs text-gray-400"></div>
) : err ? (
<div className="py-8 text-center text-xs text-rose-500">{err}</div>
) : rows.length === 0 ? (
<div className="py-8 text-center text-xs text-gray-400"></div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-100 text-left text-[11px] uppercase tracking-wide text-gray-400">
<th className="py-2 pr-3 font-medium"></th>
<th className="py-2 pr-3 font-medium"></th>
<th className="py-2 pr-3 font-medium"></th>
<th className="py-2 pr-3 text-right font-medium"></th>
<th className="py-2 text-right font-medium"></th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.id} className="border-b border-gray-50 last:border-0">
<td className="py-2 pr-3 font-medium text-gray-800">{r.name}</td>
<td className="py-2 pr-3"><span className="rounded bg-gray-100 px-1.5 py-0.5 text-[10px] text-gray-600">{KIND_LABEL[r.kind] ?? r.kind}</span></td>
<td className="py-2 pr-3 text-xs text-gray-500">{r.tenant_name || r.tenant_id || "—"}</td>
<td className="py-2 pr-3 text-right tabular-nums text-gray-700">{r.doc_count}</td>
<td className="py-2 text-right tabular-nums text-gray-500">{fmtWords(r.total_words)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}
const ROUTE_TONE: Record<string, { dot: string; text: string }> = {
violet: { dot: "bg-violet-500", text: "text-violet-600" },
cyan: { dot: "bg-cyan-500", text: "text-cyan-600" },
emerald: { dot: "bg-emerald-500", text: "text-emerald-600" },
};
function Route({ color, name, impl, desc }: { color: string; name: string; impl: string; desc: string }) {
const t = ROUTE_TONE[color];
return (
<div className="rounded-lg border border-gray-100 bg-gray-50/50 p-3">
<div className="flex items-center gap-2">
<span className={`h-2 w-2 rounded-full ${t.dot}`} />
<span className="text-sm font-medium text-gray-700">{name}</span>
<span className={`text-[10px] ${t.text}`}>{impl}</span>
</div>
<p className="mt-1 text-[11px] leading-relaxed text-gray-500">{desc}</p>
</div>
);
}
const STAT_TONE: Record<string, string> = { violet: "text-violet-600", cyan: "text-cyan-600", emerald: "text-emerald-600" };
function Stat({ label, value, sub, tone }: { label: string; value: string; sub?: string; tone: string }) {
return (
<div className="rounded-xl border border-gray-100 bg-white p-4 shadow-sm">
<div className="text-xs text-gray-400">{label}</div>
<div className={`mt-1 text-2xl font-semibold tabular-nums ${STAT_TONE[tone] ?? "text-gray-800"}`}>{value}</div>
{sub && <div className="mt-1 text-[11px] text-gray-400">{sub}</div>}
</div>
);
}