{message.mood ? (
心情 · {labelFor("mood", message.mood)}
) : null}
+ {switched ?
: null}
{message.tools?.map((t, i) =>
)}
diff --git a/frontend/web/src/lib/api.ts b/frontend/web/src/lib/api.ts
index 08958ee..e175ff4 100644
--- a/frontend/web/src/lib/api.ts
+++ b/frontend/web/src/lib/api.ts
@@ -40,8 +40,9 @@ export {
fetchMessages,
fetchSessions,
markSessionRead,
+ patchSession,
} from "./api/sessions";
-export type { MessageDto } from "./api/sessions";
+export type { CreateSessionResult, MessageDto, SessionPatch } from "./api/sessions";
export { deleteMessage, patchMessage } from "./api/messages";
diff --git a/frontend/web/src/lib/api/sessions.ts b/frontend/web/src/lib/api/sessions.ts
index aca8870..1c6cfa0 100644
--- a/frontend/web/src/lib/api/sessions.ts
+++ b/frontend/web/src/lib/api/sessions.ts
@@ -18,6 +18,9 @@ export interface MessageDto {
deviceId?: string;
/** Sender display name of a third-party message (design/16 §4.6). Blank for the owner. */
senderName?: string;
+ /** The role that spoke this message (design/22 §7-5, backing column
+ * `chat_messages.persona_id`); `''` means "inherit the current default role". */
+ personaId?: string;
createdAt: number;
}
@@ -35,6 +38,7 @@ function toChatMessage(m: MessageDto): ChatMessage {
platform: m.platform,
deviceId: m.deviceId,
senderName: m.senderName,
+ personaId: m.personaId,
createdAt: m.createdAt,
};
}
@@ -44,14 +48,54 @@ export function fetchSessions(): Promise
{
return request("/sessions");
}
-/** POST /api/v1/sessions — creates a new secondary session. */
-export function createSubSession(title?: string): Promise {
- return request("/sessions", {
+/** `POST /api/v1/sessions` reply: the new session's id, plus the bound role when
+ * one was requested (docs/api-client.md §5) — the full summary is not echoed. `id`
+ * is accepted too so a backend that returns a summary still reads correctly. */
+export interface CreateSessionResult {
+ sessionId?: string;
+ id?: string;
+ personaId?: string;
+}
+
+/** POST /api/v1/sessions — creates a new secondary session. `title` is the
+ * optional display name; `personaId` binds the session to a role (design/22
+ * §4.5). An omitted/empty `personaId` leaves the session inheriting the global
+ * default role. The reply carries only the new id, so callers seed the new row
+ * locally and let the next `GET /sessions` supply the metadata. */
+export function createSubSession(opts: {
+ title?: string;
+ personaId?: string;
+} = {}): Promise {
+ return request("/sessions", {
method: "POST",
- body: JSON.stringify(title ? { title } : {}),
+ body: JSON.stringify({
+ ...(opts.title ? { title: opts.title } : {}),
+ ...(opts.personaId ? { personaId: opts.personaId } : {}),
+ }),
});
}
+/** One PATCH /api/v1/sessions/{id} change set. At least one field must be set;
+ * the gateway rejects an empty body with 400. `personaId: ""` explicitly
+ * re-binds the session to "inherit the default role" (design/22 §4.5). */
+export interface SessionPatch {
+ title?: string;
+ pinned?: boolean;
+ personaId?: string;
+}
+
+/** PATCH /api/v1/sessions/{id} — updates a session's display metadata and/or its
+ * bound role. The reply echoes only the fields that were changed. */
+export function patchSession(
+ sessionId: string,
+ patch: SessionPatch
+): Promise<{ id: string } & SessionPatch> {
+ return request<{ id: string } & SessionPatch>(
+ `/sessions/${encodeURIComponent(sessionId)}`,
+ { method: "PATCH", body: JSON.stringify(patch) }
+ );
+}
+
/** GET /api/v1/sessions/{id}/messages?before=&limit=&platform= — hot-paginated
* history, optionally filtered to one source platform (design/16 §4.4). The wire
* returns newest-first, but callers render chronologically (oldest first), so we
diff --git a/frontend/web/src/lib/persona.ts b/frontend/web/src/lib/persona.ts
index 35509bc..da9b108 100644
--- a/frontend/web/src/lib/persona.ts
+++ b/frontend/web/src/lib/persona.ts
@@ -1,4 +1,4 @@
-import type { PersonaRecord } from "./types";
+import type { PersonaRecord, SessionMeta } from "./types";
/** Neutral fallback avatar when a persona has no avatar of its own. Never another
* role's image — a persona without an avatar gets this neutral default. */
@@ -21,3 +21,52 @@ export function personaChatBackground(
): string | undefined {
return p?.chatBackground?.[orientation];
}
+
+/** The globally configured default role: the one flagged `isDefault`, else the
+ * first the catalog lists (an older registry may flag none), else none at all —
+ * in which case the UI keeps its neutral placeholder. */
+export function defaultPersona(personas: PersonaRecord[]): PersonaRecord | null {
+ return personas.find((p) => p.isDefault) ?? personas[0] ?? null;
+}
+
+/** Which role does `sessionId` talk to (design/22 §4.6)?
+ *
+ * Session-bound role wins; an empty/absent `personaId` — and a binding whose
+ * role is no longer in the catalog (deleted persona, or a catalog we have not
+ * loaded yet) — falls back to the global default. This is the ONE resolution
+ * point: the avatar, the wallpaper, the name, the mood and the message labels
+ * all read the value it returns, so no two of them can disagree about "who is
+ * speaking" (design/22 §5 risk row 1). */
+export function resolveSessionPersona(
+ sessionId: string,
+ sessions: Record,
+ personas: PersonaRecord[]
+): PersonaRecord | null {
+ const boundId = sessionId ? sessions[sessionId]?.personaId ?? "" : "";
+ const bound = boundId ? personas.find((p) => p.id === boundId) : undefined;
+ return bound ?? defaultPersona(personas);
+}
+
+/** Who spoke one message (design/22 §7-5), and whether the session has since moved
+ * to a different role — the display half of "切换角色后旧消息仍属于旧角色".
+ *
+ * The message's own `personaId` wins, so a role switch cannot retroactively
+ * re-attribute turns that were already sent. A blank id (`''` = "inherit the
+ * current default role", the migration's meaning), an absent id (a client-side
+ * optimistic bubble, or an older backend) and an id that is not in the catalog
+ * (role deleted, catalog still loading) all fall back to the role the session
+ * speaks as now — never a name we cannot resolve. `switched` is true only when the
+ * message's role is known AND differs from the session's current one; that is the
+ * only case worth labelling, since repeating the session's own role on every
+ * bubble is noise. */
+export function resolveMessagePersona(
+ personaId: string | undefined,
+ personas: PersonaRecord[],
+ active: PersonaRecord | null
+): { persona: PersonaRecord | null; switched: boolean } {
+ const spoken = personaId ? personas.find((p) => p.id === personaId) ?? null : null;
+ return {
+ persona: spoken ?? active,
+ switched: Boolean(spoken && active && spoken.id !== active.id),
+ };
+}
diff --git a/frontend/web/src/lib/sessionPersona.ts b/frontend/web/src/lib/sessionPersona.ts
new file mode 100644
index 0000000..2be2936
--- /dev/null
+++ b/frontend/web/src/lib/sessionPersona.ts
@@ -0,0 +1,50 @@
+/**
+ * Re-binding one session to another role (design/22 §4.6).
+ *
+ * Picking a role is a two-step write: the local session list is updated first so
+ * the avatar / name / wallpaper / mood switch at once, then `PATCH
+ * /api/v1/sessions/{id}` persists it. A rejected request must not leave the UI
+ * showing a role the server never accepted, so the optimistic write is rolled
+ * back on failure — otherwise the next `GET /sessions` would silently snap every
+ * consumer back and the user would be looking at a lie in the meantime.
+ *
+ * The store and the REST call arrive as `io` rather than being imported here, so
+ * the ordering (write → persist → maybe roll back) is a plain function that can
+ * be driven by a logic test with fakes — this project ships no test runner.
+ */
+
+export interface SessionPersonaIO {
+ /** The locally-known bound role id for a session ("" = inherits the default). */
+ read: (sessionId: string) => string;
+ /** Write the locally-known bound role id (the optimistic and rollback write). */
+ write: (sessionId: string, personaId: string) => void;
+ /** Persist the binding remotely; the returned promise rejects on failure. */
+ persist: (sessionId: string, personaId: string) => Promise;
+}
+
+/** Bind `sessionId` to `personaId` ("" = inherit the default role).
+ *
+ * Resolves true when the server accepted it — including the no-op case where the
+ * session is already bound to that role, which fires no request at all. Resolves
+ * false when the session id is empty or the request failed (the local value is
+ * then restored, so the store and the server list agree again). */
+export async function setSessionPersona(
+ io: SessionPersonaIO,
+ sessionId: string,
+ personaId: string
+): Promise {
+ if (!sessionId) return false;
+ const previous = io.read(sessionId);
+ if (previous === personaId) return true;
+ io.write(sessionId, personaId);
+ try {
+ await io.persist(sessionId, personaId);
+ return true;
+ } catch {
+ // Roll back only while our optimistic value is still the one on screen: a
+ // second pick that landed meanwhile owns its own request and its own answer,
+ // and reverting it here would clobber a newer choice with a stale value.
+ if (io.read(sessionId) === personaId) io.write(sessionId, previous);
+ return false;
+ }
+}
diff --git a/frontend/web/src/lib/types.ts b/frontend/web/src/lib/types.ts
index 5d387eb..25bf2bb 100644
--- a/frontend/web/src/lib/types.ts
+++ b/frontend/web/src/lib/types.ts
@@ -93,6 +93,16 @@ export interface ChatMessage {
* design/16 §4.6). Blank for the owner's private chat, so present only when a
* non-owner is speaking; the recipient renders it as the sender label. */
senderName?: string;
+ /** The role that produced this message (design/22 §7-5, `chat_messages
+ * .persona_id`, served by the message DTO). A session may switch roles without
+ * losing its context, so the authoring role is recorded per turn: a historical
+ * bubble keeps the role of *its own* turn instead of being re-attributed to the
+ * role the session speaks as now.
+ *
+ * Absent on a client-side optimistic bubble and on an older backend; `''` means
+ * "inherit the current global default role". Consumers fall back to the active
+ * session's role only in those cases — never to a name they cannot resolve. */
+ personaId?: string;
createdAt: number;
}
@@ -113,6 +123,11 @@ export interface SessionMeta {
* 协同 B3/D4). Optional so an older backend stays compatible — absent means
* "unknown", which the UI and the read path both treat as 0. */
unreadCount?: number;
+ /** The role this session talks to (design/22 §2 option (a): the session is the
+ * single source of truth). `''`/absent means "inherit the global default role",
+ * which is the pre-multi-persona behaviour, so an older backend keeps working
+ * unchanged. Resolved in `lib/persona.ts#resolveSessionPersona`. */
+ personaId?: string;
}
export type AgentStatus =
diff --git a/frontend/web/src/lib/ws.ts b/frontend/web/src/lib/ws.ts
index ec702af..a63aa84 100644
--- a/frontend/web/src/lib/ws.ts
+++ b/frontend/web/src/lib/ws.ts
@@ -225,8 +225,15 @@ export function dispatchFrame(ev: { type: string; sessionId?: string; data: unkn
break;
}
case "mood": {
- if (typeof ev.data === "string") s.setMood(ev.data);
- else if (r && typeof r.mood === "string") s.setMood(r.mood);
+ // Role-scoped (design/22 §4.6): the frame names the persona the change
+ // belongs to and carries no sessionId, so the bucket key is personaId —
+ // each role's mood stays with its own conversations. A legacy string frame
+ // has nowhere to put it, so it lands in the unscoped "" bucket.
+ if (typeof ev.data === "string") {
+ s.setMood(ev.data);
+ } else if (r && typeof r.mood === "string") {
+ s.setMood(r.mood, typeof r.personaId === "string" ? r.personaId : "");
+ }
break;
}
case "tool_used": {
diff --git a/frontend/web/src/modes/ChatShell.tsx b/frontend/web/src/modes/ChatShell.tsx
index e29c8eb..067c1da 100644
--- a/frontend/web/src/modes/ChatShell.tsx
+++ b/frontend/web/src/modes/ChatShell.tsx
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from "react";
-import { useAppStore } from "../store/useAppStore";
+import { useAppStore, useCurrentPersona } from "../store/useAppStore";
import { useModeStore } from "../store/useModeStore";
import { useSendChat } from "../lib/useChat";
import { sendUp } from "../lib/ws";
@@ -18,7 +18,8 @@ import { personaChatBackground, type Orientation } from "../lib/persona";
*/
export function ChatShell(): React.ReactNode {
const messages = useAppStore((s) => s.messages);
- const currentPersona = useAppStore((s) => s.currentPersona);
+ // The chat wallpaper follows the *active session's* role (design/22 §4.6).
+ const currentPersona = useCurrentPersona();
const { send, sending } = useSendChat();
const bottomRef = useRef(null);
diff --git a/frontend/web/src/store/personaSession.test.ts b/frontend/web/src/store/personaSession.test.ts
new file mode 100644
index 0000000..5c59d47
--- /dev/null
+++ b/frontend/web/src/store/personaSession.test.ts
@@ -0,0 +1,254 @@
+// Logic test for per-session role selection (design/22 §4.6/§7, batch 1 frontend):
+// the role the UI renders is *derived* from the active session — never a global
+// value — an unbound (empty/absent) `personaId` falls back to the default role, a
+// re-bind is written locally first and rolled back when the PATCH is rejected, and
+// the role-scoped `mood` frame lands in its own role's bucket. The project has no
+// frontend test runner and no jsdom, so this is a dependency-free plain-node check
+// that drives the real resolver (lib/persona.ts), the real re-bind helper
+// (lib/sessionPersona.ts), the real WS frame router (lib/ws.ts) and the real
+// store, and throws 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/personaSession.test.ts \
+// --bundle --platform=node --format=esm \
+// --banner:js='globalThis.window={localStorage:{getItem:()=>null,setItem:()=>{},removeItem:()=>{}}};' \
+// --outfile=/tmp/cyrene-persona-session.test.mjs \
+// && node /tmp/cyrene-persona-session.test.mjs
+//
+// `tsc --noEmit` (part of `npm run build`) type-checks this file, which is why it
+// imports nothing from node:*.
+
+import { resolveMessagePersona, resolveSessionPersona } from "../lib/persona";
+import { setSessionPersona, type SessionPersonaIO } from "../lib/sessionPersona";
+import { dispatchFrame } from "../lib/ws";
+import { selectCurrentPersona, useAppStore } from "./useAppStore";
+import type { PersonaRecord, SessionMeta } from "../lib/types";
+
+let checks = 0;
+
+function ok(cond: boolean, what: string): void {
+ checks += 1;
+ if (!cond) throw new Error(`FAILED: ${what}`);
+}
+
+function same(got: T, want: T, what: string): void {
+ ok(
+ JSON.stringify(got) === JSON.stringify(want),
+ `${what} — got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`
+ );
+}
+
+const MAIN = "web";
+const SUB_A = "web:sub-a";
+const SUB_B = "web:sub-b";
+
+const PERSONAS: PersonaRecord[] = [
+ { id: "cyrene", name: "昔涟", character: "cyrene", isDefault: true, mode: "chat", memoryNamespace: "persona.cyrene" },
+ { id: "yuki", name: "雪", character: "yuki", isDefault: false, mode: "chat", memoryNamespace: "persona.yuki" },
+];
+
+/** Three sessions: A bound to role Y, B explicitly unbound, main with no field at
+ * all (the shape an older backend returns). */
+function sessionFixtures(): Record {
+ 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: "" },
+ };
+}
+
+const fixtures = sessionFixtures();
+
+/** 1. The rendered role follows the ACTIVE session (design/22 §4.6/§7-5). */
+function testResolutionFollowsActiveSession(): void {
+ same(resolveSessionPersona(SUB_A, fixtures, PERSONAS)?.id, "yuki", "session A resolves role Y");
+ same(resolveSessionPersona(SUB_B, fixtures, PERSONAS)?.id, "cyrene", "session B resolves the default role");
+ same(resolveSessionPersona(MAIN, fixtures, PERSONAS)?.id, "cyrene", "a session without personaId resolves the default");
+ same(resolveSessionPersona("", fixtures, PERSONAS)?.id, "cyrene", "no active session resolves the default");
+
+ // …and the store selector is that same answer, so the avatar/name/wallpaper
+ // cannot disagree with the picker.
+ useAppStore.setState({ personas: PERSONAS, sessions: fixtures, activeSessionId: SUB_A });
+ same(selectCurrentPersona(useAppStore.getState())?.name, "雪", "active session A renders role Y");
+ useAppStore.setState({ activeSessionId: SUB_B });
+ same(selectCurrentPersona(useAppStore.getState())?.name, "昔涟", "switching to B follows its own role");
+ useAppStore.setState({ activeSessionId: SUB_A });
+ same(selectCurrentPersona(useAppStore.getState())?.name, "雪", "switching back follows A's role again");
+ useAppStore.setState({ activeSessionId: "" });
+ same(selectCurrentPersona(useAppStore.getState())?.name, "昔涟", "still loading / no session falls back to the default");
+}
+
+/** 2. An empty binding — and one we cannot resolve — falls back to the default. */
+function testFallbackToDefaultRole(): void {
+ const deleted: Record = {
+ [SUB_A]: { id: SUB_A, type: "sub", createdAt: 2, personaId: "gone" },
+ };
+ same(resolveSessionPersona(SUB_A, deleted, PERSONAS)?.id, "cyrene", "a binding to a deleted role falls back to the default");
+ same(resolveSessionPersona(SUB_A, deleted, []), null, "an empty catalog resolves nothing (neutral placeholder)");
+
+ // A catalog that flags no default still yields the first listed role.
+ const noFlag: PersonaRecord[] = [{ ...PERSONAS[1], isDefault: false }];
+ same(resolveSessionPersona(MAIN, fixtures, noFlag)?.id, "yuki", "no isDefault flag falls back to the first role");
+}
+
+/** 3. A re-bind writes locally first, then persists; a rejection restores it. */
+async function testOptimisticRebind(): Promise {
+ const calls: string[] = [];
+ const state: Record = { [SUB_A]: "yuki" };
+ const io: SessionPersonaIO = {
+ read: (id) => state[id] ?? "",
+ write: (id, personaId) => {
+ state[id] = personaId;
+ calls.push(`write ${id}=${personaId}`);
+ },
+ persist: (id, personaId) => {
+ calls.push(`persist ${id}=${personaId}`);
+ return Promise.resolve();
+ },
+ };
+
+ same(await setSessionPersona(io, SUB_A, "cyrene"), true, "an accepted re-bind resolves true");
+ same(state[SUB_A], "cyrene", "local binding after a successful PATCH");
+ same(
+ calls,
+ [`write ${SUB_A}=cyrene`, `persist ${SUB_A}=cyrene`],
+ "the local write happens before the request (no request would mean no UI change)"
+ );
+
+ // Rejection: the local value must go back, or the UI would show a role the
+ // server never accepted until the next GET /sessions corrects it.
+ const failedCalls: string[] = [];
+ const failedState: Record = { [SUB_A]: "yuki" };
+ const failing: SessionPersonaIO = {
+ read: (id) => failedState[id] ?? "",
+ write: (id, personaId) => {
+ failedState[id] = personaId;
+ failedCalls.push(`write ${id}=${personaId}`);
+ },
+ persist: (id, personaId) => {
+ failedCalls.push(`persist ${id}=${personaId}`);
+ return Promise.reject(new Error("patch failed"));
+ },
+ };
+ same(await setSessionPersona(failing, SUB_A, "cyrene"), false, "a rejected re-bind resolves false");
+ same(failedState[SUB_A], "yuki", "local binding restored after a failed PATCH");
+ same(
+ failedCalls,
+ [`write ${SUB_A}=cyrene`, `persist ${SUB_A}=cyrene`, `write ${SUB_A}=yuki`],
+ "the rollback writes the previous value back"
+ );
+
+ // Unchanged binding: no write, no request at all.
+ let writes = 0;
+ let persists = 0;
+ const noop: SessionPersonaIO = {
+ read: () => "yuki",
+ write: () => {
+ writes += 1;
+ },
+ persist: () => {
+ persists += 1;
+ return Promise.resolve();
+ },
+ };
+ same(await setSessionPersona(noop, SUB_A, "yuki"), true, "re-picking the bound role resolves true");
+ same([writes, persists], [0, 0], "re-picking the bound role fires no write and no request");
+ same(await setSessionPersona(noop, "", "yuki"), false, "an empty session id is refused");
+
+ // A second pick that lands while the first request is in flight owns the value:
+ // the older failure must not revert it.
+ let rejectFirst: (err: Error) => void = () => {};
+ let firstPersist = true;
+ const state2: Record = { [SUB_A]: "yuki" };
+ const racing: SessionPersonaIO = {
+ read: (id) => state2[id] ?? "",
+ write: (id, personaId) => {
+ state2[id] = personaId;
+ },
+ persist: () =>
+ firstPersist
+ ? new Promise((_, reject) => {
+ firstPersist = false;
+ rejectFirst = reject;
+ })
+ : Promise.resolve(),
+ };
+ const first = setSessionPersona(racing, SUB_A, "cyrene");
+ const second = setSessionPersona(racing, SUB_A, "yuki");
+ rejectFirst(new Error("patch failed"));
+ same(await first, false, "the superseded request still reports its failure");
+ same(await second, true, "the newer pick is accepted");
+ same(state2[SUB_A], "yuki", "a stale failure does not revert the newer choice");
+}
+
+/** 4. The store's optimistic write re-resolves the rendered role at once. */
+function testStoreRebind(): void {
+ useAppStore.setState({ personas: PERSONAS, sessions: sessionFixtures(), activeSessionId: SUB_B });
+ useAppStore.getState().setSessionPersona(SUB_B, "yuki");
+ same(selectCurrentPersona(useAppStore.getState())?.id, "yuki", "the optimistic re-bind re-resolves the rendered role");
+ same(useAppStore.getState().sessions[SUB_B].personaId, "yuki", "the session list carries the new binding");
+
+ const after = useAppStore.getState().sessions;
+ useAppStore.getState().setSessionPersona("web:missing", "yuki");
+ ok(useAppStore.getState().sessions === after, "an unknown session id is a no-op (map untouched)");
+ same(
+ Object.keys(useAppStore.getState().sessions).sort(),
+ [MAIN, SUB_A, SUB_B].sort(),
+ "an unknown session id cannot invent a row"
+ );
+ useAppStore.getState().setSessionPersona(SUB_A, "yuki");
+ ok(useAppStore.getState().sessions === after, "re-picking the already-bound role is a no-op (map untouched)");
+}
+
+/** 5. A message keeps the role that spoke it (design/22 §7-5): switching a
+ * session's role must not re-attribute turns that were already sent. */
+function testMessageAuthoringRole(): void {
+ const yuki = PERSONAS[1];
+ const cyrene = PERSONAS[0];
+ // The session speaks as Cyrene now; this reply was authored by Yuki.
+ const before = resolveMessagePersona("yuki", PERSONAS, cyrene);
+ same(before.persona?.id, "yuki", "a reply keeps its own authoring role");
+ same(before.switched, true, "…and is flagged as coming from another role");
+ // Same role as the session: no label worth showing (and no mis-attribution).
+ const same2 = resolveMessagePersona("cyrene", PERSONAS, cyrene);
+ same(same2.persona?.id, "cyrene", "a reply from the current role resolves to it");
+ same(same2.switched, false, "a reply from the current role is not flagged");
+ // '' (inherit the default) / absent / unknown all fall back to the session's
+ // role rather than inventing a name.
+ const blank = resolveMessagePersona("", PERSONAS, yuki);
+ same([blank.persona?.id, blank.switched], ["yuki", false], "an empty id falls back to the session's role");
+ const absent = resolveMessagePersona(undefined, PERSONAS, yuki);
+ same([absent.persona?.id, absent.switched], ["yuki", false], "an absent id falls back to the session's role");
+ const gone = resolveMessagePersona("gone", PERSONAS, yuki);
+ same([gone.persona?.id, gone.switched], ["yuki", false], "an unknown role id falls back to the session's role");
+}
+
+/** 6. `mood` frames are role-scoped: each role keeps its own mood. */
+function testMoodBuckets(): void {
+ useAppStore.setState({ moods: {}, personas: PERSONAS, sessions: sessionFixtures(), activeSessionId: SUB_A });
+ dispatchFrame({ type: "mood", data: { mood: "happy", personaId: "yuki" } });
+ same(useAppStore.getState().moods.yuki, "happy", "a role-scoped mood frame lands in that role's bucket");
+ dispatchFrame({ type: "mood", data: { mood: "sad", personaId: "cyrene" } });
+ same(useAppStore.getState().moods.cyrene, "sad", "another role's mood does not overwrite it");
+ dispatchFrame({ type: "mood", data: "calm" });
+ same(useAppStore.getState().moods[""], "calm", "an unscoped (legacy) mood frame keeps its own bucket");
+ useAppStore.getState().setMood(null, "yuki");
+ same(useAppStore.getState().moods.yuki, undefined, "a null mood clears only that role's bucket");
+ same(useAppStore.getState().moods[""], "calm", "clearing one bucket leaves the others alone");
+}
+
+async function run(): Promise {
+ testResolutionFollowsActiveSession();
+ testFallbackToDefaultRole();
+ await testOptimisticRebind();
+ testStoreRebind();
+ testMessageAuthoringRole();
+ testMoodBuckets();
+ console.log(`personaSession.test: ${checks} checks passed`);
+}
+
+void run();
diff --git a/frontend/web/src/store/useAppStore.ts b/frontend/web/src/store/useAppStore.ts
index 563831e..b87e5fd 100644
--- a/frontend/web/src/store/useAppStore.ts
+++ b/frontend/web/src/store/useAppStore.ts
@@ -13,6 +13,7 @@ import type {
ToolUsed,
WsStatus,
} from "../lib/types";
+import { resolveSessionPersona } from "../lib/persona";
import { now, uid } from "../lib/util";
interface ChatContext {
@@ -32,7 +33,12 @@ export interface AppStore {
/** Messages of the *active* session, in display order (oldest first). Reset on
* session switch and refilled from the backend (design/16 §4/§5). */
messages: ChatMessage[];
- mood: string | null;
+ /** Latest mood per role id (design/22 §4.6). The `mood` WS event is
+ * 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). */
+ moods: Record;
context: ChatContext;
agents: AgentInfo[];
backthink: BackthinkItem[];
@@ -53,16 +59,27 @@ export interface AppStore {
/** Push one `channel_persisted` message (newest first, dedup by id, capped ~200). */
pushChannelEvent: (msg: AdminChannelMessage) => void;
- /** 当前 persona(web 主会话=默认 persona)。加载前/失败时为 null,头像回退中性默认占位。 */
- currentPersona: PersonaRecord | null;
+ /** The role catalog from `GET /personas` (design/22 §4.6). The role the UI
+ * renders is *derived* from it plus the active session — never stored — so the
+ * picker, the avatar and the mood cannot drift apart. Empty until loaded (or
+ * when the fetch fails), and the UI then keeps its neutral placeholder. */
+ personas: PersonaRecord[];
+ /** Replace the role catalog (`GET /personas`). */
+ setPersonas: (list: PersonaRecord[]) => void;
setWsStatus: (status: WsStatus) => void;
- setCurrentPersona: (p: PersonaRecord | null) => void;
setContext: (patch: Partial) => void;
- setMood: (mood: string | null) => void;
+ /** 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;
/** Replace the session metadata list. */
setSessions: (list: SessionMeta[]) => void;
+ /** Re-bind one session to a role locally — the optimistic write and the rollback
+ * target of `lib/sessionPersona.ts#setSessionPersona`. `""` re-binds it to
+ * "inherit the default role". A no-op for an unknown session or one already
+ * bound to that role. */
+ setSessionPersona: (sessionId: string, personaId: string) => void;
/** Zero one session's unread badge locally (Android 协同 B3/D4). Called when the
* user opens the session and its page has loaded, and on the `session_read`
* frame another end broadcasts — a no-op for an already-read or unknown
@@ -121,7 +138,7 @@ export const useAppStore = create()(
activeSessionId: "",
wsStatus: "idle",
messages: [],
- mood: null,
+ moods: {},
context: {},
agents: [
{ id: "main", name: "昔涟", status: "idle", role: "owner" },
@@ -134,20 +151,34 @@ export const useAppStore = create()(
awakenTool: null,
sessionBusy: false,
pendingCount: 0,
- currentPersona: null,
+ personas: [],
taskLogs: {},
channelEvents: [],
setWsStatus: (status) => set({ wsStatus: status }),
- setCurrentPersona: (p) => set({ currentPersona: p }),
+ setPersonas: (list) => set({ personas: list }),
setContext: (patch) =>
set((s) => ({ context: { ...s.context, ...patch } })),
- setMood: (mood) => set({ mood }),
+ setMood: (mood, personaId = "") =>
+ set((s) => {
+ const moods = { ...s.moods };
+ if (mood) moods[personaId] = mood;
+ else delete moods[personaId];
+ return { moods };
+ }),
setSessions: (list) =>
set({
sessions: Object.fromEntries(list.map((s) => [s.id, s])),
}),
+ setSessionPersona: (sessionId, personaId) =>
+ set((s) => {
+ const meta = s.sessions[sessionId];
+ if (!meta || (meta.personaId ?? "") === personaId) return s;
+ return {
+ sessions: { ...s.sessions, [sessionId]: { ...meta, personaId } },
+ };
+ }),
setActiveSession: (id) => set({ activeSessionId: id, messages: [] }),
clearUnread: (sessionId) =>
set((s) => {
@@ -203,6 +234,7 @@ export const useAppStore = create()(
image: msg.image ?? m.image,
platform: msg.platform ?? m.platform,
deviceId: msg.deviceId ?? m.deviceId,
+ personaId: msg.personaId ?? m.personaId,
createdAt: msg.createdAt ?? m.createdAt,
};
return { messages };
@@ -369,7 +401,7 @@ export const useAppStore = create()(
resetChat: () =>
set((s) => ({
messages: [],
- mood: null,
+ moods: {},
flowGraph: null,
backthink: s.backthink,
awakenState: "quiet",
@@ -381,38 +413,63 @@ export const useAppStore = create()(
}),
{
name: "cyrene.app",
- version: 2,
- // Upgrade from v1 (which persisted a full `messages` array + `sessionId`):
- // drop those — the backend is now the message truth — and carry forward the
- // still-meaningful bits (persona avatar, mood, context, backthink).
- migrate: (persisted) => {
+ version: 3,
+ // v1 persisted the whole chat (`messages` + a single `sessionId`); v2 added
+ // one resolved `currentPersona`, which can no longer describe the active role
+ // now that the role belongs to a *session*. Both shapes are dropped:
+ // `personas` is refilled from `GET /personas` and the role is re-derived from
+ // the session list. `moods` is transient by nature (a restart resets a role's
+ // mood), so it is not carried across either. A v1 state was never trusted for
+ // the session metadata, so those users simply restart on the main session.
+ migrate: (persisted, version) => {
const p = (persisted ?? {}) as Partial;
+ const fromV1 = version < 2;
return {
- activeSessionId: "",
- sessions: {},
- mood: p.mood ?? null,
+ activeSessionId: fromV1 ? "" : p.activeSessionId ?? "",
+ sessions: fromV1 ? {} : p.sessions ?? {},
+ personas: p.personas ?? [],
+ moods: {},
context: p.context ?? {},
backthink: p.backthink ?? [],
- currentPersona: p.currentPersona ?? null,
};
},
partialize: (s) => ({
// messages deliberately excluded: the backend is the message truth
// (design/16 §1), so we never persist the whole chat into localStorage.
// We keep the (small) session metadata + the focused session id so a
- // refresh lands on the same conversation, plus the resolved persona for
- // the avatar/background.
+ // refresh lands on the same conversation, plus the role catalog so the
+ // avatar/name/wallpaper resolve before the first fetch returns.
activeSessionId: s.activeSessionId,
sessions: s.sessions,
- mood: s.mood,
+ personas: s.personas,
+ moods: s.moods,
context: s.context,
backthink: s.backthink,
- currentPersona: s.currentPersona,
}),
}
)
);
+/** 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];