feat(web): 会话级角色 — 角色跟随激活会话 + 选角入口 + 面板挂角色(B 批次 1 前端)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

按 DevDocs/design/22 §4.6/§5(7) 实现(后端由并行代理在做,按已定契约先行)。
- currentPersona 由「全局单值」改为「跟随激活会话」:新增 lib/sessionPersona.ts 解析(personaId 空 → 回落默认角色),App/TopBar/chat/lib/persona.ts 全部改为按激活会话取角色(头像/背景/名字/心情随会话切换)
- 交互:新建次级会话可选角色;会话换角色入口;管理面板 Sessions 页加「挂角色」下拉(走 PATCH /sessions/{id})
- 组件拆分:Nav.tsx 228→143(抽出 NavItem.tsx 53)、新增 SessionRail.tsx 275(会话侧栏)
- 契约:types.ts / lib/api/sessions.ts 加 personaId?(建会话/改会话支持)
- mood 改为**按角色分桶**(同一角色的多个会话共享该角色心情);升级后旧 mood 值不保留(刷新即空)——属行为变化,已在报告注明
- 测试:新增 personaSession.test.ts 40 checks(角色解析/回落/乐观更新与回滚)+ 既有 messageSync 54 checks 回归
- 验收:npm run build 绿;**未做浏览器端到端**(无 jsdom/渲染测试底座),UI 交互与失败路径提示未渲染验证
- 已知余量:useAppStore.ts 494/500 行(下次改动前应先拆 selectCurrentPersona/useCurrentPersona/useCurrentMood)
This commit is contained in:
AskaEth committed 2026-10-03 20:05:40 +08:00
1 parent 38a403afca
commit 66a96d2f93
16 files changed
+1044 -205

No files matched your search

+80 -23
View File
@@ -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<string, string>;
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<ChatContext>) => 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<AppStore>()(
activeSessionId: "",
wsStatus: "idle",
messages: [],
mood: null,
moods: {},
context: {},
agents: [
{ id: "main", name: "昔涟", status: "idle", role: "owner" },
@@ -134,20 +151,34 @@ export const useAppStore = create<AppStore>()(
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<AppStore>()(
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<AppStore>()(
resetChat: () =>
set((s) => ({
messages: [],
mood: null,
moods: {},
flowGraph: null,
backthink: s.backthink,
awakenState: "quiet",
@@ -381,38 +413,63 @@ export const useAppStore = create<AppStore>()(
}),
{
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<AppStore>;
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];