feat(persona,web): 人格读侧(thinking 三态 + 角色级 mood)+ 面板表单 + mood 播种
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

按 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 逻辑测试通过
This commit is contained in:
AskaEth committed 2026-10-03 20:40:44 +08:00
1 parent 6dc1f218d5
commit 2aa3233f84
20 files changed
+1042 -61

No files matched your search

@@ -0,0 +1,165 @@
// Logic test for the panel's `thinking` form ↔ request mapping (design/01 §2.1,
// v0.43 read side). The two rules it must not lose are the tri-state of
// `showToUser` (omitted = 未配置 = keep the current behavior; never flattened into
// `false`) and the omission rule: a form that configures nothing must leave the
// `thinking` key OUT of the body, because the write side reads an omitted object
// as "keep the stored block" (an all-default object would CLEAR it). The project
// has no frontend test runner and no jsdom, so this is a dependency-free
// plain-node check driving the real module (lib/personaThinking.ts) and throwing
// on the first failure.
//
// Run it with the esbuild that already ships with vite (no new dependency, no
// package.json change):
//
// cd frontend/web \
// && ./node_modules/.bin/esbuild src/lib/personaThinking.test.ts \
// --bundle --platform=node --format=esm \
// --outfile=/tmp/cyrene-persona-thinking.test.mjs \
// && node /tmp/cyrene-persona-thinking.test.mjs
//
// `tsc --noEmit` (part of `npm run build`) type-checks this file, which is why it
// imports nothing from node:*.
import {
EMPTY_THINKING_DRAFT,
THINKING_EFFORTS,
thinkingDraftFrom,
thinkingDraftToRequest,
thinkingValidationError,
type PersonaThinking,
type ThinkingDraft,
} from "./personaThinking";
let checks = 0;
function ok(cond: boolean, what: string): void {
checks += 1;
if (!cond) throw new Error(`FAILED: ${what}`);
}
function same(got: unknown, want: unknown, what: string): void {
const g = JSON.stringify(got);
const w = JSON.stringify(want);
ok(g === w, `${what} — got ${g}, want ${w}`);
}
/* --- 1. Backfill from the read side ----------------------------------------- */
// An absent block (persona configures no thinking) reads as the empty draft.
same(thinkingDraftFrom(undefined), EMPTY_THINKING_DRAFT, "absent read block → empty draft");
same(thinkingDraftFrom(null), EMPTY_THINKING_DRAFT, "null read block → empty draft");
// A configured block backfills field by field, including the tri-state arm.
const read: PersonaThinking = { budgetTokens: 4096, effort: "high", showToUser: false, mode: "off" };
same(
thinkingDraftFrom(read),
{ budgetTokens: "4096", effort: "high", showToUser: "off", mode: "off" },
"configured block backfills (explicit false stays 关)"
);
same(
thinkingDraftFrom({ budgetTokens: 0, effort: "", showToUser: null, mode: "" }),
EMPTY_THINKING_DRAFT,
"an all-unset read block backfills to the empty draft"
);
same(
thinkingDraftFrom({ budgetTokens: 1024, effort: "", showToUser: null, mode: "" }),
{ budgetTokens: "1024", effort: "", showToUser: "unset", mode: "" },
"null showToUser backfills as 未配置, not 关"
);
// An out-of-list stored value is preserved (never silently blanked).
same(
thinkingDraftFrom({ budgetTokens: 0, effort: "tiny", showToUser: null, mode: "always" }).effort,
"tiny",
"an unknown stored effort is preserved for display"
);
/* --- 2. Form → request body, incl. tri-state and omission -------------------- */
// The omission rule: nothing configured → no `thinking` key at all on the wire.
const untouched = thinkingDraftToRequest(EMPTY_THINKING_DRAFT);
ok(untouched === undefined, "an untouched form maps to undefined (omit the block)");
same(
JSON.stringify({ name: "昔涟", thinking: untouched }),
JSON.stringify({ name: "昔涟" }),
"an untouched form leaves no `thinking` key in the request body (省略保留)"
);
// …and a whitespace-only budget is still "untouched", not budget 0.
ok(
thinkingDraftToRequest({ ...EMPTY_THINKING_DRAFT, budgetTokens: " " }) === undefined,
"a whitespace-only budget is treated as unconfigured"
);
// The tri-state of showToUser, verbatim on the wire.
const unset = thinkingDraftToRequest({ ...EMPTY_THINKING_DRAFT, budgetTokens: "2048" });
ok(unset !== undefined, "a budget-only form still maps to a request");
ok(!("showToUser" in (unset as object)), "showToUser:未配置 omits the key (stays nil, not false)");
same(unset?.showToUser, undefined, "showToUser:未配置 is undefined, never false");
const off = thinkingDraftToRequest({ ...EMPTY_THINKING_DRAFT, showToUser: "off" });
same(off, { showToUser: false }, "showToUser:关 maps to the boolean false");
same(JSON.stringify(off), JSON.stringify({ showToUser: false }), "an explicit false survives JSON.stringify");
const on = thinkingDraftToRequest({ ...EMPTY_THINKING_DRAFT, showToUser: "on" });
same(on, { showToUser: true }, "showToUser:开 maps to the boolean true");
// Every other knob maps only when set.
same(
thinkingDraftToRequest({ budgetTokens: "4096", effort: "medium", showToUser: "unset", mode: "on" }),
{ budgetTokens: 4096, effort: "medium", mode: "on" },
"set knobs map, unset ones are omitted"
);
same(
thinkingDraftToRequest({ budgetTokens: "0", effort: "", showToUser: "unset", mode: "" }),
{ budgetTokens: 0 },
"an explicit 0 budget is a configured value (0 = 未设 for the backend)"
);
/* --- 3. Round-trip: read → form → request ---------------------------------- */
// What the panel sends back for a persona it just loaded must describe the same
// stored block, so opening a persona and saving it changes nothing.
const cases: PersonaThinking[] = [
{ budgetTokens: 4096, effort: "high", showToUser: false, mode: "off" },
{ budgetTokens: 2048, effort: "", showToUser: null, mode: "" },
{ budgetTokens: 0, effort: "", showToUser: true, mode: "on" },
{ budgetTokens: 0, effort: "low", showToUser: null, mode: "default" },
];
for (const c of cases) {
const back = thinkingDraftToRequest(thinkingDraftFrom(c));
// Built in the mapper's own key order (JSON.stringify is order-sensitive here).
const want: Record<string, unknown> = {};
if (c.budgetTokens) want.budgetTokens = c.budgetTokens;
if (c.effort) want.effort = c.effort;
if (c.mode) want.mode = c.mode;
if (c.showToUser !== null) want.showToUser = c.showToUser;
same(back, want, `round-trip ${JSON.stringify(c)}`);
}
/* --- 4. Input validation (only what the client can know) -------------------- */
same(thinkingValidationError(EMPTY_THINKING_DRAFT), null, "empty draft is valid");
same(thinkingValidationError({ ...EMPTY_THINKING_DRAFT, budgetTokens: "4096" }), null, "4096 is valid");
same(thinkingValidationError({ ...EMPTY_THINKING_DRAFT, budgetTokens: "0" }), null, "0 is valid (non-negative)");
ok(thinkingValidationError({ ...EMPTY_THINKING_DRAFT, budgetTokens: "-1" }) !== null, "a negative budget is rejected");
ok(thinkingValidationError({ ...EMPTY_THINKING_DRAFT, budgetTokens: "1.5" }) !== null, "a fractional budget is rejected");
ok(thinkingValidationError({ ...EMPTY_THINKING_DRAFT, budgetTokens: "4096t" }) !== null, "a non-numeric budget is rejected");
same(thinkingValidationError({ ...EMPTY_THINKING_DRAFT, mode: "on" }), null, "mode on is valid");
same(thinkingValidationError({ ...EMPTY_THINKING_DRAFT, mode: "default" }), null, "mode default is valid");
ok(
thinkingValidationError({ ...EMPTY_THINKING_DRAFT, mode: "always" as ThinkingDraft["mode"] }) !== null,
"an unknown mode is rejected"
);
for (const e of THINKING_EFFORTS) {
same(thinkingValidationError({ ...EMPTY_THINKING_DRAFT, effort: e }), null, `effort ${e} is valid`);
}
ok(thinkingValidationError({ ...EMPTY_THINKING_DRAFT, effort: "tiny" }) !== null, "an unknown effort is rejected");
// The panel must not claim a capability: no error is raised merely because the
// value may be unsupported by the *active* provider (the backend WARNs instead).
same(
thinkingValidationError({ ...EMPTY_THINKING_DRAFT, effort: "none", mode: "off" }),
null,
"a provider-dependent combination is not blocked client-side"
);
console.log(`personaThinking: ${checks} checks passed`);
+135
View File
@@ -0,0 +1,135 @@
/** 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;
}
+15
View File
@@ -1,6 +1,8 @@
// Shared wire & UI model types for the Cyrene v2 user frontend (M9).
// Strict mode: no `any`; unknown payloads are narrowed at the parse boundary.
import type { PersonaThinking, PersonaThinkingRequest } from "./personaThinking";
/** The 9 behavior modes. Switching only changes the presentation layer. */
export type Mode =
| "chat"
@@ -218,6 +220,14 @@ export interface PersonaRecord {
avatar?: string;
/** Per-orientation chat wallpaper; a role's own, never another's. Omitted when unset. */
chatBackground?: { landscape?: string; portrait?: string };
/** The role's current mood (v0.43, role-level: design/01 §2 — emotion follows
* the role, never a session, so every session of a role shows the same value).
* `""`/absent = the role has no tracked emotion; the UI keeps its neutral
* placeholder rather than inventing one. Used to seed the mood badge on load. */
mood?: string;
/** The optional `thinking` block (v0.43). Admin list (`GET /admin/personas`)
* only; absent when the persona configures none. */
thinking?: PersonaThinking;
}
export interface LoginResult {
@@ -557,6 +567,11 @@ export interface PersonaUpsertRequest {
modes?: string[];
mode?: string;
memoryNamespace?: string;
/** Optional reasoning/presentation block (design/01 §2.1). **Omit the key when
* the form configures nothing**: an omitted object keeps the stored block,
* while an all-default object clears it. `showToUser` stays tri-state — omit it
* (= not configured) rather than sending `false`. */
thinking?: PersonaThinkingRequest;
}
/** POST /api/v1/admin/users/apikey — the plaintext key is returned exactly once. */