From 79b110afd0aa5aa7384634bd8ef679644390aaec Mon Sep 17 00:00:00 2001 From: Blizzard Date: Mon, 20 Jul 2026 11:17:54 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E6=95=B0=E6=8D=AE=E6=BA=90?= =?UTF-8?q?=E9=A1=B5=E8=BD=AC=20RAG=20=E8=BF=90=E7=BB=B4=E5=8F=B0=20+=20?= =?UTF-8?q?=E6=94=AF=E4=BB=98/=E6=A8=A1=E5=9E=8B=E8=8F=9C=E5=8D=95?= =?UTF-8?q?=E9=87=8D=E7=BB=84=20+=20=E6=A6=82=E8=A7=88=E5=8D=87=E7=BA=A7?= =?UTF-8?q?=E4=B8=BA=E4=BB=AA=E8=A1=A8=E7=9B=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端: - 新增 POST /admin/kb/search:管理端跨租户检索,支持 mode 指定单路 (vector/fulltext/graph/hybrid),不走 scopedKB(否则会被强制锁到调用者 自己的 space,跨租户排障就没法做了) - KB 清单补 space_id(检索键是 /,缺它前端拼不出 key) 前端: - 数据源&RAG 页补「检索试验台」:同一 query 并排跑生产链路 + 四路诊断, 召回不准时能直接定位是向量/分词/图谱哪一环挂了 - 支付拆成「配置 / 订单与对账」两个子页,挂到运维 > 支付 下; 导航支持二级菜单(NavParent 命中子路由自动展开) - SettingsPage → ModelConfigPage「模型配置」,模型参数与计费规则合一 - 概览 → 仪表盘:并入计费与用量(UsagePage → UsageSection), 去掉系统健康拓扑(与服务状态页重复,同一份 /admin/status 数据) - 全局隐藏滚动条(保留滚动) Co-Authored-By: Claude Opus 4.8 --- sundynix-admin/src/api.ts | 26 +++ .../src/components/RetrievalBench.tsx | 178 ++++++++++++++++++ .../UsageSection.tsx} | 12 +- sundynix-admin/src/index.css | 12 ++ sundynix-admin/src/pages/DashboardPage.tsx | 171 +++++++---------- sundynix-admin/src/pages/DatasourcesPage.tsx | 8 +- sundynix-admin/src/pages/ModelConfigPage.tsx | 20 ++ .../src/pages/PaymentConfigPage.tsx | 15 ++ .../src/pages/PaymentOrdersPage.tsx | 15 ++ sundynix-admin/src/pages/SettingsPage.tsx | 64 ------- sundynix-admin/src/routes.test.tsx | 35 +++- sundynix-admin/src/routes.tsx | 94 +++++---- sundynix-admin/src/shell/AppShell.tsx | 110 +++++++++-- sundynix-gateway/internal/handler/kb.go | 42 +++++ sundynix-gateway/internal/router/router.go | 1 + .../internal/store/datasource_query.go | 11 +- 16 files changed, 577 insertions(+), 237 deletions(-) create mode 100644 sundynix-admin/src/components/RetrievalBench.tsx rename sundynix-admin/src/{pages/UsagePage.tsx => components/UsageSection.tsx} (95%) create mode 100644 sundynix-admin/src/pages/ModelConfigPage.tsx create mode 100644 sundynix-admin/src/pages/PaymentConfigPage.tsx create mode 100644 sundynix-admin/src/pages/PaymentOrdersPage.tsx delete mode 100644 sundynix-admin/src/pages/SettingsPage.tsx diff --git a/sundynix-admin/src/api.ts b/sundynix-admin/src/api.ts index 6701de5..4f9b5a8 100644 --- a/sundynix-admin/src/api.ts +++ b/sundynix-admin/src/api.ts @@ -434,6 +434,8 @@ export async function guardrailEvents(limit = 100): Promise { export interface DatasourceKB { id: string; name: string; + /** 检索作用域键前半段:三库里的库名是 "/",检索试验台按此定位。 */ + space_id: string; kind: string; tenant_id: string; tenant_name: string; @@ -442,6 +444,30 @@ export interface DatasourceKB { total_words: number; } +/** 检索命中:text=命中片段,score=该路的相似度/融合分(不同 mode 分数体系不同,勿跨路比较)。 */ +export interface KbHit { + text: string; + score: number; +} + +/** 检索模式:单路用于逐路定位是哪一路没召回;hybrid=纯 RRF 融合(不 rerank);""=生产链路(混合+rerank)。 */ +export type SearchMode = "" | "vector" | "fulltext" | "graph" | "hybrid"; + +// adminKbSearch 检索试验台:按完整作用域键跨租户检索任意知识库。 +// kb 传 `${space_id}/${name}`(来自 adminDatasources)。 +export async function adminKbSearch(kb: string, q: string, topK = 5, mode: SearchMode = ""): Promise { + const res = guard( + await fetch(`${ADMIN}/kb/search`, { + method: "POST", + headers: authHeaders(true), + body: JSON.stringify({ kb, q, topK, mode }), + }), + ); + const d = (await res.json().catch(() => ({}))) as { hits?: KbHit[]; error?: string }; + if (!res.ok) throw new Error(d.error ?? `search failed: ${res.status}`); + return d.hits ?? []; +} + export async function adminDatasources(): Promise<{ counts: { users: number; kbs: number; docs: number }; datasources: DatasourceKB[] }> { const res = guard(await fetch(`${ADMIN}/datasources`, { headers: authHeaders() })); const d = (await res.json().catch(() => ({}))) as { counts?: { users: number; kbs: number; docs: number }; datasources?: DatasourceKB[]; error?: string }; diff --git a/sundynix-admin/src/components/RetrievalBench.tsx b/sundynix-admin/src/components/RetrievalBench.tsx new file mode 100644 index 0000000..2bf3649 --- /dev/null +++ b/sundynix-admin/src/components/RetrievalBench.tsx @@ -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>; + +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({}); + 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 ( +
+
+

检索试验台

+ 同一 query 跑通所有检索路,定位召回问题出在哪一环 +
+ + {/* 查询条件 */} +
+ + + + + + + +
+ + {withDocs.length === 0 && ( +

还没有含文档的知识库,先去桌面端建库并入库再来试。

+ )} + + {ran && ( +
+ {/* 生产链路:用户实际拿到的结果 */} + + + {/* 诊断四路 */} +
+
+ 分路诊断(分数体系不同,勿跨路比大小) +
+
+ {ROUTES.map((r) => ( + + ))} +
+
+
+ )} +
+ ); +} + +function RouteCard({ + label, + hint, + data, + highlight, +}: { + label: string; + hint: string; + data?: { hits: KbHit[]; err?: string }; + highlight?: boolean; +}) { + const hits = data?.hits ?? []; + return ( +
+
+ {label} + + {hits.length} 命中 + +
+
{hint}
+ + {data?.err ? ( +

{data.err}

+ ) : hits.length === 0 ? ( +

这一路没召回

+ ) : ( +
    + {hits.map((h, i) => ( +
  1. +
    + #{i + 1} + {h.score.toFixed(4)} +
    +

    + {h.text} +

    +
  2. + ))} +
+ )} +
+ ); +} diff --git a/sundynix-admin/src/pages/UsagePage.tsx b/sundynix-admin/src/components/UsageSection.tsx similarity index 95% rename from sundynix-admin/src/pages/UsagePage.tsx rename to sundynix-admin/src/components/UsageSection.tsx index c899caf..fbc30eb 100644 --- a/sundynix-admin/src/pages/UsagePage.tsx +++ b/sundynix-admin/src/components/UsageSection.tsx @@ -1,10 +1,10 @@ import { useEffect, useMemo, useState, type ReactNode } from "react"; import { adminUsage, listTenants, type TenantRow, type UsageReport, type UsageTenantSum, type UsageDay } from "../api"; -import { GrantCreditsModal } from "../components/GrantCreditsModal"; +import { GrantCreditsModal } from "./GrantCreditsModal"; -// 管理端「用量 & 计费」= 计费闭环一页:顶部配「规则」(单价/积分权重/汇率),下方看「结果」(用量观测)。 -// 规则→扣费→观测:改规则即对后续任务生效,用量观测(读 /admin/usage,系统级跨租户)即其结果。 +// 仪表盘的「计费与用量」区块:全平台(或单租户)的积分消耗/Token/成本 + 趋势 + 租户排行。 +// 自包含取数与筛选,直接塞进仪表盘即可,不需要父级传参。 // 金额/积分均为微单位(×10⁻⁶),展示时 ÷1e6。 const MICRO = 1_000_000; @@ -37,7 +37,7 @@ function fillDays(trend: UsageDay[], days: number): UsageDay[] { } const mmdd = (ymdStr: string) => `${ymdStr.slice(4, 6)}-${ymdStr.slice(6, 8)}`; -export function UsagePage() { +export function UsageSection() { const [days, setDays] = useState(30); const [tenant, setTenant] = useState(""); // "" = 全平台 const [report, setReport] = useState(null); @@ -106,11 +106,9 @@ export function UsagePage() { {/* 发放积分 Modal */} setGrantTenant(null)} onDone={() => void load()} /> - {/* 本页只做「用量」观测。模型/计价、支付渠道/订单流水均按域收口到「系统配置」页。 */} - {/* 观测端:用量结果 */}
-

