- 面板读写面与 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)。
368 lines
14 KiB
TypeScript
368 lines
14 KiB
TypeScript
import type { AdminLlmProvider } from "../../../lib/types";
|
||
import { Button, Switch, Tag } from "../../../components/primitives";
|
||
|
||
/* ---------------------------------------------------------------------------
|
||
* 阶段 5 新增的字段(multimodal / reasoning.kind / per-persona 路由)与上下文
|
||
* 窗口(contextWindow)的编辑界面。取值域一律由后端下发(GET /admin/llm 的
|
||
* reasoningKinds / personaProviderOptions),本文件只提供展示文案与本地校验,
|
||
* 不硬编码第二套枚举,也不拿任何示例值顶替。
|
||
* -------------------------------------------------------------------------*/
|
||
|
||
/** 一个 persona 路由行的编辑草稿。 */
|
||
export interface PersonaRouteDraft {
|
||
persona: string;
|
||
model: string;
|
||
provider: string;
|
||
}
|
||
|
||
/** 从已存分组播种路由行:两张表的键取并集,使「只声明了 provider、没声明模型」
|
||
* 的条目也照实显示(面板绝不悄悄丢掉已存配置)。 */
|
||
export function initPersonaRoutes(p: AdminLlmProvider | null): PersonaRouteDraft[] {
|
||
const models = p?.personaModels ?? {};
|
||
const providers = p?.personaProviders ?? {};
|
||
const keys = [...new Set([...Object.keys(models), ...Object.keys(providers)])].sort();
|
||
return keys.map((persona) => ({
|
||
persona,
|
||
model: models[persona] ?? "",
|
||
provider: providers[persona] ?? "",
|
||
}));
|
||
}
|
||
|
||
/** 路由行 → 两张表。空 persona 的行是没填完的行(提交前已被拦下),不编造;
|
||
* 「有 provider 没 model」的行按原样保留在后端已有的 schema 语义里
|
||
* (它不生效,面板在行上明说),因此绝不把已存配置悄悄变成别的形状。 */
|
||
export function routesToMaps(routes: PersonaRouteDraft[]): {
|
||
personaModels: Record<string, string>;
|
||
personaProviders: Record<string, string>;
|
||
} {
|
||
const personaModels: Record<string, string> = {};
|
||
const personaProviders: Record<string, string> = {};
|
||
routes.forEach((r) => {
|
||
const persona = r.persona.trim();
|
||
if (!persona) return;
|
||
const model = r.model.trim();
|
||
const provider = r.provider.trim();
|
||
if (model) personaModels[persona] = model;
|
||
if (provider) personaProviders[persona] = provider;
|
||
});
|
||
return { personaModels, personaProviders };
|
||
}
|
||
|
||
/** 路由行的本地校验。只拦两类确定会让整张表出错的输入:persona 没填就填了别的
|
||
* 东西(后端会 400),以及同一 persona 出现多次(对象键会悄悄合并成一条)。 */
|
||
export function personaRouteProblem(routes: PersonaRouteDraft[]): string | null {
|
||
const seen = new Set<string>();
|
||
for (const r of routes) {
|
||
const persona = r.persona.trim();
|
||
if (!persona) {
|
||
if (r.model.trim() || r.provider.trim()) return "按角色路由:有行的 persona 没填(补上 persona,或删掉该行)";
|
||
continue;
|
||
}
|
||
if (seen.has(persona)) return `按角色路由:persona「${persona}」出现多次——同一 persona 只能有一条`;
|
||
seen.add(persona);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** 目录(models.dev)对候选模型的能力标注,用于给面板**建议**。
|
||
* attachment 为 undefined = 目录未标注该能力(不是「不支持」)。 */
|
||
export interface CatalogHint {
|
||
provider: string;
|
||
model: string;
|
||
attachment?: boolean;
|
||
supportsReasoning: boolean;
|
||
}
|
||
|
||
/** multimodal 开关 + 语义说明 + 目录建议。目录只是建议,随时可推翻;
|
||
* 目录不可用时这一栏照常可编辑(hint 为 null)。 */
|
||
export function MultimodalField({
|
||
checked,
|
||
hint,
|
||
onChange,
|
||
}: {
|
||
checked: boolean;
|
||
hint: CatalogHint | null;
|
||
onChange: (next: boolean) => void;
|
||
}): React.ReactNode {
|
||
return (
|
||
<div className="space-y-1">
|
||
<Switch checked={checked} onChange={onChange} label="主模型可收图(multimodal)" />
|
||
<p className="text-xs text-[var(--mu-text-lo)]">
|
||
勾选 = 主模型自己接收图片输入。图片识别 / OCR 工具用的模型由 .env 的 LLM_VISION_MODEL /
|
||
LLM_OCR_MODEL 另配,与这里无关。
|
||
</p>
|
||
{hint ? (
|
||
<p className="text-xs text-[var(--mu-text-mid)]">
|
||
目录(models.dev)对 {hint.model} 的标注:
|
||
{hint.attachment === true
|
||
? "接受图片输入"
|
||
: hint.attachment === false
|
||
? "不接受图片输入"
|
||
: "未标注该能力(请按你的端点实际能力填)"}
|
||
{hint.supportsReasoning ? " · 支持推理(开关形状仍需按端点选)" : ""} —— 只是建议,可随时推翻。
|
||
</p>
|
||
) : (
|
||
<p className="text-xs text-[var(--mu-text-lo)]">未从目录带入建议(目录不可用或本次是手填),不影响编辑。</p>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** 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> = {
|
||
auto: "auto(按模型名 / baseURL 推断,形状有歧义就不下发)",
|
||
deepseek: "deepseek(顶层 thinking:{type} + reasoning_effort)",
|
||
openai: "openai(只有 reasoning_effort)",
|
||
vllm: "vllm(chat_template_kwargs.enable_thinking)",
|
||
dashscope: "dashscope(顶层 enable_thinking)",
|
||
};
|
||
|
||
/** 推理开关形状下拉框。候选值 = 后端下发的 reasoningKinds(外加「未声明」);
|
||
* 已存值不在候选里时原样列出并标注,绝不悄悄改成别的值。 */
|
||
export function ReasoningField({
|
||
value,
|
||
kinds,
|
||
provider,
|
||
onChange,
|
||
}: {
|
||
value: string;
|
||
kinds: string[];
|
||
provider: string;
|
||
onChange: (next: string) => void;
|
||
}): React.ReactNode {
|
||
const opts = kinds.map((k) => ({ value: k, label: REASONING_LABEL[k] ?? k }));
|
||
if (value && !kinds.includes(value)) {
|
||
opts.unshift({ value, label: `${value}(当前值:后端未把它列为可选形状)` });
|
||
}
|
||
return (
|
||
<div className="space-y-1">
|
||
<label className="block space-y-1">
|
||
<span className="text-xs text-[var(--mu-text-lo)]">推理开关形状 reasoning.kind</span>
|
||
<select value={value} onChange={(e) => onChange(e.target.value)} className={`${inputCls} w-full`}>
|
||
<option value="">未声明(等同 auto:按模型名 / baseURL 推断)</option>
|
||
{opts.map((o) => (
|
||
<option key={o.value} value={o.value}>
|
||
{o.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
{kinds.length === 0 ? (
|
||
<p className="text-xs text-[var(--mu-warn)]">
|
||
后端未下发可选形状(响应缺少 reasoningKinds):这里只能选「未声明」,或按 design/04 §3.2 手填后由后端校验(非法值会 400)。
|
||
</p>
|
||
) : null}
|
||
{provider === "anthropic" && value !== "" && value !== "auto" ? (
|
||
<p className="text-xs text-[var(--mu-warn)]">
|
||
anthropic 组只接受「未声明 / auto」:这些形状属于 OpenAI 兼容端点,保存时后端会回 400。
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** 一行 persona 路由:persona(可手填,已知角色作为候选)+ 模型 + 协议覆盖。 */
|
||
function PersonaRouteRow({
|
||
route,
|
||
index,
|
||
providerOptions,
|
||
groupProvider,
|
||
listId,
|
||
onPatch,
|
||
onRemove,
|
||
}: {
|
||
route: PersonaRouteDraft;
|
||
index: number;
|
||
providerOptions: string[];
|
||
groupProvider: string;
|
||
listId: string;
|
||
onPatch: (index: number, patch: Partial<PersonaRouteDraft>) => void;
|
||
onRemove: (index: number) => void;
|
||
}): React.ReactNode {
|
||
const persona = route.persona.trim();
|
||
const hasModel = route.model.trim() !== "";
|
||
const providerValue = route.provider.trim();
|
||
const providerChoices = providerValue && !providerOptions.includes(providerValue)
|
||
? [providerValue, ...providerOptions]
|
||
: providerOptions;
|
||
const dead = persona !== "" && providerValue !== "" && !hasModel;
|
||
return (
|
||
<div className="flex flex-wrap items-center gap-2 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2">
|
||
<input
|
||
value={route.persona}
|
||
onChange={(e) => onPatch(index, { persona: e.target.value })}
|
||
list={listId}
|
||
placeholder="persona id"
|
||
className={`${inputCls} w-40`}
|
||
aria-label="persona id"
|
||
/>
|
||
<input
|
||
value={route.model}
|
||
onChange={(e) => onPatch(index, { model: e.target.value })}
|
||
placeholder="模型名(留空 = 不绑定)"
|
||
className={`${inputCls} w-56 font-mono text-xs`}
|
||
aria-label="该 persona 的模型名"
|
||
/>
|
||
<select
|
||
value={providerValue}
|
||
onChange={(e) => onPatch(index, { provider: e.target.value })}
|
||
className={`${inputCls} w-56`}
|
||
aria-label="该 persona 的协议覆盖"
|
||
>
|
||
<option value="">跟随本组协议({groupProvider || "未定"})</option>
|
||
{providerChoices.map((p) => (
|
||
<option key={p} value={p}>
|
||
{providerOptions.includes(p) ? p : `${p}(当前值:后端未把它列为可选)`}
|
||
</option>
|
||
))}
|
||
</select>
|
||
{dead ? <Tag tone="warn">只有协议没有模型 → 不生效</Tag> : null}
|
||
<span className="ml-auto">
|
||
<Button size="sm" variant="ghost" onClick={() => onRemove(index)}>
|
||
删除
|
||
</Button>
|
||
</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** per-persona 路由编辑器。角色清单只是**候选**(可手填 id):清单取不到时
|
||
* 照常手填,绝不阻塞保存;每条路由的含义与生效条件在下方如实说明。 */
|
||
export function PersonaRoutesEditor({
|
||
routes,
|
||
knownIds,
|
||
personaListError,
|
||
providerOptions,
|
||
groupProvider,
|
||
onChange,
|
||
}: {
|
||
routes: PersonaRouteDraft[];
|
||
knownIds: string[];
|
||
personaListError: string | null;
|
||
providerOptions: string[];
|
||
groupProvider: string;
|
||
onChange: (next: PersonaRouteDraft[]) => void;
|
||
}): React.ReactNode {
|
||
const listId = "llm-persona-ids";
|
||
const patch = (index: number, p: Partial<PersonaRouteDraft>): void =>
|
||
onChange(routes.map((r, i) => (i === index ? { ...r, ...p } : r)));
|
||
const remove = (index: number): void => onChange(routes.filter((_, i) => i !== index));
|
||
return (
|
||
<div className="space-y-2">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<span className="text-xs text-[var(--mu-text-lo)]">按角色路由(persona → 模型 / 协议)</span>
|
||
<span className="text-xs text-[var(--mu-text-lo)]">
|
||
未列出的 persona 一律用本组默认模型(协议 {groupProvider || "未定"})
|
||
</span>
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
className="ml-auto"
|
||
onClick={() => onChange([...routes, { persona: "", model: "", provider: "" }])}
|
||
>
|
||
添加一行
|
||
</Button>
|
||
</div>
|
||
<datalist id={listId}>
|
||
{knownIds.map((id) => (
|
||
<option key={id} value={id} />
|
||
))}
|
||
</datalist>
|
||
{personaListError ? (
|
||
<p className="text-xs text-[var(--mu-warn)]">
|
||
角色清单未取到({personaListError}):persona id 仍可手填,不影响保存。
|
||
</p>
|
||
) : null}
|
||
{routes.length === 0 ? (
|
||
<p className="text-xs text-[var(--mu-text-lo)]">没有 per-persona 路由:该组所有角色都用本组的 model。</p>
|
||
) : (
|
||
routes.map((r, i) => (
|
||
<PersonaRouteRow
|
||
key={i}
|
||
route={r}
|
||
index={i}
|
||
providerOptions={providerOptions}
|
||
groupProvider={groupProvider}
|
||
listId={listId}
|
||
onPatch={patch}
|
||
onRemove={remove}
|
||
/>
|
||
))
|
||
)}
|
||
{providerOptions.length === 0 ? (
|
||
<p className="text-xs text-[var(--mu-warn)]">
|
||
后端未下发可选的 persona 协议(响应缺少 personaProviderOptions):这里只能选「跟随本组协议」。
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const inputCls =
|
||
"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";
|