feat(gateway,admin-web): 面板可编辑 provider 推理形状 / 角色级路由 / multimodal 真语义(裁决 03.4.2/03.4.1)
- 后端: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:
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";
|
||||
|
||||
Reference in new issue
Block a user