Files
Cyrene_v2/frontend/web/src/admin/modules/llm/LlmProviderRouting.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

368 lines
14 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 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";