- 面板读写面与 maxTokens 逐处对齐:读 DTO 增 contextWindow(omitempty)、写请求 ContextWindow *int(缺失=保留、显式 0=未声明同义、负数 400)、回写 models.json;负数红线仍由 runtimeconfig.Group.Validate 唯一裁决,网关守卫只做「更早失败 + 面板可读字段名」。前端:类型 + 表单字段(提交前 /^\d+$/ 校验)+ 分组行角标 + Notice + 目录候选用早已解析却没人用的 contextLimit 预填。文案写明「重启后生效」(推导只在进程启动时算一次)。 - 顺手修既有红线违反:ProviderFields 87→76(抽 ApiKeyAndBudgetField)、LlmProviderForm 113→74(buildRequest 提为模块级纯函数)——本次碰过的文件里已无 >80 的函数,行为/文案逐字未变。 - DocumentRef 的 chars/truncated 注释从「抽取侧」改为「本回合注入侧」(全文长度只在 document-read 的 totalChars、extractTruncated 专指撞 8 MiB 安全上限);机械校验为空 ⇒ 代码零改动。 - 主会话独立验收:隔离 worktree Go 侧 build/vet + gateway/history 两包绿;主树 npx tsc --noEmit exit 0、npm run build 成功并 copy 到 internal/webui/dist;注释机械校验为空;变异由子代理复现(写路径不认该键 ⇒ 3 红;去掉网关负数守卫 ⇒ 400 变 500)。
281 lines
10 KiB
TypeScript
281 lines
10 KiB
TypeScript
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 = <K extends keyof FormState>(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 (
|
||
<>
|
||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||
<label className="block space-y-1">
|
||
<span className="text-xs text-[var(--mu-text-lo)]">名称 / ID(留空自动生成)</span>
|
||
<input
|
||
value={form.name}
|
||
onChange={(e) => set("name", e.target.value)}
|
||
placeholder="deepseek"
|
||
disabled={editing}
|
||
className={inputCls}
|
||
/>
|
||
</label>
|
||
<label className="block space-y-1">
|
||
<span className="text-xs text-[var(--mu-text-lo)]">协议 Provider</span>
|
||
<select value={form.provider} onChange={(e) => set("provider", e.target.value)} className={inputCls}>
|
||
<option value="openai">openai(OpenAI 兼容)</option>
|
||
<option value="anthropic">anthropic(Anthropic Messages)</option>
|
||
</select>
|
||
</label>
|
||
</div>
|
||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||
<label className="block space-y-1">
|
||
<span className="text-xs text-[var(--mu-text-lo)]">模型</span>
|
||
<input value={form.model} onChange={(e) => set("model", e.target.value)} className={inputCls} />
|
||
</label>
|
||
<label className="block space-y-1">
|
||
<span className="text-xs text-[var(--mu-text-lo)]">baseURL</span>
|
||
<input value={form.baseUrl} onChange={(e) => set("baseUrl", e.target.value)} placeholder="https://api.example.com/v1" className={inputCls} />
|
||
</label>
|
||
</div>
|
||
<ApiKeyAndBudgetField
|
||
apiKey={form.apiKey}
|
||
maxTokens={form.maxTokens}
|
||
editing={editing}
|
||
onApiKey={(v) => set("apiKey", v)}
|
||
onMaxTokens={(v) => set("maxTokens", v)}
|
||
/>
|
||
<ContextWindowField value={form.contextWindow} onChange={(v) => set("contextWindow", v)} />
|
||
<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)}
|
||
/>
|
||
</>
|
||
);
|
||
}
|
||
|
||
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<FormState>(() => 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 (
|
||
<Surface className="space-y-3">
|
||
<div className="flex items-center gap-2">
|
||
<h3 className="text-sm font-semibold">{editing ? `编辑模型组 · ${initial?.id}` : "新增模型(保存到 models.json,即刻热更新)"}</h3>
|
||
{editing ? (
|
||
<Button size="sm" variant="ghost" className="ml-auto" onClick={onCancel}>
|
||
取消编辑
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
<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"}
|
||
</span>
|
||
</div>
|
||
</Surface>
|
||
);
|
||
}
|
||
|
||
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";
|
||
|