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 逻辑测试通过
This commit is contained in:
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;
|
||||
|
||||
@@ -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];
|
||||
|
||||
Reference in new issue
Block a user