import { useState } from "react"; import { adminAddLlmProvider } from "../../../lib/api"; import type { AdminLlmProvider, LlmProviderRequest } from "../../../lib/types"; import { useAuditStore } from "../../audit"; import { Button, Surface } from "../../../components/primitives"; import { ApiKeyAndBudgetField, ContextWindowField, 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 * backend merges the request over the stored group, so blank optional fields * (apiKey / maxTokens / contextWindow) are simply omitted and keep their * stored value. */ interface FormState { name: string; provider: string; model: string; baseUrl: string; apiKey: string; multimodal: boolean; maxTokens: string; /** 模型上下文窗口(token);空串 = 未声明(不提交该键,保留已存窗口)。 */ contextWindow: string; /** 推理开关形状;空串 = 未声明(等同 auto)。 */ reasoning: string; /** per-persona 路由行(persona → 模型 / 协议覆盖)。 */ personaRoutes: PersonaRouteDraft[]; } type FieldSetter = (key: K, value: FormState[K]) => void; function initState(initial: AdminLlmProvider | null): FormState { return { name: initial?.id ?? "", provider: initial?.provider || "openai", model: initial?.model ?? "", baseUrl: initial?.baseUrl ?? "", apiKey: "", multimodal: initial?.multimodal ?? false, maxTokens: initial?.maxTokens ? String(initial.maxTokens) : "", contextWindow: initial?.contextWindow ? String(initial.contextWindow) : "", reasoning: initial?.reasoning?.kind ?? "", personaRoutes: initPersonaRoutes(initial), }; } /** 把表单状态变成一个 provider 保存请求 —— 模块级纯函数,因此「保存」这条路径的 * 语义不依赖组件内部状态。提交前的本地校验(两个数字字段的非负整数、按角色路由 * 的行)都在这里发生:不通过就通过 onError 报红字并返回 null(一个请求都不发)。 */ function buildRequest( form: FormState, editing: boolean, initial: AdminLlmProvider | null, onError: (msg: string | null) => void, ): LlmProviderRequest | null { const budget = form.maxTokens.trim(); if (budget !== "" && !/^\d+$/.test(budget)) { onError("maxTokens 必须为非负整数(留空 = 保持/默认)"); return null; } const window = form.contextWindow.trim(); if (window !== "" && !/^\d+$/.test(window)) { onError("contextWindow 必须为非负整数(留空 = 未声明,按 128 Ki token 保守估算)"); return null; } 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); // 空串 = 不提交该键(缺省 = 保留已存窗口);显式 `0` 与省略同义(都表示 // 未声明),因此面板只在此处如实转发用户真的填了的那个数。 if (window !== "") req.contextWindow = Number(window); return req; } /** The form fields; state lives in the form component below. */ 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 ( <>
set("apiKey", v)} onMaxTokens={(v) => set("maxTokens", v)} /> set("contextWindow", v)} /> set("multimodal", v)} /> set("reasoning", v)} /> set("personaRoutes", next)} /> ); } interface LlmProviderFormProps { initial: AdminLlmProvider | null; busy: boolean; setBusy: (v: boolean) => void; onResult: (msg: string) => void; 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. */ export function LlmProviderForm({ initial, busy, setBusy, onResult, onError, onSaved, onCancel, hint, reasoningKinds, personaProviderOptions, personaIds, personaListError, }: LlmProviderFormProps): React.ReactNode { const editing = initial !== null; const [form, setForm] = useState(() => initState(initial)); const log = useAuditStore((s) => s.log); const set: FieldSetter = (key, value) => setForm((prev) => ({ ...prev, [key]: value })); const save = () => { const req = buildRequest(form, editing, initial, onError); if (!req) return; setBusy(true); onError(null); adminAddLlmProvider(req) .then((r) => { log("llm.addProvider", "ok"); const budgetNote = r.provider.maxTokens ? ` · maxTokens ${r.provider.maxTokens}` : ""; const windowNote = r.provider.contextWindow ? ` · contextWindow ${r.provider.contextWindow}` : ""; onResult(`已保存到 models.json 并热更新:${r.provider.name} · ${r.provider.model}${budgetNote}${windowNote}${r.path ? `(${r.path})` : ""}`); onSaved(); if (editing) onCancel(); else setForm(initState(null)); }) .catch((e: unknown) => { log("llm.addProvider", "error"); onError(e instanceof Error ? e.message : "保存模型失败"); }) .finally(() => setBusy(false)); }; return (

{editing ? `编辑模型组 · ${initial?.id}` : "新增模型(保存到 models.json,即刻热更新)"}

{editing ? ( ) : null}
{editing ? "只提交本表单填写的字段,未填字段保持原值(按角色路由与推理形状按当前表单内容整块提交)" : "保存后写入 models.json 并热更新运行中的 LLM provider"}
); } 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";