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:
1 parent
3a01b4e236
commit
47e56ca13c
11 files changed
+355
-68
No files matched your search
@@ -10,7 +10,7 @@ import { CatalogPanel } from "./llm/LlmCatalogPanel";
|
||||
import { LlmProviderForm } from "./llm/LlmProviderForm";
|
||||
import type { CatalogHint } from "./llm/LlmProviderRouting";
|
||||
|
||||
/** 一个供应商分组行:身份 + token 预算 + 推理形状 + 路由 + 测试/编辑动作。 */
|
||||
/** 一个供应商分组行:身份 + token 预算 + 上下文窗口 + 推理形状 + 路由 + 测试/编辑动作。 */
|
||||
function ProviderRow({
|
||||
p,
|
||||
busy,
|
||||
@@ -31,6 +31,9 @@ function ProviderRow({
|
||||
<Tag tone={p.maxTokens ? "accent" : "neutral"}>
|
||||
{p.maxTokens ? `maxTokens ${p.maxTokens}` : "maxTokens 默认"}
|
||||
</Tag>
|
||||
<Tag tone={p.contextWindow ? "accent" : "neutral"}>
|
||||
{p.contextWindow ? `窗口 ${p.contextWindow}` : "窗口未声明"}
|
||||
</Tag>
|
||||
<Tag tone={p.reasoning?.kind ? "accent" : "neutral"}>
|
||||
{`推理 ${p.reasoning?.kind || "未声明"}`}
|
||||
</Tag>
|
||||
@@ -259,7 +262,7 @@ export function LlmModule(): React.ReactNode {
|
||||
<FallbackChain providers={providers} />
|
||||
|
||||
<Notice>
|
||||
每组的 reasoning.kind(推理开关形状)按端点选:形状属于端点而不属于模型,选错会 400,因此面板只给出「未声明 / auto」与后端下发的几种形状。按角色路由(persona → 模型 / 协议)与降级链由后端 loop 真接线;面板保存即写入 models.json 并热更新运行中的 LLM provider(无需重启,design/04 §3.1),.env 为兜底。maxTokens 留空 = 保持原值 / 未设时回落 LLM_FALLBACK_MAX_TOKENS。multimodal 只表示主模型自己能否收图;图片/OCR 工具用的模型走 .env 的 LLM_VISION_MODEL / LLM_OCR_MODEL。目录候选来自 models.dev,由后端缓存成快照(离线仍可用),绝不进模型运行时热路径。
|
||||
每组的 reasoning.kind(推理开关形状)按端点选:形状属于端点而不属于模型,选错会 400,因此面板只给出「未声明 / auto」与后端下发的几种形状。按角色路由(persona → 模型 / 协议)与降级链由后端 loop 真接线;面板保存即写入 models.json 并热更新运行中的 LLM provider(无需重启,design/04 §3.1),.env 为兜底。maxTokens 留空 = 保持原值 / 未设时回落 LLM_FALLBACK_MAX_TOKENS。contextWindow(模型上下文窗口,token)留空 = 未声明:后端按 128 Ki token 保守估算并记一条 WARN,填该模型的真实窗口才能吃到按窗口推导的上下文预算(该推导只在启动时算一次 ⇒ 重启后生效)。multimodal 只表示主模型自己能否收图;图片/OCR 工具用的模型走 .env 的 LLM_VISION_MODEL / LLM_OCR_MODEL。目录候选来自 models.dev,由后端缓存成快照(离线仍可用),绝不进模型运行时热路径。
|
||||
</Notice>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -158,8 +158,9 @@ function protocolFromCatalog(p: CatalogProvider): string {
|
||||
}
|
||||
|
||||
/** 目录候选 → 表单草稿 + 能力建议。分组 id 取目录 id,重复填入即幂等更新同名
|
||||
* 分组;multimodal 只在目录**明确标注**接受图片输入时预填,maxTokens 建议值取
|
||||
* 模型的输出上限(目录真值,没有任何猜测成分)——两者都只是预填,用户可推翻。 */
|
||||
* 分组;multimodal 只在目录**明确标注**接受图片输入时预填,maxTokens 与
|
||||
* contextWindow 的建议值取目录的**输出上限 / 上下文窗口**(目录真值,没有任何
|
||||
* 猜测成分)——都只是预填,用户可推翻。 */
|
||||
function draftFromCatalog(p: CatalogProvider, m: CatalogModel): { draft: AdminLlmProvider; hint: CatalogHint } {
|
||||
return {
|
||||
draft: {
|
||||
@@ -171,6 +172,7 @@ function draftFromCatalog(p: CatalogProvider, m: CatalogModel): { draft: AdminLl
|
||||
multimodal: m.attachment === true,
|
||||
enabled: false,
|
||||
maxTokens: m.outputLimit || undefined,
|
||||
contextWindow: m.contextLimit || undefined,
|
||||
},
|
||||
hint: {
|
||||
provider: p.id,
|
||||
@@ -199,6 +201,7 @@ function CatalogModelRow({
|
||||
{model.reasoning === true ? <Tag tone="neutral">推理</Tag> : null}
|
||||
{model.toolCall ? <Tag tone="neutral">工具</Tag> : null}
|
||||
{model.outputLimit ? <Tag tone="neutral">{`输出上限 ${model.outputLimit}`}</Tag> : null}
|
||||
{model.contextLimit ? <Tag tone="neutral">{`上下文窗口 ${model.contextLimit}`}</Tag> : null}
|
||||
{provider.api ? null : <Tag tone="warn">目录未标 baseURL</Tag>}
|
||||
<span className="ml-auto">
|
||||
<Button size="sm" variant="ghost" onClick={onPick}>
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -2,9 +2,10 @@ import type { AdminLlmProvider } from "../../../lib/types";
|
||||
import { Button, Switch, Tag } from "../../../components/primitives";
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* 阶段 5 新增的字段(multimodal / reasoning.kind / per-persona 路由)的编辑界面。
|
||||
* 取值域一律由后端下发(GET /admin/llm 的 reasoningKinds / personaProviderOptions),
|
||||
* 本文件只提供展示文案与本地校验,不硬编码第二套枚举,也不拿任何示例值顶替。
|
||||
* 阶段 5 新增的字段(multimodal / reasoning.kind / per-persona 路由)与上下文
|
||||
* 窗口(contextWindow)的编辑界面。取值域一律由后端下发(GET /admin/llm 的
|
||||
* reasoningKinds / personaProviderOptions),本文件只提供展示文案与本地校验,
|
||||
* 不硬编码第二套枚举,也不拿任何示例值顶替。
|
||||
* -------------------------------------------------------------------------*/
|
||||
|
||||
/** 一个 persona 路由行的编辑草稿。 */
|
||||
@@ -107,6 +108,70 @@ export function MultimodalField({
|
||||
);
|
||||
}
|
||||
|
||||
/** API key 与 maxTokens 一行(同排布的两列)。抽出来只为让 ProviderFields 守住
|
||||
* ≤80 行:字段、文案与交互与抽取前逐字一致(编辑态下钥匙的提示语照旧跟着切换)。 */
|
||||
export function ApiKeyAndBudgetField({
|
||||
apiKey,
|
||||
maxTokens,
|
||||
editing,
|
||||
onApiKey,
|
||||
onMaxTokens,
|
||||
}: {
|
||||
apiKey: string;
|
||||
maxTokens: string;
|
||||
editing: boolean;
|
||||
onApiKey: (next: string) => void;
|
||||
onMaxTokens: (next: string) => void;
|
||||
}): 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)]">
|
||||
API key({editing ? "留空 = 保留原 key" : "写入 models.json,不回显"})
|
||||
</span>
|
||||
<input value={apiKey} onChange={(e) => onApiKey(e.target.value)} placeholder="sk-…" className={`${inputCls} w-full`} />
|
||||
</label>
|
||||
<label className="block space-y-1">
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">maxTokens(该组预算,留空 = 保持/默认)</span>
|
||||
<input
|
||||
value={maxTokens}
|
||||
onChange={(e) => onMaxTokens(e.target.value)}
|
||||
inputMode="numeric"
|
||||
placeholder="8192"
|
||||
className={`${inputCls} w-full`}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 模型上下文窗口(token)输入行 —— 与 maxTokens 同排布同交互:留空 = 不提交
|
||||
* 该键(缺省 = 保留已存窗口),填了才作为一个明确声明提交。窗口只有部署者知道
|
||||
* (本仓没有 provider 侧发现代码),因此面板既不预填猜测值、也不把它当成必填。 */
|
||||
export function ContextWindowField({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (next: string) => void;
|
||||
}): React.ReactNode {
|
||||
return (
|
||||
<label className="block space-y-1">
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">上下文窗口 contextWindow(token,留空 = 未声明)</span>
|
||||
<input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
inputMode="numeric"
|
||||
placeholder="1048576"
|
||||
className={`${inputCls} w-full`}
|
||||
/>
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">
|
||||
留空 = 未声明(按 128 Ki token 保守估算);填该模型真实窗口(如 1048576)才能吃到按窗口推导的上下文预算(重启后生效)。
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
/** reasoning.kind 的展示文案:键是后端下发的取值,值是**形状说明**。
|
||||
* 后端将来新增的取值没有文案时原样显示,不编造含义。 */
|
||||
const REASONING_LABEL: Record<string, string> = {
|
||||
|
||||
@@ -146,6 +146,10 @@ export interface AdminLlmProvider {
|
||||
/** The group's token budget. Absent/0 = unset (the runtime falls back to
|
||||
* LLM_FALLBACK_MAX_TOKENS on the last step, then the adapter default). */
|
||||
maxTokens?: number;
|
||||
/** 该组声明的**模型上下文窗口**(token)。缺失/0 = **未声明**:后端按回落窗口
|
||||
* (128 Ki token)保守估算并记一条 WARN。只有填了该模型的真实窗口(如
|
||||
* `1048576`)才能吃到按窗口推导的上下文预算。 */
|
||||
contextWindow?: number;
|
||||
/** 该组的推理开关形状。后端始终下发(kind 可能为空串),旧构建则整个字段缺失。 */
|
||||
reasoning?: AdminLlmReasoning;
|
||||
/** persona → 模型名的路由表(裁决 03.4.1);缺失 = 该组没有 per-persona 绑定。 */
|
||||
|
||||
@@ -40,6 +40,9 @@ export interface LlmProviderRequest {
|
||||
fallback?: string;
|
||||
/** Absent = keep the stored budget (or leave unset for a new group). */
|
||||
maxTokens?: number;
|
||||
/** 该组声明的模型上下文窗口(token)。**Absent = 保留已存窗口**;显式 `0` 与
|
||||
* absent 同义(都表示未声明、按回落窗口推导);负数后端回 400。 */
|
||||
contextWindow?: number;
|
||||
/** 推理开关形状:**缺省 = 保留已存形状**;给出(哪怕 `{}`)= 用其中的 kind
|
||||
* 覆盖,kind 缺省/空串 = 回到「未声明」(等同 auto)。取值见
|
||||
* AdminLlmConfig.reasoningKinds —— 非法 kind 后端回 400。 */
|
||||
|
||||
Reference in new issue
Block a user