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; personaProviders: Record; } { const personaModels: Record = {}; const personaProviders: Record = {}; 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(); 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 (

勾选 = 主模型自己接收图片输入。图片识别 / OCR 工具用的模型由 .env 的 LLM_VISION_MODEL / LLM_OCR_MODEL 另配,与这里无关。

{hint ? (

目录(models.dev)对 {hint.model} 的标注: {hint.attachment === true ? "接受图片输入" : hint.attachment === false ? "不接受图片输入" : "未标注该能力(请按你的端点实际能力填)"} {hint.supportsReasoning ? " · 支持推理(开关形状仍需按端点选)" : ""} —— 只是建议,可随时推翻。

) : (

未从目录带入建议(目录不可用或本次是手填),不影响编辑。

)}
); } /** 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 (
); } /** 模型上下文窗口(token)输入行 —— 与 maxTokens 同排布同交互:留空 = 不提交 * 该键(缺省 = 保留已存窗口),填了才作为一个明确声明提交。窗口只有部署者知道 * (本仓没有 provider 侧发现代码),因此面板既不预填猜测值、也不把它当成必填。 */ export function ContextWindowField({ value, onChange, }: { value: string; onChange: (next: string) => void; }): React.ReactNode { return ( ); } /** reasoning.kind 的展示文案:键是后端下发的取值,值是**形状说明**。 * 后端将来新增的取值没有文案时原样显示,不编造含义。 */ const REASONING_LABEL: Record = { 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 (
{kinds.length === 0 ? (

后端未下发可选形状(响应缺少 reasoningKinds):这里只能选「未声明」,或按 design/04 §3.2 手填后由后端校验(非法值会 400)。

) : null} {provider === "anthropic" && value !== "" && value !== "auto" ? (

anthropic 组只接受「未声明 / auto」:这些形状属于 OpenAI 兼容端点,保存时后端会回 400。

) : null}
); } /** 一行 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) => 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 (
onPatch(index, { persona: e.target.value })} list={listId} placeholder="persona id" className={`${inputCls} w-40`} aria-label="persona id" /> onPatch(index, { model: e.target.value })} placeholder="模型名(留空 = 不绑定)" className={`${inputCls} w-56 font-mono text-xs`} aria-label="该 persona 的模型名" /> {dead ? 只有协议没有模型 → 不生效 : null}
); } /** 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): void => onChange(routes.map((r, i) => (i === index ? { ...r, ...p } : r))); const remove = (index: number): void => onChange(routes.filter((_, i) => i !== index)); return (
按角色路由(persona → 模型 / 协议) 未列出的 persona 一律用本组默认模型(协议 {groupProvider || "未定"})
{knownIds.map((id) => ( {personaListError ? (

角色清单未取到({personaListError}):persona id 仍可手填,不影响保存。

) : null} {routes.length === 0 ? (

没有 per-persona 路由:该组所有角色都用本组的 model。

) : ( routes.map((r, i) => ( )) )} {providerOptions.length === 0 ? (

后端未下发可选的 persona 协议(响应缺少 personaProviderOptions):这里只能选「跟随本组协议」。

) : null}
); } 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";