Files
Cyrene_v2/frontend/web/src/admin/modules/llm/LlmProviderForm.tsx
T
AskaEth 47e56ca13c feat(admin): 面板暴露 contextWindow(可见可改)+ DocumentRef 语义注释同步
- 面板读写面与 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)。
2026-10-04 18:11:43 +08:00

281 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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";