diff --git a/frontend/web/src/App.tsx b/frontend/web/src/App.tsx index 64d59c6..8c240cc 100644 --- a/frontend/web/src/App.tsx +++ b/frontend/web/src/App.tsx @@ -22,7 +22,7 @@ export default function App(): React.ReactNode { // when we hold a token — otherwise the gateway rejects the handshake with 401. const [route, setRoute] = useState(() => parseAdminHash(window.location.hash)); const [menuOpen, setMenuOpen] = useState(false); - const setCurrentPersona = useAppStore((s) => s.setCurrentPersona); + const setPersonas = useAppStore((s) => s.setPersonas); useEffect(() => { initTheme(); @@ -35,13 +35,14 @@ export default function App(): React.ReactNode { }; }, []); - // Load personas and pick the default one as the web main-session persona. This - // must not hinge on a single mount-time fetch: a token may appear (login) after - // mount, or the fetch may transiently fail — in which case currentPersona would - // stay null and the UI would show a neutral placeholder (or worse, silently - // persist a null). We re-run whenever the token appears, and retry on failure. - // The resolved persona is also persisted by useAppStore so a refresh keeps the - // correct avatar/background. + // Load the role catalog (design/22 §4.6). The rendered role is no longer one + // global value: it is derived per active session from this catalog plus that + // session's own `personaId` (see useAppStore's selectCurrentPersona), so this + // fetch only has to fill the catalog. It must not hinge on a single mount-time + // request: a token may appear (login) after mount, or the fetch may transiently + // fail — in which case the avatar/name would stay on the neutral placeholder. We + // re-run when the token appears, and retry on failure. The catalog is persisted + // by useAppStore, so a refresh renders the right role before the fetch returns. const token = getAuthToken(); useEffect(() => { if (!token) return; @@ -53,7 +54,7 @@ export default function App(): React.ReactNode { fetchPersonas() .then((list) => { if (cancelled) return; - setCurrentPersona(list.find((p) => p.isDefault) ?? list[0] ?? null); + setPersonas(list); }) .catch(() => { // Transient failure — retry rather than leaving the placeholder forever. @@ -66,7 +67,7 @@ export default function App(): React.ReactNode { cancelled = true; if (retry !== null) window.clearTimeout(retry); }; - }, [token, setCurrentPersona]); + }, [token, setPersonas]); const sessions = useAppStore((s) => s.sessions); const activeSessionId = useAppStore((s) => s.activeSessionId); diff --git a/frontend/web/src/admin/modules/Sessions.tsx b/frontend/web/src/admin/modules/Sessions.tsx index 46a1542..eff7f59 100644 --- a/frontend/web/src/admin/modules/Sessions.tsx +++ b/frontend/web/src/admin/modules/Sessions.tsx @@ -1,7 +1,12 @@ import { useState } from "react"; import { useAsyncData } from "../../lib/useAsyncData"; -import { adminListSessions, createSubSession } from "../../lib/api"; -import type { SessionMeta } from "../../lib/types"; +import { + adminListPersonas, + adminListSessions, + createSubSession, + patchSession, +} from "../../lib/api"; +import type { PersonaRecord, SessionMeta } from "../../lib/types"; import { useAutoReload } from "../hooks"; import { useAuditStore } from "../audit"; import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components"; @@ -17,14 +22,130 @@ function exportMeta(sess: SessionMeta): void { URL.revokeObjectURL(url); } -export function SessionsModule(): React.ReactNode { - const { data, loading, error, reload } = useAsyncData(adminListSessions, []); - useAutoReload(reload, 10000); - const [query, setQuery] = useState(""); +/** The role cell of one row: the session's bound role, editable in place. An id + * that is not in the catalog (role deleted, catalog still loading) keeps its own + * option, so the cell never silently reads as "inherit the default". */ +function PersonaCell({ + session, + personas, + disabled, + onChange, +}: { + session: SessionMeta; + personas: PersonaRecord[]; + disabled: boolean; + onChange: (personaId: string) => void; +}): React.ReactNode { + const bound = session.personaId ?? ""; + const unknown = bound !== "" && !personas.some((p) => p.id === bound); + return ( + + ); +} + +/** The session table's columns. Built per render because two of the cells are + * wired to the module's live state (the busy flag, the role catalog). */ +function buildColumns(opts: { + personas: PersonaRecord[]; + busy: string | null; + onChangePersona: (sessionId: string, personaId: string) => void; +}): AdminColumn[] { + const { personas, busy, onChangePersona } = opts; + return [ + { key: "type", title: "类型", render: (s) => {s.type === "main" ? "主会话" : "次级会话"} }, + { key: "title", title: "标题", render: (s) => {s.title ? s.title : (未命名)} }, + { key: "id", title: "会话 ID", render: (s) => {s.id} }, + { + key: "persona", + title: "角色", + render: (s) => ( + onChangePersona(s.id, personaId)} + /> + ), + }, + { key: "createdAt", title: "创建时间", render: (s) => {new Date(s.createdAt).toLocaleString()} }, + { + key: "actions", + title: "操作", + render: (s) => ( + + ), + }, + ]; +} + +/** The page's two write actions, both audited. The list is re-read on success — + * the PATCH reply is not a full session row — which also refreshes the row from + * server truth instead of a local guess. */ +function useSessionMutations(reload: () => void): { + busy: string | null; + fail: string | null; + newSubSession: () => void; + changePersona: (sessionId: string, personaId: string) => void; +} { const [busy, setBusy] = useState(null); const [fail, setFail] = useState(null); const log = useAuditStore((s) => s.log); + const run = ( + busyKey: string, + action: string, + call: () => Promise, + fallback: string + ) => { + setBusy(busyKey); + setFail(null); + call() + .then(() => { + log(action, "ok"); + reload(); + }) + .catch((e: unknown) => { + log(action, "error"); + setFail(e instanceof Error ? e.message : fallback); + }) + .finally(() => setBusy(null)); + }; + + return { + busy, + fail, + newSubSession: () => + run("new", "session.create", () => createSubSession(), "新建次级会话失败"), + changePersona: (sessionId, personaId) => + run(sessionId, "session.persona", () => patchSession(sessionId, { personaId }), "挂角色失败"), + }; +} + +export function SessionsModule(): React.ReactNode { + const { data, loading, error, reload } = useAsyncData(adminListSessions, []); + useAutoReload(reload, 10000); + // The role catalog for the "挂角色" column (design/22 §4.6); the panel reads it + // from the admin endpoint, and the write goes through the ordinary session PATCH. + const personas = useAsyncData(adminListPersonas, []); + const [query, setQuery] = useState(""); + const { busy, fail, newSubSession, changePersona } = useSessionMutations(reload); + const filtered = (data ?? []).filter((s) => { if (!query.trim()) return true; const q = query.toLowerCase(); @@ -35,44 +156,11 @@ export function SessionsModule(): React.ReactNode { ); }); - const newSubSession = () => { - setBusy("new"); - setFail(null); - createSubSession() - .then(() => { - log("session.create", "ok"); - reload(); - }) - .catch((e: unknown) => { - log("session.create", "error"); - setFail(e instanceof Error ? e.message : "新建次级会话失败"); - }) - .finally(() => setBusy(null)); - }; - - const columns: AdminColumn[] = [ - { key: "type", title: "类型", render: (s) => {s.type === "main" ? "主会话" : "次级会话"} }, - { key: "title", title: "标题", render: (s) => {s.title ? s.title : (未命名)} }, - { - key: "id", - title: "会话 ID", - render: (s) => {s.id}, - }, - { - key: "createdAt", - title: "创建时间", - render: (s) => {new Date(s.createdAt).toLocaleString()}, - }, - { - key: "actions", - title: "操作", - render: (s) => ( - - ), - }, - ]; + const columns: AdminColumn[] = buildColumns({ + personas: personas.data ?? [], + busy, + onChangePersona: changePersona, + }); return (
@@ -112,6 +200,8 @@ export function SessionsModule(): React.ReactNode { 列表来自后端持久化会话(主会话 + 次级会话),主会话 id 固定。导出为会话元数据 JSON;身份判定永远由 actor 元数据 + 白名单 + 策略引擎完成,不依赖提示词。 + 「角色」列是会话挂载的角色(design/22 §4.6):「继承默认角色」= 会话不绑定,随全局默认角色走; + 改角色决定该会话记忆/情绪/提示词的归属(owner-only + 审计)。
); diff --git a/frontend/web/src/components/Nav.tsx b/frontend/web/src/components/Nav.tsx index 6a45ca6..6d5a4ba 100644 --- a/frontend/web/src/components/Nav.tsx +++ b/frontend/web/src/components/Nav.tsx @@ -1,15 +1,20 @@ import { useState } from "react"; import { applyTheme, readTheme, type ThemeMode } from "../lib/theme"; -import { createSubSession, getAuthToken } from "../lib/api"; +import { getAuthToken } from "../lib/api"; import { labelFor } from "../lib/enums"; -import { useAppStore } from "../store/useAppStore"; -import { Icon, type IconName } from "./Icon"; +import { personaAvatar } from "../lib/persona"; +import { useAppStore, useCurrentMood, useCurrentPersona } from "../store/useAppStore"; +import { Icon } from "./Icon"; +import { NavItem } from "./NavItem"; +import { SessionRail } from "./SessionRail"; import { Tag } from "./primitives"; /** * Left rail — NOT a behavior-mode list (design/01 §3: mode switching is driven * at runtime by the agent / user, and rendered per message). This is a small - * navigation rail: brand + connection status + theme + admin/settings entry. + * navigation rail: brand + connection status + the conversation list + * (`SessionRail`, which also owns per-session role picking, design/22 §4.6) + + * theme + admin/settings entry. * * Two surface variants share the same NavContent: * - Sidebar (desktop): fixed rail, `hidden md:flex`. @@ -20,13 +25,11 @@ import { Tag } from "./primitives"; function NavContent({ onNavigate }: { onNavigate: () => void }): React.ReactNode { const wsStatus = useAppStore((s) => s.wsStatus); - const mood = useAppStore((s) => s.mood); - const sessions = useAppStore((s) => s.sessions); - const activeSessionId = useAppStore((s) => s.activeSessionId); - const setSessions = useAppStore((s) => s.setSessions); - const setActiveSession = useAppStore((s) => s.setActiveSession); + // The mood and the avatar/name belong to the *active session's* role (design/22 + // §4.6): switching sessions switches the role the rail speaks for. + const mood = useCurrentMood(); + const currentPersona = useCurrentPersona(); const [theme, setTheme] = useState(readTheme); - const [creating, setCreating] = useState(false); const connected = wsStatus === "connected"; const wsLabel = connected ? "实时连接" : wsStatus === "expired" ? "登录过期" : "未连接"; const hasToken = !!getAuthToken(); @@ -37,42 +40,17 @@ function NavContent({ onNavigate }: { onNavigate: () => void }): React.ReactNode setTheme(next); }; - // Main session pinned first; secondary sessions follow by creation time. - const sessionList = Object.values(sessions).sort((a, b) => { - if (a.type !== b.type) return a.type === "main" ? -1 : 1; - return a.createdAt - b.createdAt; - }); - - const selectSession = (id: string) => { - setActiveSession(id); - onNavigate(); - }; - - const createSession = async () => { - if (creating) return; - setCreating(true); - try { - const meta = await createSubSession(); - // Optimistically append so the new session shows immediately; the backend - // will return it on the next GET /sessions too. - const next = Object.values(useAppStore.getState().sessions); - setSessions([...next, ...(next.some((s) => s.id === meta.id) ? [] : [meta])]); - setActiveSession(meta.id); - onNavigate(); - } catch { - // Backend not wired yet — leave the list unchanged. - } finally { - setCreating(false); - } - }; - return ( <>
- Cyrene + {currentPersona?.name
Cyrene
-
昔涟
+
{currentPersona?.name ?? "昔涟"}
@@ -91,32 +69,9 @@ function NavContent({ onNavigate }: { onNavigate: () => void }): React.ReactNode ) : null} - {/* conversation sessions (design/16 §2) — main fixed, secondary created */} - {hasToken ? ( -
-
- 会话 -
- {sessionList.map((s) => ( - selectSession(s.id)} - /> - ))} - -
- ) : null} + {/* conversation sessions (design/16 §2 + design/22 §4.6) — main fixed, + secondary created, each row able to pick the role it talks to */} + {hasToken ? : null}
voi ); } -function NavItem({ - icon, - label, - onClick, - active = false, - hint, - badge = 0, -}: { - icon: IconName; - label: string; - onClick: () => void; - active?: boolean; - hint?: string; - /** Unread count (协同 B3/D4); 0 or absent renders no badge. */ - badge?: number; -}): React.ReactNode { - return ( - - ); -} diff --git a/frontend/web/src/components/NavItem.tsx b/frontend/web/src/components/NavItem.tsx new file mode 100644 index 0000000..5580b31 --- /dev/null +++ b/frontend/web/src/components/NavItem.tsx @@ -0,0 +1,53 @@ +import { Icon, type IconName } from "./Icon"; + +/** One rail row: icon + label + an optional unread badge, hint or trailing slot. + * Shared by the brand/utility rows in `Nav.tsx` and the conversation rows in + * `SessionRail.tsx`, so the two surfaces cannot drift apart. */ +export function NavItem({ + icon, + label, + onClick, + active = false, + hint, + badge = 0, + trailing, +}: { + icon: IconName; + label: string; + onClick: () => void; + active?: boolean; + hint?: string; + /** Unread count (协同 B3/D4); 0 or absent renders no badge. */ + badge?: number; + /** Optional control rendered after the label (e.g. a per-row menu button). */ + trailing?: React.ReactNode; +}): React.ReactNode { + return ( +
+ + {trailing} +
+ ); +} diff --git a/frontend/web/src/components/SessionRail.tsx b/frontend/web/src/components/SessionRail.tsx new file mode 100644 index 0000000..cc56bc4 --- /dev/null +++ b/frontend/web/src/components/SessionRail.tsx @@ -0,0 +1,276 @@ +import { useState } from "react"; +import { createSubSession, getAuthToken, patchSession } from "../lib/api"; +import { setSessionPersona } from "../lib/sessionPersona"; +import { useAppStore } from "../store/useAppStore"; +import type { PersonaRecord, SessionMeta } from "../lib/types"; +import { Icon } from "./Icon"; +import { NavItem } from "./NavItem"; +import { Button } from "./primitives"; + +/** + * The conversation list in the rail (design/16 §2 + design/22 §4.6): + * - main session first, secondary sessions by creation time, unread badges; + * - "新建会话" reveals a role picker, so a new conversation can start bound to a + * role (empty = inherit the global default role); + * - every row has a role menu that re-binds *that* session through + * `PATCH /sessions/{id} {personaId}` (design/22 §4.6). Changing a session's + * role changes which memory / emotion / system prompt the session talks to, so + * it stays an explicit, per-session action instead of a global switch. + * + * Kept out of `Nav.tsx` so no single component grows past the 80-line rule. + */ +export function SessionRail({ onNavigate }: { onNavigate: () => void }): React.ReactNode { + const sessions = useAppStore((s) => s.sessions); + const activeSessionId = useAppStore((s) => s.activeSessionId); + const setSessions = useAppStore((s) => s.setSessions); + const setActiveSession = useAppStore((s) => s.setActiveSession); + const personas = useAppStore((s) => s.personas); + const setSessionPersonaLocal = useAppStore((s) => s.setSessionPersona); + /** The session whose role menu is expanded, or null when none is open. */ + const [menuFor, setMenuFor] = useState(null); + /** Set when a role change was rejected, so the row can say so instead of + * silently showing the role the server still has. */ + const [failed, setFailed] = useState(false); + + if (!getAuthToken()) return null; + + // Main session pinned first; secondary sessions follow by creation time. + const sessionList = Object.values(sessions).sort((a, b) => { + if (a.type !== b.type) return a.type === "main" ? -1 : 1; + return a.createdAt - b.createdAt; + }); + + const openSession = (id: string) => { + setActiveSession(id); + onNavigate(); + }; + + /** Show a freshly created session at once and focus it. `POST /sessions` replies + * with the new id (plus the bound role when one was requested), not the summary, + * so the row is seeded from what we know and replaced by the server's summary on + * the next list read. */ + const adoptSession = (sessionId: string, personaId: string) => { + if (!sessionId) return; + const next = Object.values(useAppStore.getState().sessions); + const meta: SessionMeta = { id: sessionId, type: "sub", createdAt: Date.now(), personaId }; + setSessions(next.some((s) => s.id === sessionId) ? next : [...next, meta]); + setActiveSession(sessionId); + onNavigate(); + }; + + /** Re-bind one session to a role: written optimistically into the store and + * rolled back when the PATCH is rejected (lib/sessionPersona.ts), so the rail + * never keeps showing a binding the server refused. */ + const changePersona = async (sessionId: string, personaId: string) => { + setFailed(false); + const applied = await setSessionPersona( + { + read: (id) => useAppStore.getState().sessions[id]?.personaId ?? "", + write: setSessionPersonaLocal, + persist: (id, pid) => patchSession(id, { personaId: pid }), + }, + sessionId, + personaId + ); + if (applied) setMenuFor(null); + else setFailed(true); + }; + + return ( +
+
会话
+ {sessionList.map((s) => ( + openSession(s.id)} + onToggleMenu={() => { + setFailed(false); + setMenuFor(menuFor === s.id ? null : s.id); + }} + onChangePersona={(personaId) => void changePersona(s.id, personaId)} + /> + ))} + +
+ ); +} + +/** One conversation row plus its role menu. */ +function SessionEntry({ + session, + active, + menuOpen, + failed, + personas, + onOpen, + onToggleMenu, + onChangePersona, +}: { + session: SessionMeta; + active: boolean; + menuOpen: boolean; + failed: boolean; + personas: PersonaRecord[]; + onOpen: () => void; + onToggleMenu: () => void; + onChangePersona: (personaId: string) => void; +}): React.ReactNode { + const label = session.title ?? (session.type === "main" ? "主会话" : "次级会话"); + return ( +
+ + + + } + /> + {menuOpen ? ( +
+ 会话角色 + + {failed ? ( + 切换失败,已保持原角色 + ) : null} +
+ ) : null} +
+ ); +} + +/** The "new conversation" affordance: a plain button until opened, then a small + * panel with a role picker. Owns the create request; the rail only adopts the id + * it gets back (the reply carries no summary, docs/api-client.md §5). */ +function CreateSessionSection({ + personas, + onCreated, +}: { + personas: PersonaRecord[]; + onCreated: (sessionId: string, personaId: string) => void; +}): React.ReactNode { + const [open, setOpen] = useState(false); + const [creating, setCreating] = useState(false); + /** Role the next session is created with ("" = inherit the default role). */ + const [personaId, setPersonaId] = useState(""); + + const create = async () => { + if (creating) return; + setCreating(true); + try { + // The reply is the new id (docs/api-client.md §5); `id` covers a backend + // that echoes a full summary instead. Without either there is nothing to + // focus, so the picker stays open rather than closing on a silent no-op. + const created = await createSubSession({ personaId }); + const id = created.sessionId ?? created.id ?? ""; + if (!id) return; + onCreated(id, personaId); + setOpen(false); + setPersonaId(""); + } catch { + // Backend not wired yet — leave the list and the picker unchanged. + } finally { + setCreating(false); + } + }; + + if (!open) { + return ( + + ); + } + return ( +
+ 新会话角色 + +
+ + +
+
+ ); +} + +/** Miuix-styled role picker over `GET /personas` (design/22 §4.6). The empty + * option means "inherit the global default role" — the value a session with no + * binding has — so a session can always be returned to the default. A value that + * is not in the catalog (role deleted, catalog still loading) is shown as its own + * option rather than silently reading as "inherit". */ +function RoleSelect({ + value, + personas, + disabled, + label, + onChange, +}: { + value: string; + personas: PersonaRecord[]; + disabled?: boolean; + label: string; + onChange: (personaId: string) => void; +}): React.ReactNode { + const unknown = value !== "" && !personas.some((p) => p.id === value); + return ( + + ); +} diff --git a/frontend/web/src/components/TopBar.tsx b/frontend/web/src/components/TopBar.tsx index 92c6cff..73de3c3 100644 --- a/frontend/web/src/components/TopBar.tsx +++ b/frontend/web/src/components/TopBar.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { useAppStore } from "../store/useAppStore"; +import { useAppStore, useCurrentMood, useCurrentPersona } from "../store/useAppStore"; import { useModeStore } from "../store/useModeStore"; import { applyTheme, readTheme, type ThemeMode } from "../lib/theme"; import { personaAvatar } from "../lib/persona"; @@ -29,8 +29,11 @@ const wsMeta: Record void }): React.ReactNode { const wsStatus = useAppStore((s) => s.wsStatus); - const mood = useAppStore((s) => s.mood); - const currentPersona = useAppStore((s) => s.currentPersona); + // Both follow the *active session's* role (design/22 §4.6): switching sessions + // switches the avatar/name, and the mood is the one the active role reported + // (mood frames are role-scoped). + const mood = useCurrentMood(); + const currentPersona = useCurrentPersona(); const mode = useModeStore((s) => s.mode); const [theme, setTheme] = useState(readTheme); @@ -56,7 +59,7 @@ export function TopBar({ onMenu }: { onMenu: () => void }): React.ReactNode {
{currentPersona?.name
-
Cyrene · {currentPersona?.name ?? "昔涟"}
+
{currentPersona?.name ?? "昔涟"}
diff --git a/frontend/web/src/components/chat.tsx b/frontend/web/src/components/chat.tsx index c7be369..34a8934 100644 --- a/frontend/web/src/components/chat.tsx +++ b/frontend/web/src/components/chat.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; -import type { ChatMessage, Mode, ToolUsed } from "../lib/types"; -import { useAppStore } from "../store/useAppStore"; -import { personaAvatar } from "../lib/persona"; +import type { ChatMessage, Mode, PersonaRecord, ToolUsed } from "../lib/types"; +import { useAppStore, useCurrentPersona } from "../store/useAppStore"; +import { personaAvatar, resolveMessagePersona } from "../lib/persona"; import { labelFor } from "../lib/enums"; import { Tag } from "./primitives"; import { Icon, type IconName } from "./Icon"; @@ -147,6 +147,27 @@ function SenderLabel({ name }: { name?: string }): React.ReactNode { ); } +/** The bubble-side binding of `lib/persona.ts#resolveMessagePersona` (design/22 + * §7-5): which role spoke this message, and whether it differs from the role the + * session speaks as now. */ +function useSpokenPersona(personaId?: string): { + persona: PersonaRecord | null; + switched: boolean; +} { + const personas = useAppStore((s) => s.personas); + const active = useCurrentPersona(); + return resolveMessagePersona(personaId, personas, active); +} + +/** Muted "角色 · X" line for a bubble whose authoring role differs from the role + * the session speaks as now. */ +function PersonaLabel({ name }: { name?: string }): React.ReactNode { + if (!name) return null; + return ( +
角色 · {name}
+ ); +} + /** A single message bubble. Per design/07 §1 the bubble shape is driven by the * message's own `mode`: the same role renders differently under different modes, * and a single session may mix shapes. Falls back to `chat` when mode is absent @@ -251,11 +272,11 @@ function AssistantParaBubble({ full: boolean; active?: boolean; }): React.ReactNode { - const currentPersona = useAppStore((s) => s.currentPersona); + const { persona, switched } = useSpokenPersona(message.personaId); return (
{full ? ( - + ) : (
)} @@ -265,6 +286,7 @@ function AssistantParaBubble({
心情 · {labelFor("mood", message.mood)}
) : null} {full ? : null} + {full && switched ? : null} {full ? : null} {full ? : null} {full ? message.tools?.map((t, i) => ) : null} @@ -396,16 +418,17 @@ function AssistantBubble({ mode: Mode; active?: boolean; }): React.ReactNode { - const currentPersona = useAppStore((s) => s.currentPersona); + const { persona, switched } = useSpokenPersona(message.personaId); return (
- +
{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];