feat(gateway,admin-web): 模型目录自动化 —— GET /admin/llm/catalog(裁决 03.4.2 次件)
- 新增 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 行
This commit is contained in:
1 parent
b8fa53e43c
commit
63e5b70e3d
7 files changed
+1320
-37
No files matched your search
@@ -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({
|
||||
<EmptyState
|
||||
icon={<Icon name="robot" size={32} />}
|
||||
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 (
|
||||
<>
|
||||
<PageHeader
|
||||
title="LLM 模型"
|
||||
subtitle="供应商 + 模型配置(base/key/多模态检测/token 预算) + 测试(真实调用) + fallback + 按角色路由 + 用量"
|
||||
/>
|
||||
{fail ? <Notice tone="err">{fail}</Notice> : null}
|
||||
{result ? <Notice tone="ok">{result}</Notice> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** 生效链:只画接口真的返回过的分组与它们的备选模型,不臆造本地第三级
|
||||
* 或 per-persona 路由的运行态(那两个由后端配置决定,本端点不报告)。 */
|
||||
function FallbackChain({ providers }: { providers: AdminLlmProvider[] }): React.ReactNode {
|
||||
return (
|
||||
<Surface className="space-y-3">
|
||||
<h3 className="text-sm font-semibold">生效链(来自接口返回的真实分组)</h3>
|
||||
{providers.length === 0 ? (
|
||||
<p className="text-xs text-[var(--mu-text-lo)]">暂无分组:上面保存一个之后,这里会显示它的主/备链。</p>
|
||||
) : (
|
||||
providers.map((p) => (
|
||||
<div key={p.id} className="flex flex-wrap items-center gap-3 text-sm">
|
||||
<Tag tone={p.enabled ? "ok" : "neutral"}>{p.enabled ? "生效组" : "备用组"}</Tag>
|
||||
<span className="font-mono text-xs text-[var(--mu-text-lo)]">{p.model}</span>
|
||||
<span className="text-[var(--mu-text-mid)]">→ {p.fallback || "无备选模型"}</span>
|
||||
<span className="ml-auto text-xs text-[var(--mu-text-lo)]">接口报错自动降级</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<Notice>
|
||||
本地模型第三级与 per-persona 路由由后端开关/配置决定,本端点不报告它们的运行态,因此这里不显示,也不臆造。
|
||||
</Notice>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
|
||||
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<AdminLlmProvider | null>(null);
|
||||
// 同一分组 id 连续填入不同候选时也必须重挂表单(表单按 key 取初值),
|
||||
// 否则第二次填入不会生效。
|
||||
const [draftSeq, setDraftSeq] = useState(0);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [fail, setFail] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<string | null>(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 (
|
||||
<div className="space-y-5">
|
||||
<PageHeader
|
||||
title="LLM 模型"
|
||||
subtitle="供应商 + 模型配置(base/key/多模态检测/token 预算) + 测试(真实调用) + fallback + 按角色路由 + 用量"
|
||||
/>
|
||||
|
||||
{fail ? <Notice tone="err">{fail}</Notice> : null}
|
||||
{result ? <Notice tone="ok">{result}</Notice> : null}
|
||||
<LlmPageStatus fail={fail} result={result} />
|
||||
|
||||
<Surface className="space-y-3">
|
||||
<h3 className="text-sm font-semibold">供应商 / 模型</h3>
|
||||
@@ -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)}
|
||||
/>
|
||||
</Surface>
|
||||
|
||||
<CatalogPanel
|
||||
onPick={(draft) => {
|
||||
log("llm.catalog.fill", "ok");
|
||||
edit(draft);
|
||||
}}
|
||||
/>
|
||||
|
||||
<LlmProviderForm
|
||||
key={editing?.id ?? "new"}
|
||||
key={editing ? `${editing.id}#${draftSeq}` : `new#${draftSeq}`}
|
||||
initial={editing}
|
||||
busy={busy}
|
||||
setBusy={setBusy}
|
||||
@@ -164,18 +220,11 @@ export function LlmModule(): React.ReactNode {
|
||||
onCancel={() => setEditing(null)}
|
||||
/>
|
||||
|
||||
<Surface className="space-y-3">
|
||||
<h3 className="text-sm font-semibold">按角色路由 + 降级链</h3>
|
||||
{["owner", "admin", "viewer"].map((role) => (
|
||||
<div key={role} className="flex items-center gap-3 text-sm">
|
||||
<Tag tone="neutral">{role}</Tag>
|
||||
<span className="text-[var(--mu-text-mid)]">主 → 备 → 本地</span>
|
||||
<span className="ml-auto text-xs text-[var(--mu-text-lo)]">接口报错自动降级</span>
|
||||
</div>
|
||||
))}
|
||||
</Surface>
|
||||
<FallbackChain providers={providers} />
|
||||
|
||||
<Notice>per-role 路由 + 降级逻辑由后端 loop 实现;面板保存即写入 models.json 并热更新运行中的 LLM provider(无需重启,design/04 §3.1)。.env 为兜底。每组 maxTokens 留空 = 保持原值 / 未设时回落 LLM_FALLBACK_MAX_TOKENS。</Notice>
|
||||
<Notice>
|
||||
per-role 路由 + 降级逻辑由后端 loop 实现;面板保存即写入 models.json 并热更新运行中的 LLM provider(无需重启,design/04 §3.1)。.env 为兜底。每组 maxTokens 留空 = 保持原值 / 未设时回落 LLM_FALLBACK_MAX_TOKENS。目录候选来自 models.dev,由后端缓存成快照(离线仍可用),绝不进模型运行时热路径。
|
||||
</Notice>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user