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,143 @@
// Logic test for seeding the mood badge from the role catalog (v0.43 read side,
// design/01 §2): `GET /personas` reports each role's *role-level* mood, and the
// frontend seeds `moods[roleId]` from it on load so a refresh no longer flashes an
// empty 心情 badge. The project has no frontend test runner and no jsdom, so this
// is a dependency-free plain-node check driving the real seed helper
// (store/personaSelectors.ts), the real store and the real WS frame router
// (lib/ws.ts), and throwing on the first failure.
//
// Run it with the esbuild that already ships with vite (no new dependency, no
// package.json change); the banner supplies the `window.localStorage` the store's
// persist middleware probes, which is simply absent under node:
//
// cd frontend/web \
// && ./node_modules/.bin/esbuild src/store/personaMoodSeed.test.ts \
// --bundle --platform=node --format=esm \
// --banner:js='globalThis.window={localStorage:{getItem:()=>null,setItem:()=>{},removeItem:()=>{}}};' \
// --outfile=/tmp/cyrene-persona-mood-seed.test.mjs \
// && node /tmp/cyrene-persona-mood-seed.test.mjs
//
// `tsc --noEmit` (part of `npm run build`) type-checks this file, which is why it
// imports nothing from node:*.
import { dispatchFrame } from "../lib/ws";
import type { PersonaRecord, SessionMeta } from "../lib/types";
import { personaMoodSeed, selectCurrentMood } from "./personaSelectors";
import { useAppStore } from "./useAppStore";
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}`);
}
const MAIN = "web";
const SUB_A = "web:sub-a";
const SUB_B = "web:sub-b";
/** A catalog shaped like `GET /api/v1/personas` v0.43: 昔涟 has a tracked mood,
* 雪 has none (the backend reports ""), and the third role carries no mood field
* at all (an older backend). */
const CATALOG: PersonaRecord[] = [
{ id: "cyrene", name: "昔涟", character: "cyrene", isDefault: true, mode: "chat", memoryNamespace: "persona.cyrene", mood: "happy" },
{ id: "yuki", name: "雪", character: "yuki", isDefault: false, mode: "chat", memoryNamespace: "persona.yuki", mood: "" },
{ id: "old", name: "旧端点", character: "old", isDefault: false, mode: "chat", memoryNamespace: "persona.old" },
];
/** Main session → default role (昔涟); sub-a → 雪; sub-b → default again. */
function sessions(): Record<string, SessionMeta> {
return {
[MAIN]: { id: MAIN, type: "main", createdAt: 1 },
[SUB_A]: { id: SUB_A, type: "sub", createdAt: 2, personaId: "yuki" },
[SUB_B]: { id: SUB_B, type: "sub", createdAt: 3, personaId: "" },
};
}
/** 1. The pure seed: role id → role-level mood, and nothing for a role the
* backend reported no mood for. */
function testSeedShape(): void {
same(personaMoodSeed(CATALOG), { cyrene: "happy" }, "only roles with a mood are seeded");
same(personaMoodSeed([]), {}, "an empty catalog seeds nothing");
same(personaMoodSeed([{ ...CATALOG[0], mood: "" }]), {}, "an empty mood seeds nothing (never a placeholder)");
same(personaMoodSeed([{ ...CATALOG[0], id: "" , mood: "happy" }]), {}, "a role without an id seeds nothing");
// Seeding is keyed by role id, so it can never key onto a session.
ok(!Object.keys(personaMoodSeed(CATALOG)).includes(MAIN), "no session id ever becomes a mood bucket");
}
/** 2. The refresh scenario this batch exists for: after loading the catalog the
* active role's mood is already known — no blank badge while the next WS frame
* is awaited. */
function testSeedFillsBadgeOnLoad(): void {
useAppStore.setState({ moods: {}, personas: [], sessions: {}, activeSessionId: "" });
same(selectCurrentMood(useAppStore.getState()), null, "before the catalog loads there is no mood");
useAppStore.setState({ personas: CATALOG, sessions: sessions(), activeSessionId: MAIN });
useAppStore.getState().seedMoods(personaMoodSeed(CATALOG));
same(useAppStore.getState().moods, { cyrene: "happy" }, "the catalog's role mood lands in its own bucket");
same(selectCurrentMood(useAppStore.getState()), "happy", "the badge is filled on load (main session → 昔涟)");
}
/** 3. The mood is role-level: two sessions of the same role show the same mood,
* and switching to another role's session shows that role's own value. */
function testMoodIsRoleLevel(): void {
useAppStore.setState({
moods: {},
personas: CATALOG,
sessions: sessions(),
activeSessionId: MAIN,
});
useAppStore.getState().seedMoods(personaMoodSeed(CATALOG));
same(selectCurrentMood(useAppStore.getState()), "happy", "main session (default role 昔涟) shows happy");
useAppStore.setState({ activeSessionId: SUB_B });
same(selectCurrentMood(useAppStore.getState()), "happy", "another session of the same role shows the SAME mood");
useAppStore.setState({ activeSessionId: SUB_A });
same(selectCurrentMood(useAppStore.getState()), null, "a role with no tracked mood shows nothing (no invented placeholder)");
// A live frame for the unseeded role fills it, and the role-scoped keying keeps
// the two roles apart.
dispatchFrame({ type: "mood", data: { mood: "sad", personaId: "yuki" } });
same(selectCurrentMood(useAppStore.getState()), "sad", "a mood frame updates the active role's badge");
useAppStore.setState({ activeSessionId: MAIN });
same(selectCurrentMood(useAppStore.getState()), "happy", "the other role's badge is untouched");
}
/** 4. Seeding merges: it overwrites with the server's truth but never clears a
* bucket, so a WS frame that arrived before the catalog loaded survives. */
function testSeedMergesAndNeverClears(): void {
useAppStore.setState({ moods: { yuki: "excited" }, personas: [], sessions: {}, activeSessionId: MAIN });
useAppStore.getState().seedMoods(personaMoodSeed(CATALOG));
same(useAppStore.getState().moods, { yuki: "excited", cyrene: "happy" }, "an unmentioned bucket is kept, the seeded one added");
// The server is the truth for a role it does report a mood for.
useAppStore.setState({ moods: { cyrene: "angry" } });
useAppStore.getState().seedMoods(personaMoodSeed(CATALOG));
same(useAppStore.getState().moods.cyrene, "happy", "a reported role mood overwrites the stale local value");
// "" / absent (an untracked role) must not wipe a live bucket.
useAppStore.setState({ moods: { yuki: "excited" } });
useAppStore.getState().seedMoods(personaMoodSeed(CATALOG));
same(useAppStore.getState().moods.yuki, "excited", "a role the seed skips keeps its bucket");
// An empty seed is a no-op (no needless state churn).
const before = useAppStore.getState().moods;
useAppStore.getState().seedMoods({});
ok(useAppStore.getState().moods === before, "an empty seed leaves the state object untouched");
}
function run(): void {
testSeedShape();
testSeedFillsBadgeOnLoad();
testMoodIsRoleLevel();
testSeedMergesAndNeverClears();
console.log(`personaMoodSeed.test: ${checks} checks passed`);
}
run();
@@ -0,0 +1,50 @@
import { resolveSessionPersona } from "../lib/persona";
import type { PersonaRecord } from "../lib/types";
import { useAppStore, type AppStore } from "./useAppStore";
/** The role the UI renders (design/22 §4.6): the active session's bound role,
* else the global default. THE single resolution point — the avatar, name,
* wallpaper, mood bucket and message labels all read it, so none of them can
* disagree about "who is speaking" (design/22 §5 risk row 1).
*
* Lives in its own module (not in useAppStore.ts, which is at its size ceiling)
* so the store file keeps holding state/actions only. */
export function selectCurrentPersona(s: AppStore): PersonaRecord | null {
return resolveSessionPersona(s.activeSessionId, s.sessions, s.personas);
}
/** The active role's mood (design/22 §4.6). `mood` frames are role-scoped and
* carry no sessionId, so a role's mood is only shown above its own sessions —
* the bucket is keyed by the *resolved* role id, never a global value. */
export function selectCurrentMood(s: AppStore): string | null {
const personaId = selectCurrentPersona(s)?.id ?? "";
return personaId ? s.moods[personaId] ?? null : null;
}
/** Subscribe to the active session's role. */
export function useCurrentPersona(): PersonaRecord | null {
return useAppStore(selectCurrentPersona);
}
/** Subscribe to the active role's mood. */
export function useCurrentMood(): string | null {
return useAppStore(selectCurrentMood);
}
/** Mood buckets to seed from the role catalog (`GET /api/v1/personas`): the role
* id → its current role-level mood (design/01 §2: mood follows the role).
*
* Two rules, both about not lying with a placeholder:
* - a role the backend reports no mood for (`""`/absent) contributes NOTHING, so
* seeding never clears a bucket a WS `mood` frame already filled;
* - only roles the catalog actually lists are seeded, so no bucket is invented.
*
* Pure and exported so the seeding rule is testable without a browser. */
export function personaMoodSeed(personas: PersonaRecord[]): Record<string, string> {
const seed: Record<string, string> = {};
for (const p of personas) {
if (p.id && p.mood) seed[p.id] = p.mood;
}
return seed;
}
@@ -25,7 +25,8 @@
import { resolveMessagePersona, resolveSessionPersona } from "../lib/persona";
import { setSessionPersona, type SessionPersonaIO } from "../lib/sessionPersona";
import { dispatchFrame } from "../lib/ws";
import { selectCurrentPersona, useAppStore } from "./useAppStore";
import { selectCurrentPersona } from "./personaSelectors";
import { useAppStore } from "./useAppStore";
import type { PersonaRecord, SessionMeta } from "../lib/types";
let checks = 0;
+10 -22
View File
@@ -13,7 +13,6 @@ import type {
ToolUsed,
WsStatus,
} from "../lib/types";
import { resolveSessionPersona } from "../lib/persona";
import { now, uid } from "../lib/util";
interface ChatContext {
@@ -37,7 +36,8 @@ export interface AppStore {
* persona-scoped and carries no `sessionId`, so each role gets its own bucket
* and the UI shows the bucket of the *active session's* role — role X's mood
* must not sit above role Y's conversation. `""` holds events that arrived
* without a persona (legacy/unscoped frames). */
* without a persona (legacy/unscoped frames). Seeded from the role catalog's
* role-level mood on load (`seedMoods` + `personaMoodSeed`). */
moods: Record<string, string>;
context: ChatContext;
agents: AgentInfo[];
@@ -72,6 +72,12 @@ export interface AppStore {
/** Record one role's new mood. `personaId` is the role the event named; "" keeps
* an unscoped/legacy frame in its own bucket. A null mood drops the bucket. */
setMood: (mood: string | null, personaId?: string) => void;
/** Merge role-level moods read from `GET /personas` into the buckets (design/01
* §2: mood follows the role, so the catalog is an authoritative seed). Values
* already known are overwritten by the server's truth; buckets the seed does
* not mention are left alone, so a WS frame that arrived first is never
* cleared. Build the seed with `personaMoodSeed`. */
seedMoods: (seed: Record<string, string>) => void;
/** Replace the session metadata list. */
setSessions: (list: SessionMeta[]) => void;
@@ -166,6 +172,8 @@ export const useAppStore = create<AppStore>()(
else delete moods[personaId];
return { moods };
}),
seedMoods: (seed) =>
set((s) => (Object.keys(seed).length ? { moods: { ...s.moods, ...seed } } : s)),
setSessions: (list) =>
set({
@@ -450,26 +458,6 @@ export const useAppStore = create<AppStore>()(
)
);
/** The role the UI renders (design/22 §4.6): the active session's bound role,
* else the global default. THE single resolution point — the avatar, name,
* wallpaper, mood bucket and message labels all read it, so none of them can
* disagree about "who is speaking" (design/22 §5 risk row 1). */
export function selectCurrentPersona(s: AppStore): PersonaRecord | null {
return resolveSessionPersona(s.activeSessionId, s.sessions, s.personas);
}
/** Subscribe to the active session's role. */
export function useCurrentPersona(): PersonaRecord | null {
return useAppStore(selectCurrentPersona);
}
/** Subscribe to the active role's mood. `mood` frames are role-scoped and carry
* no sessionId, so a role's mood is only shown above its own sessions. */
export function useCurrentMood(): string | null {
const personaId = useAppStore((s) => selectCurrentPersona(s)?.id ?? "");
return useAppStore((s) => (personaId ? s.moods[personaId] ?? null : null));
}
/** Convenience selector for the last streaming assistant message id. */
export function lastStreamingMessage(s: AppStore): ChatMessage | undefined {
const last = s.messages[s.messages.length - 1];