用量观测

+

计费与用量

按规则折算后的实际消耗
diff --git a/sundynix-admin/src/index.css b/sundynix-admin/src/index.css index b9e5d9d..cd4bc99 100644 --- a/sundynix-admin/src/index.css +++ b/sundynix-admin/src/index.css @@ -9,6 +9,18 @@ body, margin: 0; } +/* 全局隐藏滚动条(保留滚动功能)——官网与控制台所有页面、以及页内任意滚动区 + (侧栏、表格 overflow-auto、Modal 等)统一生效。 */ +* { + scrollbar-width: none; /* Firefox */ + -ms-overflow-style: none; /* 旧 Edge/IE */ +} +*::-webkit-scrollbar { + width: 0; + height: 0; + display: none; /* Chrome / Safari */ +} + /* ══ 官网落地页设计 token(跟随 logo:青→蓝→紫)══ 仅 src/site/ 的落地页组件用这些;admin 控制台用默认 gray/violet 调色板,互不影响。 bg-ground/text-ink 等只作用在 SiteLayout 的容器上(见 site-layout.tsx),不改 body。 */ diff --git a/sundynix-admin/src/pages/DashboardPage.tsx b/sundynix-admin/src/pages/DashboardPage.tsx index b5fdcba..fe4f18e 100644 --- a/sundynix-admin/src/pages/DashboardPage.tsx +++ b/sundynix-admin/src/pages/DashboardPage.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { UsageSection } from "../components/UsageSection"; import { adminOverview, getStatus, type AdminOverview, type SystemStatus } from "../api"; // 管理端「概览」= 系统控制塔:统筹全系统的吞吐 / 配置态 / 健康,而非某个账号的个人工作台。 @@ -150,6 +151,75 @@ export function DashboardPage() { /> + {/* C. 全局任务吞吐 */} +
+
+
+
+

