feat(gateway,admin-web): 面板可编辑 provider 推理形状 / 角色级路由 / multimodal 真语义(裁决 03.4.2/03.4.1)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 后端:AdminLlmProvider 暴露 reasoning{kind}(恒下发,""=未声明=auto)、personaModels、personaProviders;
  顶层新增 reasoningKinds / personaProviderOptions 两个**取值域**(唯一来源= runtimeconfig 导出枚举,
  校验错误文案也由枚举拼接 ⇒ 不可能漂移)
- 读写共用同一映射(admin_llm_provider.go),避免「读侧少一个键 ⇒ 面板一保存就抹掉」
- 写侧语义:缺失=保留、给出=整块/整表替换;非法值一律 runtimeconfig.Group.Validate() ⇒ 400 bad_request
  (实测非法 kind 返回 400 且 models.json 完全不动,而非 500 静默失败)
- 前端:新增 LlmProviderRouting(推理形状下拉 + multimodal 开关 + 按角色路由编辑器);
  目录能力位改三态(true/false/未标注),「未标注」不再谎称「不支持」;目录/角色清单不可用时
  **不阻塞编辑**且与「字段为空」是不同状态;绝无 mock/sample 回退
- 复核:build/vet 绿 + gateway/config 包测试绿 + tsc 0 错 + vite build 成功;2 条变异反证(删 reasoning
  补丁 → 2 例红;删 Group.Validate → 该 400 用例红)已还原
- 注意(主会话已知):npm run build 把新 SPA 写进 frontend/web/dist,而运行中的二进制优先从磁盘读该目录
  ⇒ :10942 目前是「新面板 + 旧后端」,等阶段 5 全绿后按红线「先 build 到位、再一次 restart」切换
This commit is contained in:
AskaEth committed 2026-10-04 13:06:54 +08:00
1 parent 8890c30bb5
commit 1ac02bd77e
12 files changed
+998 -85

No files matched your search

