/** Persona `thinking` config — the wire shapes and the panel's form mapping * (design/01 §2.1; provider mapping in design/04 §3.2). * * Kept out of lib/types.ts (which is at its size ceiling) and, more importantly, * out of the panel component: the mapping is pure, so the tri-state rules are * testable without a browser. * * Two rules the read/write sides share: * - **`showToUser` is tri-state all the way.** `null`/absent = 未配置 = keep the * current behavior (thinking frames keep flowing); `true`/`false` = an explicit * choice. Flattening "unset" onto `false` would silently turn display off. * - **An all-unset block is omitted, not sent as zeros.** The write side reads an * omitted `thinking` object as "keep the stored one", so a panel that ships * defaults would wipe a hand-edited config. */ /** Read shape of the `thinking` block (`GET /api/v1/admin/personas`, v0.43). The * key is absent entirely when the persona configures none. */ export interface PersonaThinking { budgetTokens: number; effort: string; /** `null` = 未配置(保持现行为), `true`/`false` = 显式选择. */ showToUser: boolean | null; mode: string; } /** Write shape (`POST`/`PUT /api/v1/admin/personas`). An omitted key = unset; an * omitted whole object = keep the stored block. */ export interface PersonaThinkingRequest { budgetTokens?: number; effort?: string; showToUser?: boolean; mode?: string; } /** Explicit reasoning switch, matching the backend's `ThinkingMode` constants * (`internal/persona/loader`). */ export const THINKING_MODES = ["default", "on", "off"] as const; /** `reasoning_effort` values the model layer recognizes across the providers it * maps (design/04 §3.2). The **effective whitelist still depends on the active * provider** — OpenAI-compatible takes `none…max`; DeepSeek takes * `none/low/high/max` and normalizes the aliases (`minimal→low`, `medium→high`, * `xhigh→high`, `ultra→max`) — and only the backend can see which provider is in * play, so this list drives a select, not a promise. `ultra` is listed because * DeepSeek accepts it as an alias; on an OpenAI-compatible provider the backend * drops it and WARNs. */ export const THINKING_EFFORTS = [ "none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra", ] as const; /** Form state: everything is a string so a half-typed number never coerces to 0 * and the tri-state select has an explicit "unset" arm. */ export interface ThinkingDraft { budgetTokens: string; effort: string; showToUser: "unset" | "on" | "off"; mode: "" | (typeof THINKING_MODES)[number]; } export const EMPTY_THINKING_DRAFT: ThinkingDraft = { budgetTokens: "", effort: "", showToUser: "unset", mode: "", }; /** Backfill the form from the read side. An absent block (and an unknown mode) * yields the empty draft, which maps back to "omitted" — so opening a persona * that configures no thinking and saving it changes nothing. */ export function thinkingDraftFrom(read: PersonaThinking | null | undefined): ThinkingDraft { if (!read) return EMPTY_THINKING_DRAFT; return { budgetTokens: read.budgetTokens ? String(read.budgetTokens) : "", effort: read.effort ?? "", showToUser: read.showToUser === true ? "on" : read.showToUser === false ? "off" : "unset", mode: (THINKING_MODES as readonly string[]).includes(read.mode) ? (read.mode as ThinkingDraft["mode"]) : "", }; } /** Input validation only — what the client can actually know. Non-negative * integer budget, enumerations for mode/effort. Whether the provider really * honors the value is the backend's call (it validates against the active * provider's whitelist and WARNs when it drops one); the panel does not pretend * to know that, so it does not block on it. Returns an error message, or null. */ export function thinkingValidationError(draft: ThinkingDraft): string | null { const budget = draft.budgetTokens.trim(); if (budget !== "") { if (!/^\d+$/.test(budget)) return "思考预算必须是非负整数"; if (Number(budget) > Number.MAX_SAFE_INTEGER) return "思考预算过大"; } if (draft.mode !== "" && !(THINKING_MODES as readonly string[]).includes(draft.mode)) { return `思考开关取值非法:${draft.mode}(仅 default/on/off)`; } if (draft.effort !== "" && !(THINKING_EFFORTS as readonly string[]).includes(draft.effort)) { return `思考强度取值非法:${draft.effort}`; } return null; } /** Whether the form holds anything at all. An untouched form must not be sent: * the write side treats an omitted `thinking` object as "keep the stored block". */ export function thinkingDraftIsEmpty(draft: ThinkingDraft): boolean { return ( draft.budgetTokens.trim() === "" && draft.effort === "" && draft.mode === "" && draft.showToUser === "unset" ); } /** Map the form onto the request body. Returns `undefined` when nothing is * configured — the caller then omits `thinking` entirely, preserving whatever is * stored (write-side semantics, unchanged). `showToUser: "unset"` omits the key * so it stays `nil` in the backend, never a flattened `false`. Callers validate * with `thinkingValidationError` first. */ export function thinkingDraftToRequest(draft: ThinkingDraft): PersonaThinkingRequest | undefined { if (thinkingDraftIsEmpty(draft)) return undefined; const req: PersonaThinkingRequest = {}; const budget = draft.budgetTokens.trim(); if (budget !== "") req.budgetTokens = Number(budget); if (draft.effort !== "") req.effort = draft.effort; if (draft.mode !== "") req.mode = draft.mode; if (draft.showToUser !== "unset") req.showToUser = draft.showToUser === "on"; return req; }