全平台任务吞吐

+

近 7 天调度中心处理的任务总数(所有租户/用户)

+
+ 真实数据 +
+ + + + + + + + + {areaPath && } + {dPath && } + {pts.map((p, i) => ( + + + {`${trend[i].key}: ${trend[i].count} 任务`} + + ))} + +
+ {trend.map((d) => ( + {d.key} + ))} +
+
+ +
+
+

任务终态分布

+

近 7 天全局终态占比

+
+
+ {ov.status_count.length === 0 &&
暂无任务
} + {ov.status_count + .slice() + .sort((a, b) => b.count - a.count) + .map((d) => { + const st = statusStyle(d.key); + const pct = (d.count / totalStatus) * 100; + return ( +
+
+ + + {st.label} + + {d.count} · {pct.toFixed(0)}% +
+
+
+
+
+ ); + })} +
+
+
+ + {/* 计费与用量(原独立页并入:积分消耗/Token/成本 + 趋势 + 各租户排行 + 充值) */} + + {/* B. 控制面配置态(管理端独有) */}
@@ -224,90 +294,6 @@ export function DashboardPage() {
- {/* C. 全局任务吞吐 */} -
-
-
-
-

全平台任务吞吐

-

近 7 天调度中心处理的任务总数(所有租户/用户)

-
- 真实数据 -
- - - - - - - - - {areaPath && } - {dPath && } - {pts.map((p, i) => ( - - - {`${trend[i].key}: ${trend[i].count} 任务`} - - ))} - -
- {trend.map((d) => ( - {d.key} - ))} -
-
- -
-
-

任务终态分布

-

近 7 天全局终态占比

-
-
- {ov.status_count.length === 0 &&
暂无任务
} - {ov.status_count - .slice() - .sort((a, b) => b.count - a.count) - .map((d) => { - const st = statusStyle(d.key); - const pct = (d.count / totalStatus) * 100; - return ( -
-
- - - {st.label} - - {d.count} · {pct.toFixed(0)}% -
-
-
-
-
- ); - })} -
-
-
- - {/* D. 系统健康拓扑 */} -
-
-
-

系统健康拓扑

-

基建 + 应用服务 + MCP 工具注册(NATS 实时探活)

-
- 实时 -
-
- ({ name: i.name, up: i.up, detail: i.detail }))} /> - ({ name: s.name, up: s.up, detail: s.detail }))} /> - ({ name: t.server, up: t.up, detail: `${t.tools?.length ?? 0} 个工具` }))} - /> -
-
); } @@ -321,23 +307,6 @@ function Row({ label, children }: { label: string; children: ReactNode }) { ); } -function HealthGroup({ title, items }: { title: string; items: { name: string; up: boolean; detail?: string }[] }) { - return ( -
-
{title}
- {items.map((it) => ( -
- - - {it.name} - - {it.up ? it.detail || "在线" : "离线"} -
- ))} -
- ); -} - type Tone = "violet" | "emerald" | "cyan" | "amber" | "rose"; const TONE: Record = { violet: "bg-violet-50 text-violet-600", diff --git a/sundynix-admin/src/pages/DatasourcesPage.tsx b/sundynix-admin/src/pages/DatasourcesPage.tsx index 09bec9b..4a9f09c 100644 --- a/sundynix-admin/src/pages/DatasourcesPage.tsx +++ b/sundynix-admin/src/pages/DatasourcesPage.tsx @@ -1,5 +1,6 @@ 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)。 @@ -73,10 +74,10 @@ export function DatasourcesPage() {
- 前往「系统配置 → 模型」修改 → + 前往「模型配置」修改 → @@ -240,6 +241,9 @@ export function DatasourcesPage() {

+ {/* 检索试验台:紧跟管线说明 —— 讲完原理就能当场验证,不用只看文字 */} + + {/* 平台数据源计数 */}
diff --git a/sundynix-admin/src/pages/ModelConfigPage.tsx b/sundynix-admin/src/pages/ModelConfigPage.tsx new file mode 100644 index 0000000..9a93721 --- /dev/null +++ b/sundynix-admin/src/pages/ModelConfigPage.tsx @@ -0,0 +1,20 @@ +import { ModelsPage } from "./ModelsPage"; +import { BillingRules } from "../components/BillingRules"; + +// 模型配置:模型登记/激活/连通性测试 + 它的计费规则(token→积分汇率、硬拦截、按模型定价与权重)。 +// 配了模型就顺手定它的价,两块放一起。 +// 支付相关(渠道配置 / 订单对账)已拆到「运维 → 支付」下的两个子页,不在这里。 +// 提示词有版本历史 + diff + 激活的完整工作流,保留独立页。 +export function ModelConfigPage() { + return ( +
+
+

模型配置

+

对话 / 向量模型登记与激活 · 计费规则与按模型定价

+
+ + + +
+ ); +} diff --git a/sundynix-admin/src/pages/PaymentConfigPage.tsx b/sundynix-admin/src/pages/PaymentConfigPage.tsx new file mode 100644 index 0000000..7c541b4 --- /dev/null +++ b/sundynix-admin/src/pages/PaymentConfigPage.tsx @@ -0,0 +1,15 @@ +import { TopupChannels } from "../components/TopupChannels"; + +// 支付 · 配置:充值渠道(积分包定价、兑换码生成/台账)+ 微信支付参数。 +// 与「支付 · 订单与对账」拆开:这里只管「怎么收钱」,那边看「收到了什么」。 +export function PaymentConfigPage() { + return ( +
+
+