+54 -18
View File
@@ -1,6 +1,6 @@
import { useState } from "react";
import { useAsyncData } from "../../lib/useAsyncData";
import { adminGetLlm, adminTestLlm, isNotImplemented } from "../../lib/api";
import { adminGetLlm, adminListPersonas, adminTestLlm, isNotImplemented } from "../../lib/api";
import type { AdminLlmProvider } from "../../lib/types";
import { useAuditStore } from "../audit";
import { Notice, PageHeader } from "../components";
@@ -8,8 +8,9 @@ import { Button, EmptyState, Surface, Tag } from "../../components/primitives";
import { Icon } from "../../components/Icon";
import { CatalogPanel } from "./llm/LlmCatalogPanel";
import { LlmProviderForm } from "./llm/LlmProviderForm";
import type { CatalogHint } from "./llm/LlmProviderRouting";
/** 一个供应商分组行:身份 + 当前 token 预算 + 测试/编辑动作。 */
/** 一个供应商分组行:身份 + token 预算 + 推理形状 + 路由 + 测试/编辑动作。 */
function ProviderRow({
p,
busy,
@@ -21,6 +22,7 @@ function ProviderRow({
onTest: (p: AdminLlmProvider) => void;
onEdit: (p: AdminLlmProvider) => void;
}): React.ReactNode {
const routes = Object.keys(p.personaModels ?? {}).length;
return (
<div className="flex flex-wrap items-center gap-3 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2.5">
<span className="font-medium">{p.name}</span>
@@ -29,9 +31,13 @@ function ProviderRow({
<Tag tone={p.maxTokens ? "accent" : "neutral"}>
{p.maxTokens ? `maxTokens ${p.maxTokens}` : "maxTokens 默认"}
</Tag>
<Tag tone={p.reasoning?.kind ? "accent" : "neutral"}>
{`推理 ${p.reasoning?.kind || "未声明"}`}
</Tag>
{routes > 0 ? <Tag tone="accent">{`按角色路由 ${routes}`}</Tag> : null}
<span className="ml-auto flex items-center gap-4">
<Tag tone={p.enabled ? "ok" : "neutral"}>{p.enabled ? "启用" : "停用"}</Tag>
<Tag tone={p.multimodal ? "accent" : "neutral"}>{p.multimodal ? "多模态" : "文本"}</Tag>
<Tag tone={p.multimodal ? "accent" : "neutral"}>{p.multimodal ? "主模型可收图" : "主模型不收图"}</Tag>
<Button size="sm" variant="ghost" disabled={busy} onClick={() => onEdit(p)}>
编辑
</Button>
@@ -131,7 +137,7 @@ function LlmPageStatus({ fail, result }: { fail: string | null; result: string |
<>
<PageHeader
title="LLM 模型"
subtitle="供应商 + 模型配置(base/key/多模态检测/token 预算) + 测试(真实调用) + fallback + 按角色路由 + 用量"
subtitle="供应商 + 模型配置(base/key/主模型收图/token 预算/推理开关形状/按角色路由) + 测试(真实调用) + fallback"
/>
{fail ? <Notice tone="err">{fail}</Notice> : null}
{result ? <Notice tone="ok">{result}</Notice> : null}
@@ -139,26 +145,46 @@ function LlmPageStatus({ fail, result }: { fail: string | null; result: string |
);
}
/** 生效链:只画接口真的返回过的分组与它们的备选模型,不臆造本地第三级
* 或 per-persona 路由的运行态(那两个由后端配置决定,本端点不报告)。 */
/** 一个分组已配置的 per-persona 路由(接口返回的配置真值)。两张表取并集,
* 使「只声明了协议没声明模型」的死配置也照实显示,而不是看不见。 */
function PersonaRouteList({ p }: { p: AdminLlmProvider }): React.ReactNode {
const providers = p.personaProviders ?? {};
const models = p.personaModels ?? {};
const personas = [...new Set([...Object.keys(models), ...Object.keys(providers)])].sort();
if (personas.length === 0) return null;
return (
<p className="pl-1 text-xs text-[var(--mu-text-lo)]">
按角色路由:
{personas
.map((id) => `${id} → ${models[id] || "(未配模型,不生效)"}${providers[id] ? `(协议 ${providers[id]})` : ""}`)
.join(",")}
</p>
);
}
/** 生效链:只画接口真的返回过的分组、它们的备选模型与已配置的 per-persona 路由;
* 本地第三级由后端开关决定、本端点不报告其运行态,因此不臆造。 */
function FallbackChain({ providers }: { providers: AdminLlmProvider[] }): React.ReactNode {
return (
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">生效链(来自接口返回的真实分组)</h3>
<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 key={p.id} className="space-y-1">
<div 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>
<PersonaRouteList p={p} />
</div>
))
)}
<Notice>
本地模型第三级与 per-persona 路由由后端开关/配置决定,本端点不报告它们的运行态,因此这里不显示,也不臆造。
本地模型第三级由后端开关与本地组配置决定,本端点不报告它的运行态,因此这里不显示,也不臆造。
</Notice>
</Surface>
);
@@ -166,6 +192,8 @@ function FallbackChain({ providers }: { providers: AdminLlmProvider[] }): React.
export function LlmModule(): React.ReactNode {
const { data, error, loading, reload } = useAsyncData(adminGetLlm, []);
// 角色清单只作候选提示:取不到也不阻塞编辑(persona id 可手填)。
const personas = useAsyncData(adminListPersonas, []);
const log = useAuditStore((s) => s.log);
// 端点未实现 / 请求失败必须分开:前者是 404/501,后者是断连或 5xx。
const unimplemented = isNotImplemented(error);
@@ -179,9 +207,12 @@ export function LlmModule(): React.ReactNode {
const [busy, setBusy] = useState(false);
const [fail, setFail] = useState<string | null>(null);
const [result, setResult] = useState<string | null>(null);
// 目录候选带进来的能力建议(可推翻);没有建议时是 null。
const [hint, setHint] = useState<CatalogHint | null>(null);
const edit = (p: AdminLlmProvider | null) => {
const edit = (p: AdminLlmProvider | null, next: CatalogHint | null) => {
setEditing(p);
setHint(next);
setDraftSeq((n) => n + 1);
};
@@ -198,14 +229,14 @@ export function LlmModule(): React.ReactNode {
unimplemented={unimplemented}
busy={busy}
onTest={(p) => runLlmTest(p, log, setBusy, setFail, setResult)}
onEdit={(p) => edit(p)}
onEdit={(p) => edit(p, null)}
/>
</Surface>
<CatalogPanel
onPick={(draft) => {
onPick={(draft, picked) => {
log("llm.catalog.fill", "ok");
edit(draft);
edit(draft, picked);
}}
/>
@@ -218,12 +249,17 @@ export function LlmModule(): React.ReactNode {
onError={setFail}
onSaved={reload}
onCancel={() => setEditing(null)}
hint={hint}
reasoningKinds={data?.reasoningKinds ?? []}
personaProviderOptions={data?.personaProviderOptions ?? []}
personaIds={(personas.data ?? []).map((p) => p.id)}
personaListError={personas.error}
/>
<FallbackChain providers={providers} />
<Notice>
per-role 路由 + 降级逻辑由后端 loop 实现;面板保存即写入 models.json 并热更新运行中的 LLM provider(无需重启,design/04 §3.1)。.env 为兜底。每组 maxTokens 留空 = 保持原值 / 未设时回落 LLM_FALLBACK_MAX_TOKENS。目录候选来自 models.dev,由后端缓存成快照(离线仍可用),绝不进模型运行时热路径。
每组的 reasoning.kind(推理开关形状)按端点选:形状属于端点而不属于模型,选错会 400,因此面板只给出「未声明 / auto」与后端下发的几种形状。按角色路由(persona → 模型 / 协议)与降级链由后端 loop 真接线;面板保存即写入 models.json 并热更新运行中的 LLM provider(无需重启,design/04 §3.1),.env 为兜底。maxTokens 留空 = 保持原值 / 未设时回落 LLM_FALLBACK_MAX_TOKENS。multimodal 只表示主模型自己能否收图;图片/OCR 工具用的模型走 .env 的 LLM_VISION_MODEL / LLM_OCR_MODEL。目录候选来自 models.dev,由后端缓存成快照(离线仍可用),绝不进模型运行时热路径。
</Notice>
</div>
);