From 63e5b70e3d0c280255027b286bb0998ff7205878 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 4 Oct 2026 12:43:04 +0800 Subject: [PATCH] =?UTF-8?q?feat(gateway,admin-web):=20=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E7=9B=AE=E5=BD=95=E8=87=AA=E5=8A=A8=E5=8C=96=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20GET=20/admin/llm/catalog=EF=BC=88=E8=A3=81=E5=86=B3?= =?UTF-8?q?=2003.4.2=20=E6=AC=A1=E4=BB=B6=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 GET /api/v1/admin/llm/catalog(owner|admin,?refresh=1 强制刷新):抓 models.dev api.json 并裁剪 (226 供应商 / 8392 模型 → 1.31 MB),写落盘快照 data/llm_catalog.json(无凭据,TTL 24h,gitignore) - 降级诚实:快照优先 → 在线刷新 → stale 快照兜底(标 stale+warning)→ 都不行回 503 catalog_unavailable, 绝不编造;抓取只发生在该端点内,**不进运行时热路径** - 前端:新增 llm/LlmCatalogPanel(在线目录 + 粘贴导入落同一份候选 → 一键带出 baseURL/model id/multimodal/ maxTokens);Llm.tsx 挂载面板、删掉硬编码的「主→备→本地」假状态、表单按 id#seq 重挂 - 诚实性:面板把「加载中 / 端点未实现 / 抓取失败或离线 / 响应结构不认识 / 目录为空」分成五种独立状态, 全程无 mock/sample 回退 - 复核:新增 9 条零外网用例全绿;**变异**(Stale:true→false / TS 只认 camelCase)各自红后已还原; Go 真产出与 TS 解析器双向实测 8392 模型能力位零差异 - 越界说明:routes.go 仅 +3 行(注册该路由);llm/LlmCatalogPanel.tsx 为拆出以守住 Llm.tsx ≤500 行 --- frontend/web/src/admin/modules/Llm.tsx | 123 +++-- .../src/admin/modules/llm/LlmCatalogPanel.tsx | 439 ++++++++++++++++++ .../services/gateway/admin_llm_catalog.go | 195 ++++++++ .../gateway/admin_llm_catalog_cache.go | 72 +++ .../gateway/admin_llm_catalog_source.go | 142 ++++++ .../gateway/admin_llm_catalog_test.go | 383 +++++++++++++++ internal/services/gateway/routes.go | 3 + 7 files changed, 1320 insertions(+), 37 deletions(-) create mode 100644 frontend/web/src/admin/modules/llm/LlmCatalogPanel.tsx create mode 100644 internal/services/gateway/admin_llm_catalog.go create mode 100644 internal/services/gateway/admin_llm_catalog_cache.go create mode 100644 internal/services/gateway/admin_llm_catalog_source.go create mode 100644 internal/services/gateway/admin_llm_catalog_test.go diff --git a/frontend/web/src/admin/modules/Llm.tsx b/frontend/web/src/admin/modules/Llm.tsx index 5a2a6be..c9d9956 100644 --- a/frontend/web/src/admin/modules/Llm.tsx +++ b/frontend/web/src/admin/modules/Llm.tsx @@ -6,6 +6,7 @@ import { useAuditStore } from "../audit"; import { Notice, PageHeader } from "../components"; import { Button, EmptyState, Surface, Tag } from "../../components/primitives"; import { Icon } from "../../components/Icon"; +import { CatalogPanel } from "./llm/LlmCatalogPanel"; import { LlmProviderForm } from "./llm/LlmProviderForm"; /** 一个供应商分组行:身份 + 当前 token 预算 + 测试/编辑动作。 */ @@ -86,7 +87,7 @@ function ProviderList({ } title="还没有配置 LLM 供应商" - hint="用下方表单新增第一个供应商 / 模型分组。" + hint="用下方目录候选或表单新增第一个供应商 / 模型分组。" /> ); } @@ -100,6 +101,69 @@ function ProviderList({ ); } +/** 一次真实模型探测(POST /admin/llm/test):结果与错误都写进页面顶部的提示区, + * 成功/失败各记一条前端动作日志。它是真实往返,绝不假装成功。 */ +function runLlmTest( + p: AdminLlmProvider, + log: (what: string, result: "ok" | "error") => void, + setBusy: (v: boolean) => void, + setFail: (v: string | null) => void, + setResult: (v: string | null) => void +): void { + setBusy(true); + setFail(null); + setResult(null); + adminTestLlm({ provider: p.provider || "openai", baseUrl: p.baseUrl, model: p.model }) + .then((r) => { + log("llm.test", "ok"); + setResult(`【${p.name}】模型 ${r.model} 响应:${r.reply}(${r.tokens} tokens)`); + }) + .catch((e: unknown) => { + log("llm.test", "error"); + setFail(e instanceof Error ? e.message : "模型测试失败"); + }) + .finally(() => setBusy(false)); +} + +/** 页面标题 + 测试结果 / 失败提示。 */ +function LlmPageStatus({ fail, result }: { fail: string | null; result: string | null }): React.ReactNode { + return ( + <> + + {fail ? {fail} : null} + {result ? {result} : null} + + ); +} + +/** 生效链:只画接口真的返回过的分组与它们的备选模型,不臆造本地第三级 + * 或 per-persona 路由的运行态(那两个由后端配置决定,本端点不报告)。 */ +function FallbackChain({ providers }: { providers: AdminLlmProvider[] }): React.ReactNode { + return ( + +