支付 · 配置

+

积分包定价与上下架 · 兑换码生成与台账 · 微信支付商户参数

+
+ +
+ ); +} diff --git a/sundynix-admin/src/pages/PaymentOrdersPage.tsx b/sundynix-admin/src/pages/PaymentOrdersPage.tsx new file mode 100644 index 0000000..208c025 --- /dev/null +++ b/sundynix-admin/src/pages/PaymentOrdersPage.tsx @@ -0,0 +1,15 @@ +import { OrderStream } from "../components/OrderStream"; + +// 支付 · 订单与对账:全平台充值订单流 + 状态计数 + 一键对账 + 人工退款。 +// 配置面在「支付 · 配置」,这里只做流水观测与处置。 +export function PaymentOrdersPage() { + return ( +
+
+

支付 · 订单与对账

+

充值订单流水 · 账本一致性对账 · 人工退款

+
+ +
+ ); +} diff --git a/sundynix-admin/src/pages/SettingsPage.tsx b/sundynix-admin/src/pages/SettingsPage.tsx deleted file mode 100644 index cd39e68..0000000 --- a/sundynix-admin/src/pages/SettingsPage.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { useState } from "react"; -import { ModelsPage } from "./ModelsPage"; -import { BillingRules } from "../components/BillingRules"; -import { TopupChannels } from "../components/TopupChannels"; -import { OrderStream } from "../components/OrderStream"; - -// 系统配置:按「域」聚合 —— 配了什么,就在同一处看它相关的规则与数据。 -// 模型 = 模型登记/激活/连通性测试 + 计费规则(token→积分汇率、硬拦截、按模型定价与权重) -// 支付 = 支付渠道配置(兑换码 / 积分包 / 微信参数)+ 订单流水(订单流、对账、人工退款) -// 「计费 & 用量」页只留纯用量观测(消耗趋势/租户排行/发放积分),不再混配置。 -// 提示词有版本历史 + diff + 激活的完整工作流,保留独立页,不塞进 Tab。 - -type Tab = "models" | "payment"; - -const TABS: Array<{ key: Tab; label: string; desc: string }> = [ - { key: "models", label: "模型", desc: "模型登记与激活 · 计费规则与按模型定价" }, - { key: "payment", label: "支付", desc: "支付渠道配置 · 订单流水与对账退款" }, -]; - -export function SettingsPage() { - const [tab, setTab] = useState("models"); - const current = TABS.find((t) => t.key === tab); - - return ( -
-
-
-

