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

@@ -2,7 +2,16 @@ import { useState } from "react";
import { adminAddLlmProvider } from "../../../lib/api";
import type { AdminLlmProvider, LlmProviderRequest } from "../../../lib/types";
import { useAuditStore } from "../../audit";
import { Button, Surface, Switch } from "../../../components/primitives";
import { Button, Surface } from "../../../components/primitives";
import {
MultimodalField,
PersonaRoutesEditor,
ReasoningField,
initPersonaRoutes,
personaRouteProblem,
routesToMaps,
} from "./LlmProviderRouting";
import type { CatalogHint, PersonaRouteDraft } from "./LlmProviderRouting";
/** Add/edit form for one LLM provider group. The group list hands in an
* `initial` group to edit (remounted via key) or null to add a new one. The
@@ -17,6 +26,10 @@ interface FormState {
apiKey: string;
multimodal: boolean;
maxTokens: string;
/** 推理开关形状;空串 = 未声明(等同 auto)。 */
reasoning: string;
/** per-persona 路由行(persona → 模型 / 协议覆盖)。 */
personaRoutes: PersonaRouteDraft[];
}
type FieldSetter = <K extends keyof FormState>(key: K, value: FormState[K]) => void;
@@ -30,11 +43,31 @@ function initState(initial: AdminLlmProvider | null): FormState {
apiKey: "",
multimodal: initial?.multimodal ?? false,
maxTokens: initial?.maxTokens ? String(initial.maxTokens) : "",
reasoning: initial?.reasoning?.kind ?? "",
personaRoutes: initPersonaRoutes(initial),
};
}
/** The form fields; state lives in the form component below. */
function ProviderFields({ form, set, editing }: { form: FormState; set: FieldSetter; editing: boolean }): React.ReactNode {
function ProviderFields({
form,
set,
editing,
hint,
reasoningKinds,
personaProviderOptions,
personaIds,
personaListError,
}: {
form: FormState;
set: FieldSetter;
editing: boolean;
hint: CatalogHint | null;
reasoningKinds: string[];
personaProviderOptions: string[];
personaIds: string[];
personaListError: string | null;
}): React.ReactNode {
return (
<>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
@@ -84,9 +117,21 @@ function ProviderFields({ form, set, editing }: { form: FormState; set: FieldSet
/>
</label>
</div>
<div className="flex items-end gap-2 pb-[2px]">
<Switch checked={form.multimodal} onChange={(v) => set("multimodal", v)} label="多模态" />
</div>
<MultimodalField checked={form.multimodal} hint={hint} onChange={(v) => set("multimodal", v)} />
<ReasoningField
value={form.reasoning}
kinds={reasoningKinds}
provider={form.provider}
onChange={(v) => set("reasoning", v)}
/>
<PersonaRoutesEditor
routes={form.personaRoutes}
knownIds={personaIds}
personaListError={personaListError}
providerOptions={personaProviderOptions}
groupProvider={form.provider}
onChange={(next) => set("personaRoutes", next)}
/>
</>
);
}
@@ -99,6 +144,16 @@ interface LlmProviderFormProps {
onError: (msg: string | null) => void;
onSaved: () => void;
onCancel: () => void;
/** 目录候选带进来的能力建议(可推翻);null = 没有建议。 */
hint: CatalogHint | null;
/** reasoning.kind 的可选取值,由后端下发(缺省 = 后端未报告)。 */
reasoningKinds: string[];
/** personaProviders 的可选取值,由后端下发(缺省 = 后端未报告)。 */
personaProviderOptions: string[];
/** 已知 persona id,仅作为候选提示(可手填)。 */
personaIds: string[];
/** 角色清单取不到时的原因;null = 清单可用。 */
personaListError: string | null;
}
/** Owns the field state and the save round-trip; the module supplies callbacks. */
@@ -110,25 +165,53 @@ export function LlmProviderForm({
onError,
onSaved,
onCancel,
hint,
reasoningKinds,
personaProviderOptions,
personaIds,
personaListError,
}: LlmProviderFormProps): React.ReactNode {
const editing = initial !== null;
const [form, setForm] = useState<FormState>(() => initState(initial));
const log = useAuditStore((s) => s.log);
const set: FieldSetter = (key, value) => setForm((prev) => ({ ...prev, [key]: value }));
const save = () => {
const buildRequest = (): LlmProviderRequest | null => {
const budget = form.maxTokens.trim();
if (budget !== "" && !/^\d+$/.test(budget)) {
onError("maxTokens 必须为非负整数(留空 = 保持/默认)");
return;
return null;
}
setBusy(true);
onError(null);
const req: LlmProviderRequest = { provider: form.provider, baseUrl: form.baseUrl, model: form.model, multimodal: form.multimodal };
const problem = personaRouteProblem(form.personaRoutes);
if (problem) {
onError(problem);
return null;
}
const { personaModels, personaProviders } = routesToMaps(form.personaRoutes);
const req: LlmProviderRequest = {
provider: form.provider,
baseUrl: form.baseUrl,
model: form.model,
multimodal: form.multimodal,
// reasoning 整块给出:kind 空串 = 显式回到「未声明」,否则面板无法把
// 已存形状改回去(缺省字段意味着「保留原值」)。
reasoning: { kind: form.reasoning },
// 两张路由表整表提交:面板删掉一行后要真的能从存储里删掉。
personaModels,
personaProviders,
};
if (editing && initial) req.id = initial.id;
else if (form.name.trim()) req.id = form.name.trim();
if (form.apiKey.trim()) req.apiKey = form.apiKey.trim();
if (budget !== "") req.maxTokens = Number(budget);
return req;
};
const save = () => {
const req = buildRequest();
if (!req) return;
setBusy(true);
onError(null);
adminAddLlmProvider(req)
.then((r) => {
log("llm.addProvider", "ok");
@@ -155,13 +238,24 @@ export function LlmProviderForm({
</Button>
) : null}
</div>
<ProviderFields form={form} set={set} editing={editing} />
<ProviderFields
form={form}
set={set}
editing={editing}
hint={hint}
reasoningKinds={reasoningKinds}
personaProviderOptions={personaProviderOptions}
personaIds={personaIds}
personaListError={personaListError}
/>
<div className="flex items-center gap-2">
<Button variant="primary" disabled={busy || !form.model || !form.baseUrl} onClick={save}>
{editing ? "保存修改" : "保存模型"}
</Button>
<span className="text-xs text-[var(--mu-text-lo)]">
{editing ? "只提交本表单填写的字段,未填字段保持原值" : "保存后写入 models.json 并热更新运行中的 LLM provider"}
{editing
? "只提交本表单填写的字段,未填字段保持原值(按角色路由与推理形状按当前表单内容整块提交)"
: "保存后写入 models.json 并热更新运行中的 LLM provider"}
</span>
</div>
</Surface>
@@ -170,3 +264,4 @@ export function LlmProviderForm({
const inputCls =
"w-full rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)] disabled:opacity-60";