生效链(来自接口返回的真实分组)

+ {providers.length === 0 ? ( +

暂无分组:上面保存一个之后,这里会显示它的主/备链。

+ ) : ( + providers.map((p) => ( +
+ {p.enabled ? "生效组" : "备用组"} + {p.model} + → {p.fallback || "无备选模型"} + 接口报错自动降级 +
+ )) + )} + + 本地模型第三级与 per-persona 路由由后端开关/配置决定,本端点不报告它们的运行态,因此这里不显示,也不臆造。 + +
+ ); +} + export function LlmModule(): React.ReactNode { const { data, error, loading, reload } = useAsyncData(adminGetLlm, []); const log = useAuditStore((s) => s.log); @@ -109,36 +173,21 @@ export function LlmModule(): React.ReactNode { const providers = data?.providers ?? []; const [editing, setEditing] = useState(null); + // 同一分组 id 连续填入不同候选时也必须重挂表单(表单按 key 取初值), + // 否则第二次填入不会生效。 + const [draftSeq, setDraftSeq] = useState(0); const [busy, setBusy] = useState(false); const [fail, setFail] = useState(null); const [result, setResult] = useState(null); - const test = (p: AdminLlmProvider) => { - setBusy(true); - setFail(null); - setResult(null); - const providerId = p.provider || "openai"; - adminTestLlm({ provider: providerId, baseUrl: p.baseUrl, model: p.model }) - .then((r) => { - log("llm.test", "ok"); - setResult(`【${p.name}】模型 ${r.model} 响应:${r.reply}(${r.tokens} tokens)`); - }) - .catch((e: unknown) => { - log("llm.test", "error"); - setFail(e instanceof Error ? e.message : "模型测试失败"); - }) - .finally(() => setBusy(false)); + const edit = (p: AdminLlmProvider | null) => { + setEditing(p); + setDraftSeq((n) => n + 1); }; return (
- - - {fail ? {fail} : null} - {result ? {result} : null} +

供应商 / 模型

@@ -148,13 +197,20 @@ export function LlmModule(): React.ReactNode { error={error} unimplemented={unimplemented} busy={busy} - onTest={test} - onEdit={setEditing} + onTest={(p) => runLlmTest(p, log, setBusy, setFail, setResult)} + onEdit={(p) => edit(p)} />
+ { + log("llm.catalog.fill", "ok"); + edit(draft); + }} + /> + setEditing(null)} /> - -

按角色路由 + 降级链

- {["owner", "admin", "viewer"].map((role) => ( -
- {role} - 主 → 备 → 本地 - 接口报错自动降级 -
- ))} -
+ - per-role 路由 + 降级逻辑由后端 loop 实现;面板保存即写入 models.json 并热更新运行中的 LLM provider(无需重启,design/04 §3.1)。.env 为兜底。每组 maxTokens 留空 = 保持原值 / 未设时回落 LLM_FALLBACK_MAX_TOKENS。 + + per-role 路由 + 降级逻辑由后端 loop 实现;面板保存即写入 models.json 并热更新运行中的 LLM provider(无需重启,design/04 §3.1)。.env 为兜底。每组 maxTokens 留空 = 保持原值 / 未设时回落 LLM_FALLBACK_MAX_TOKENS。目录候选来自 models.dev,由后端缓存成快照(离线仍可用),绝不进模型运行时热路径。 +
); } diff --git a/frontend/web/src/admin/modules/llm/LlmCatalogPanel.tsx b/frontend/web/src/admin/modules/llm/LlmCatalogPanel.tsx new file mode 100644 index 0000000..99e8e02 --- /dev/null +++ b/frontend/web/src/admin/modules/llm/LlmCatalogPanel.tsx @@ -0,0 +1,439 @@ +import { useState } from "react"; +import { useAsyncData } from "../../../lib/useAsyncData"; +import { isNotImplemented } from "../../../lib/api"; +// 目录端点是本页新引入的调用;按仓库惯例它本该落在 lib/api/admin.ts 里, +// 但本批次只允许改 Llm.tsx(+ 本文件),因此经 lib/api 的传输层直接调用 +// (信封解包 / 401 处理与其余端点一致)。 +import { request } from "../../../lib/api/core"; +import type { AdminLlmProvider } from "../../../lib/types"; +import { Notice } from "../../components"; +import { Button, Surface, Tag } from "../../../components/primitives"; + +/** 目录候选一次最多渲染多少行(其余用一行文字如实说明还有多少条)。 */ +const CATALOG_MAX_ROWS = 40; + +/** 目录里的一个候选模型:只保留面板用得上的能力位。 */ +interface CatalogModel { + id: string; + name: string; + /** 接受图片/文件输入 → 表单的 multimodal。 */ + attachment: boolean; + reasoning: boolean; + toolCall: boolean; + contextLimit: number; + outputLimit: number; +} + +/** 目录里的一个供应商:baseURL + 模型清单 + 申请 key 的提示。 */ +interface CatalogProvider { + id: string; + name: string; + api: string; + doc: string; + env: string[]; + npm: string; + models: CatalogModel[]; +} + +/** 后端裁剪形状与 models.dev 原始形状字段名不同(toolCall vs tool_call、 + * 嵌套 limit 等),但两条来源(在线目录 / 手动粘贴)必须落到同一份候选上, + * 因此下面的解析同时接受两种拼写,缺失字段一律取保守默认值。 */ +function asRecord(v: unknown): Record | null { + return typeof v === "object" && v !== null && !Array.isArray(v) ? (v as Record) : null; +} + +function str(v: unknown): string { + return typeof v === "string" ? v : ""; +} + +function num(v: unknown): number { + return typeof v === "number" && Number.isFinite(v) ? v : 0; +} + +function strList(v: unknown): string[] { + return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : []; +} + +/** 解析一个模型条目;modelId 来自外层键名,仅在条目自报 id 缺失时兜底。 */ +function parseCatalogModel(raw: unknown, modelId: string): CatalogModel | null { + const obj = asRecord(raw); + if (!obj) return null; + const id = str(obj.id) || modelId; + if (!id) return null; + const limit = asRecord(obj.limit); + return { + id, + name: str(obj.name) || id, + attachment: obj.attachment === true, + reasoning: obj.reasoning === true, + toolCall: obj.toolCall === true || obj.tool_call === true, + contextLimit: num(obj.contextLimit) || (limit ? num(limit.context) : 0), + outputLimit: num(obj.outputLimit) || (limit ? num(limit.output) : 0), + }; +} + +/** 解析模型清单:后端给数组,models.dev 给「id → 模型」映射。 */ +function parseCatalogModels(raw: unknown): CatalogModel[] { + const out: CatalogModel[] = []; + const push = (v: unknown, key: string): void => { + const parsed = parseCatalogModel(v, key); + if (parsed) out.push(parsed); + }; + if (Array.isArray(raw)) { + raw.forEach((m) => push(m, "")); + } else { + const map = asRecord(raw); + if (map) Object.keys(map).forEach((key) => push(map[key], key)); + } + return out.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); +} + +/** 解析一个供应商条目;providerId 是外层键名兜底。没有模型清单的供应商 + * 给不出候选,直接丢弃(面板不显示点了没用的行)。 */ +function parseCatalogProvider(raw: unknown, providerId: string): CatalogProvider | null { + const obj = asRecord(raw); + if (!obj) return null; + const id = str(obj.id) || providerId; + const models = parseCatalogModels(obj.models); + if (!id || models.length === 0) return null; + return { + id, + name: str(obj.name) || id, + api: str(obj.api), + doc: str(obj.doc), + env: strList(obj.env), + npm: str(obj.npm), + models, + }; +} + +/** 把任意一层结构解析成供应商候选:接受 `{providers:[…]}`(后端形状)、 + * `[…]`(数组)与 `{providerId:{…}}`(models.dev 的 api.json 形状)。 + * 结构完全不认识时抛错,由调用方按「诚实错误」渲染,而不是当空目录。 */ +function parseCatalogProviders(raw: unknown, source: string): CatalogProvider[] { + const out: CatalogProvider[] = []; + const push = (v: unknown, key: string): void => { + const parsed = parseCatalogProvider(v, key); + if (parsed) out.push(parsed); + }; + if (Array.isArray(raw)) { + raw.forEach((item) => push(item, "")); + return out; + } + const obj = asRecord(raw); + if (!obj) throw new Error(`${source}:顶层既不是对象也不是数组`); + if (Array.isArray(obj.providers)) { + obj.providers.forEach((item) => push(item, "")); + return out; + } + Object.keys(obj).forEach((key) => push(obj[key], key)); + return out; +} + +/** 离线路径:解析粘贴进来的 models.dev api.json(或单个供应商片段)。 */ +function parsePastedCatalog(text: string): CatalogProvider[] { + const trimmed = text.trim(); + if (!trimmed) throw new Error("粘贴内容为空"); + let raw: unknown; + try { + raw = JSON.parse(trimmed) as unknown; + } catch (e: unknown) { + throw new Error(`粘贴内容不是合法 JSON:${e instanceof Error ? e.message : "解析失败"}`); + } + return parseCatalogProviders(raw, "粘贴内容"); +} + +/** 合并在线目录与粘贴候选:同一供应商以主人粘贴的那份为准。 */ +function mergeProviders(fetched: CatalogProvider[], pasted: CatalogProvider[]): CatalogProvider[] { + const byId = new Map(); + fetched.forEach((p) => byId.set(p.id, p)); + pasted.forEach((p) => byId.set(p.id, p)); + return [...byId.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); +} + +/** 从目录的 SDK 包名/供应商 id 推断 wire 协议:只认能明确判定的, + * 其余按 OpenAI 兼容(面板仍可手改成 anthropic)。 */ +function protocolFromCatalog(p: CatalogProvider): string { + return /anthropic/i.test(p.npm) || /anthropic/i.test(p.id) ? "anthropic" : "openai"; +} + +/** 目录候选 → 表单草稿。分组 id 取目录 id,重复填入即幂等更新同名分组; + * maxTokens 建议值取模型的输出上限(目录真值,没有任何猜测成分)。 */ +function draftFromCatalog(p: CatalogProvider, m: CatalogModel): AdminLlmProvider { + return { + id: p.id, + name: p.name, + provider: protocolFromCatalog(p), + baseUrl: p.api, + model: m.id, + multimodal: m.attachment, + enabled: false, + maxTokens: m.outputLimit || undefined, + }; +} + +/** 目录里的一行候选:能力位如实来自目录,点「填入表单」变成表单草稿。 */ +function CatalogModelRow({ + provider, + model, + onPick, +}: { + provider: CatalogProvider; + model: CatalogModel; + onPick: () => void; +}): React.ReactNode { + return ( +
+ {model.name} + {model.id} + {model.attachment ? 多模态 : null} + {model.reasoning ? 推理 : null} + {model.toolCall ? 工具 : null} + {model.outputLimit ? {`输出上限 ${model.outputLimit}`} : null} + {provider.api ? null : 目录未标 baseURL} + + + +
+ ); +} + +/** 候选列表(按供应商 + 关键词过滤)。渲染条数有上限,超出部分用文字说明, + * 不做任何截断式伪数据。 */ +function CatalogList({ + providers, + query, + providerId, + onPick, +}: { + providers: CatalogProvider[]; + query: string; + providerId: string; + onPick: (draft: AdminLlmProvider) => void; +}): React.ReactNode { + const q = query.trim().toLowerCase(); + const rows: { provider: CatalogProvider; model: CatalogModel }[] = []; + providers.forEach((provider) => { + if (providerId && provider.id !== providerId) return; + provider.models.forEach((model) => { + if (q && !`${provider.id} ${provider.name} ${model.id} ${model.name}`.toLowerCase().includes(q)) return; + rows.push({ provider, model }); + }); + }); + if (rows.length === 0) { + return

没有匹配的模型:换个关键词,或清空供应商过滤。

; + } + const shown = rows.slice(0, CATALOG_MAX_ROWS); + return ( +
+ {shown.map(({ provider, model }) => ( + onPick(draftFromCatalog(provider, model))} + /> + ))} + {rows.length > shown.length ? ( +

+ 匹配 {rows.length} 个模型,已显示前 {shown.length} 个;用关键词缩小范围。 +

+ ) : null} +
+ ); +} + +/** 离线路径:粘贴导入。后端目录拿不到(断网/未接线)时候选照样能落地, + * 解析失败只影响这一栏,绝不碰表单里已填的内容。 */ +function PasteImport({ onParsed }: { onParsed: (providers: CatalogProvider[]) => void }): React.ReactNode { + const [text, setText] = useState(""); + const [err, setErr] = useState(null); + const submit = () => { + try { + const parsed = parsePastedCatalog(text); + if (parsed.length === 0) { + setErr("粘贴内容里没有可用供应商(需要 id + 至少一个模型)"); + return; + } + setErr(null); + onParsed(parsed); + } catch (e: unknown) { + setErr(e instanceof Error ? e.message : "粘贴内容解析失败"); + } + }; + return ( +
+ + 离线路径:粘贴 models.dev 的 api.json(或单个供应商片段) + +