Files
Cyrene_v2/frontend/web/src/lib/personaThinking.ts
T
AskaEth 2aa3233f84
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(persona,web): 人格读侧(thinking 三态 + 角色级 mood)+ 面板表单 + mood 播种
按 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 逻辑测试通过
2026-10-03 20:40:44 +08:00

136 lines
5.7 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.
/** 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;
}