按 design/04 §3.2(thinking 语义,showToUser 三态)+ design/01 §2.1 + 主人两条定论(心情跟角色不跟会话;mood 初值走口径 ①:由角色读端点暴露、前端加载播种)。 - 管理侧 GET /admin/personas 的 PersonaSummary 增 thinking(budgetTokens/effort/mode/showToUser)——**showToUser 保持三态**(未配置 ≠ false) - 用户侧 GET /personas 增**角色级 mood**(取自 emotion 层该角色当前状态,非从消息推断)→ 前端加载播种 currentMood,消除「刷新后心情徽标短暂为空」 - 前端 Personas 页加 thinking 四控件(预算/effort/模式/showToUser 三态):只做输入校验,**provider 是否真支持由后端 WARN 决定,前端不假装能力** - 先拆 useAppStore 选择器(原 494/500)到独立模块再做,避免越线 - Docs/api-client 版本号 +1(两处契约 + 三态与角色级 mood 语义) - 验收:go build/vet/test 全绿 + linux/darwin 交叉编译 + 前端 build 绿 + personaSession/messageSync 逻辑测试通过
136 lines
5.7 KiB
TypeScript
136 lines
5.7 KiB
TypeScript
/** 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;
|
||
}
|