系统配置

-

{current?.desc}

-
- -
- {TABS.map((t) => ( - - ))} -
-
- - {/* 模型域:先登记模型,紧接着配它的计价规则 */} - {tab === "models" && ( -
- - -
- )} - - {/* 支付域:先配渠道,紧接着看这些渠道产生的订单流水与对账 */} - {tab === "payment" && ( -
- - -
- )} -
- ); -} diff --git a/sundynix-admin/src/routes.test.tsx b/sundynix-admin/src/routes.test.tsx index c13cf37..3e0886b 100644 --- a/sundynix-admin/src/routes.test.tsx +++ b/sundynix-admin/src/routes.test.tsx @@ -1,11 +1,15 @@ import { describe, it, expect } from "vitest"; -import { routes, navGroups, defaultPath } from "./routes"; +import { routes, navGroups, defaultPath, type NavNode, type RouteDef } from "./routes"; + +// 把嵌套导航拍平成路由数组(顶层项 + 二级子项),顺序即渲染顺序。 +function flatten(nodes: NavNode[]): RouteDef[] { + return nodes.flatMap((n) => (n.kind === "item" ? [n.route] : n.items)); +} // routes 是控制台导航的单一事实源 —— 派生分组必须保序、不丢项、不串组。 describe("navGroups", () => { it("按注册顺序归并分组,每条路由都落到对应组", () => { - const groups = navGroups(); - const flat = groups.flatMap((g) => g.items); + const flat = navGroups().flatMap((g) => flatten(g.nodes)); // 不丢项、不重复 expect(flat).toHaveLength(routes.length); expect(new Set(flat.map((r) => r.path)).size).toBe(routes.length); @@ -17,10 +21,31 @@ describe("navGroups", () => { expect(navGroups().map((g) => g.group)).toEqual(seen); }); - it("同组路由保持注册时的相对顺序", () => { + it("同组路由保持注册时的相对顺序(含二级子项)", () => { for (const g of navGroups()) { const expected = routes.filter((r) => r.group === g.group).map((r) => r.path); - expect(g.items.map((r) => r.path)).toEqual(expected); + expect(flatten(g.nodes).map((r) => r.path)).toEqual(expected); + } + }); + + it("带 parent 的路由聚成一个二级菜单,不散落成顶层项", () => { + for (const g of navGroups()) { + for (const n of g.nodes) { + if (n.kind === "item") { + expect(n.route.parent).toBeUndefined(); // 顶层项不该带 parent + } else { + expect(n.items.length).toBeGreaterThan(0); + // 同一个二级菜单下的子项,parent 必须都等于该菜单名 + for (const r of n.items) expect(r.parent).toBe(n.label); + } + } + } + }); + + it("同 parent 的多条路由只生成一个二级菜单节点", () => { + for (const g of navGroups()) { + const labels = g.nodes.filter((n) => n.kind === "parent").map((n) => (n as Extract).label); + expect(new Set(labels).size).toBe(labels.length); } }); diff --git a/sundynix-admin/src/routes.tsx b/sundynix-admin/src/routes.tsx index 82153b6..8bcb26a 100644 --- a/sundynix-admin/src/routes.tsx +++ b/sundynix-admin/src/routes.tsx @@ -5,8 +5,7 @@ import { lazy, type ReactNode } from "react"; const DashboardPage = lazy(() => import("./pages/DashboardPage").then((m) => ({ default: m.DashboardPage }))); -const UsagePage = lazy(() => import("./pages/UsagePage").then((m) => ({ default: m.UsagePage }))); -const SettingsPage = lazy(() => import("./pages/SettingsPage").then((m) => ({ default: m.SettingsPage }))); +const ModelConfigPage = lazy(() => import("./pages/ModelConfigPage").then((m) => ({ default: m.ModelConfigPage }))); const DatasourcesPage = lazy(() => import("./pages/DatasourcesPage").then((m) => ({ default: m.DatasourcesPage }))); const StatusPage = lazy(() => import("./pages/StatusPage").then((m) => ({ default: m.StatusPage }))); const TasksPage = lazy(() => import("./pages/TasksPage").then((m) => ({ default: m.TasksPage }))); @@ -15,52 +14,33 @@ const TenantsPage = lazy(() => import("./pages/TenantsPage").then((m) => ({ defa const GuardrailsPage = lazy(() => import("./pages/GuardrailsPage").then((m) => ({ default: m.GuardrailsPage }))); const PromptsPage = lazy(() => import("./pages/PromptsPage").then((m) => ({ default: m.PromptsPage }))); const AuditPage = lazy(() => import("./pages/AuditPage").then((m) => ({ default: m.AuditPage }))); +const PaymentConfigPage = lazy(() => import("./pages/PaymentConfigPage").then((m) => ({ default: m.PaymentConfigPage }))); +const PaymentOrdersPage = lazy(() => import("./pages/PaymentOrdersPage").then((m) => ({ default: m.PaymentOrdersPage }))); const SpacesPage = lazy(() => import("./pages/SpacesPage").then((m) => ({ default: m.SpacesPage }))); export interface RouteDef { path: string; label: string; group: string; + /** 二级菜单名。同 group 内 parent 相同的若干条会聚成一个可展开子菜单(如 运维 > 支付 > 配置/订单与对账)。 */ + parent?: string; ready?: boolean; element: ReactNode; } +/** 侧栏一个节点:要么是单条路由,要么是带子项的可展开二级菜单。 */ +export type NavNode = + | { kind: "item"; route: RouteDef } + | { kind: "parent"; label: string; items: RouteDef[] }; + export const routes: RouteDef[] = [ { path: "dashboard", - label: "概览", + label: "仪表盘", group: "分析", ready: true, element: , }, - { - path: "usage", - label: "计费 & 用量", - group: "分析", - ready: true, - element: , - }, - { - path: "settings", - label: "系统配置", - group: "配置", - ready: true, - element: , - }, - { - path: "datasources", - label: "数据源 & RAG", - group: "配置", - ready: true, - element: , - }, - { - path: "prompts", - label: "提示词", - group: "配置", - ready: true, - element: , - }, { path: "status", label: "服务状态", @@ -89,6 +69,43 @@ export const routes: RouteDef[] = [ ready: true, element: , }, + { + path: "models", + label: "模型配置", + group: "运维", + ready: true, + element: , + }, + { + path: "datasources", + label: "数据源 & RAG", + group: "运维", + ready: true, + element: , + }, + { + path: "prompts", + label: "提示词", + group: "运维", + ready: true, + element: , + }, + { + path: "payment/config", + label: "配置", + group: "运维", + parent: "支付", + ready: true, + element: , + }, + { + path: "payment/orders", + label: "订单与对账", + group: "运维", + parent: "支付", + ready: true, + element: , + }, { path: "tenants", label: "租户 & 用户", @@ -115,15 +132,22 @@ export const routes: RouteDef[] = [ export const defaultPath = "dashboard"; // 派生分组导航(保持注册顺序)。 -export function navGroups(): Array<{ group: string; items: RouteDef[] }> { - const out: Array<{ group: string; items: RouteDef[] }> = []; +export function navGroups(): Array<{ group: string; nodes: NavNode[] }> { + const out: Array<{ group: string; nodes: NavNode[] }> = []; for (const r of routes) { let g = out.find((x) => x.group === r.group); if (!g) { - g = { group: r.group, items: [] }; + g = { group: r.group, nodes: [] }; out.push(g); } - g.items.push(r); + if (!r.parent) { + g.nodes.push({ kind: "item", route: r }); + continue; + } + // 同 parent 的归到同一个可展开节点(首次出现决定它在组内的位置)。 + const exist = g.nodes.find((n): n is Extract => n.kind === "parent" && n.label === r.parent); + if (exist) exist.items.push(r); + else g.nodes.push({ kind: "parent", label: r.parent, items: [r] }); } return out; } diff --git a/sundynix-admin/src/shell/AppShell.tsx b/sundynix-admin/src/shell/AppShell.tsx index 3130338..48adf40 100644 --- a/sundynix-admin/src/shell/AppShell.tsx +++ b/sundynix-admin/src/shell/AppShell.tsx @@ -1,7 +1,7 @@ import React, { Suspense, useEffect, useState } from "react"; import { NavLink, Routes, Route, Navigate, useLocation } from "react-router-dom"; -import { routes, navGroups, defaultPath } from "../routes"; +import { routes, navGroups, defaultPath, type RouteDef } from "../routes"; import { getStatus, type AuthUser } from "../api"; const ADMIN_BASE = "/admin"; @@ -18,7 +18,7 @@ const ICON_MAP: Record = { ), - settings: ( + models: ( @@ -69,6 +69,26 @@ const ICON_MAP: Record = { ), + "payment/config": ( + + + + + ), + "payment/orders": ( + + + + ), +}; + +// 二级菜单(parent)自己的图标,按 parent 名索引。 +const PARENT_ICON: Record = { + 支付: ( + + + + ), }; export function AppShell({ user, onLogout }: { user: AuthUser; onLogout: () => void }) { @@ -130,23 +150,13 @@ export function AppShell({ user, onLogout }: { user: AuthUser; onLogout: () => v {navGroups().map((g) => (
{g.group}
- {g.items.map((r) => ( - - `flex items-center gap-2.5 rounded-lg px-3 py-2 text-xs font-semibold transition-all ${ - isActive - ? "bg-violet-600 text-white shadow-sm shadow-violet-100" - : "text-gray-600 hover:bg-gray-100 hover:text-gray-900" - }` - } - > - {ICON_MAP[r.path]} - {r.label} - {!r.ready && 规划} - - ))} + {g.nodes.map((n) => + n.kind === "item" ? ( + + ) : ( + + ), + )}
))} @@ -211,3 +221,65 @@ export function AppShell({ user, onLogout }: { user: AuthUser; onLogout: () => v ); } + +// 单条导航项(顶层或二级子项共用)。 +function NavItem({ route: r }: { route: RouteDef }) { + return ( + + `flex items-center gap-2.5 rounded-lg px-3 py-2 text-xs font-semibold transition-all ${ + isActive + ? "bg-violet-600 text-white shadow-sm shadow-violet-100" + : "text-gray-600 hover:bg-gray-100 hover:text-gray-900" + }` + } + > + {ICON_MAP[r.path]} + {r.label} + {!r.ready && 规划} + + ); +} + +// 可展开的二级菜单(如 运维 > 支付 > 配置 / 订单与对账)。 +// 子项命中时自动展开;父项本身不可导航,只负责收起/展开。 +function NavParent({ label, items }: { label: string; items: RouteDef[] }) { + const loc = useLocation(); + const hasActive = items.some((r) => loc.pathname === `${ADMIN_BASE}/${r.path}`); + const [open, setOpen] = useState(hasActive); + + useEffect(() => { + if (hasActive) setOpen(true); + }, [hasActive]); + + return ( +
+ + {open && ( +
+ {items.map((r) => ( + + ))} +
+ )} +
+ ); +} diff --git a/sundynix-gateway/internal/handler/kb.go b/sundynix-gateway/internal/handler/kb.go index 31b65ef..0aadc7f 100644 --- a/sundynix-gateway/internal/handler/kb.go +++ b/sundynix-gateway/internal/handler/kb.go @@ -553,6 +553,48 @@ func (h *Handler) KbSearch(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"hits": hits}) } +// AdminKbSearch: POST /api/v1/admin/kb/search —— 管理端「检索试验台」:按**完整作用域键** +// 检索任意租户的知识库(kb 形如 "/",由 /admin/datasources 清单给出)。 +// +// 与用户侧 KbSearch 的关键差别:这里**不做 scopedKB 改写** —— 用户侧会把库名套上调用者 +// 自己的 space_id,那样 admin 只能搜到自己空间的库,试验台就废了。跨租户是本端点的目的 +// (运维排障:线上召回不准时,定位是 embedding、切块还是融合的问题)。 +// +// mode: 空=生产混合(含 rerank);vector/fulltext/graph/hybrid=单路/纯融合(不 rerank), +// 用于逐路对比看是哪一路没召回。 +func (h *Handler) AdminKbSearch(c *gin.Context) { + var body struct { + KB string `json:"kb"` // 完整作用域键 "/" + Q string `json:"q"` + TopK int `json:"topK"` + Mode string `json:"mode"` + } + if err := c.ShouldBindJSON(&body); err != nil || body.Q == "" || body.KB == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "kb 与 q 必填"}) + return + } + args := map[string]any{"kb": body.KB, "q": body.Q} + if body.TopK > 0 { + args["topK"] = body.TopK + } + if body.Mode != "" { + args["mode"] = body.Mode + } + 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}) +} + // 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"), diff --git a/sundynix-gateway/internal/router/router.go b/sundynix-gateway/internal/router/router.go index f0a72fc..74d0841 100644 --- a/sundynix-gateway/internal/router/router.go +++ b/sundynix-gateway/internal/router/router.go @@ -166,6 +166,7 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob. admin.GET("/usage", h.AdminUsage) // 用量/积分/成本:全平台按天趋势 + 租户排行 / 单租户余额 admin.GET("/evals", h.AdminEvals) // 自动评测观测:质量趋势 + 计数 + 错题本(真数据) admin.GET("/datasources", h.AdminDatasources) // 数据源清单:全平台知识库 + 文档数(真数据) + admin.POST("/kb/search", h.AdminKbSearch) // 检索试验台:按完整作用域键跨租户检索(支持单路 mode 对比) admin.POST("/migrate-kb-storage", h.MigrateKBStorage) // 增量3:存量 KB 三库 owner/kb→space/kb 重灌(一次性) admin.GET("/audit", h.AuditList) // 敏感操作审计流(倒序,翻页) admin.GET("/guardrail-events", h.GuardrailEvents) // 护栏命中安全事件流(倒序,翻页) diff --git a/sundynix-gateway/internal/store/datasource_query.go b/sundynix-gateway/internal/store/datasource_query.go index d3b24fc..93ed319 100644 --- a/sundynix-gateway/internal/store/datasource_query.go +++ b/sundynix-gateway/internal/store/datasource_query.go @@ -7,8 +7,11 @@ import "context" // DatasourceKB 是一条知识库的清单行(含文档数与总字数、租户名)。 type DatasourceKB struct { - ID string `json:"id"` - Name string `json:"name"` + ID string `json:"id"` + Name string `json:"name"` + // SpaceID 是检索作用域键的前半段:向量/全文/图谱三库里的库名是 "/" + // (见 handler.scopedKB)。admin 检索试验台要按这个完整键定位库,故必须带出来。 + SpaceID string `json:"space_id"` Kind string `json:"kind"` TenantID string `json:"tenant_id"` TenantName string `json:"tenant_name"` @@ -24,12 +27,12 @@ func (p *Postgres) AllDatasources(ctx context.Context) []DatasourceKB { } var out []DatasourceKB p.db.WithContext(WithoutTenant(ctx)).Table("sundynix_kb k"). - Select("k.id, k.name, k.kind, k.tenant_id, coalesce(t.name,'') as tenant_name, k.owner, "+ + Select("k.id, k.name, k.space_id, k.kind, k.tenant_id, coalesce(t.name,'') as tenant_name, k.owner, "+ "count(d.id) as doc_count, coalesce(sum(d.size),0) as total_words"). Joins("left join sundynix_doc d on d.kb = k.name and d.space_id = k.space_id and d.deleted_at is null"). Joins("left join sundynix_tenant t on t.id = k.tenant_id"). Where("k.deleted_at is null"). - Group("k.id, k.name, k.kind, k.tenant_id, t.name, k.owner"). + Group("k.id, k.name, k.space_id, k.kind, k.tenant_id, t.name, k.owner"). Order("doc_count desc, k.created_at desc"). Scan(&out) return out