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)。
This commit is contained in:
AskaEth committed 2026-10-04 18:11:43 +08:00
1 parent 3a01b4e236
commit 47e56ca13c
11 files changed
+355 -68

No files matched your search

@@ -4,6 +4,8 @@ import type { AdminLlmProvider, LlmProviderRequest } from "../../../lib/types";
import { useAuditStore } from "../../audit";
import { Button, Surface } from "../../../components/primitives";
import {
ApiKeyAndBudgetField,
ContextWindowField,
MultimodalField,
PersonaRoutesEditor,
ReasoningField,
@@ -16,7 +18,8 @@ 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) are simply omitted and keep their stored value. */
* (apiKey / maxTokens / contextWindow) are simply omitted and keep their
* stored value. */
interface FormState {
name: string;
@@ -26,6 +29,8 @@ interface FormState {
apiKey: string;
multimodal: boolean;
maxTokens: string;
/** 模型上下文窗口(token);空串 = 未声明(不提交该键,保留已存窗口)。 */
contextWindow: string;
/** 推理开关形状;空串 = 未声明(等同 auto)。 */
reasoning: string;
/** per-persona 路由行(persona → 模型 / 协议覆盖)。 */
@@ -43,11 +48,59 @@ function initState(initial: AdminLlmProvider | null): FormState {
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,
@@ -99,24 +152,14 @@ function ProviderFields({
<input value={form.baseUrl} onChange={(e) => set("baseUrl", e.target.value)} placeholder="https://api.example.com/v1" className={inputCls} />
</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)]">
API key({editing ? "留空 = 保留原 key" : "写入 models.json,不回显"})
</span>
<input value={form.apiKey} onChange={(e) => set("apiKey", e.target.value)} placeholder="sk-…" className={inputCls} />
</label>
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">maxTokens(该组预算,留空 = 保持/默认)</span>
<input
value={form.maxTokens}
onChange={(e) => set("maxTokens", e.target.value)}
inputMode="numeric"
placeholder="8192"
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}
@@ -176,39 +219,8 @@ export function LlmProviderForm({
const log = useAuditStore((s) => s.log);
const set: FieldSetter = (key, value) => setForm((prev) => ({ ...prev, [key]: value }));
const buildRequest = (): LlmProviderRequest | null => {
const budget = form.maxTokens.trim();
if (budget !== "" && !/^\d+$/.test(budget)) {
onError("maxTokens 必须为非负整数(留空 = 保持/默认)");
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);
return req;
};
const save = () => {
const req = buildRequest();
const req = buildRequest(form, editing, initial, onError);
if (!req) return;
setBusy(true);
onError(null);
@@ -216,7 +228,8 @@ export function LlmProviderForm({
.then((r) => {
log("llm.addProvider", "ok");
const budgetNote = r.provider.maxTokens ? ` · maxTokens ${r.provider.maxTokens}` : "";
onResult(`已保存到 models.json 并热更新:${r.provider.name} · ${r.provider.model}${budgetNote}${r.path ? `(${r.path})` : ""}`);
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));