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

+2 -1
View File
@@ -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";
+48 -4
View File
@@ -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<SessionMeta[]> {
return request<SessionMeta[]>("/sessions");
}
/** POST /api/v1/sessions — creates a new secondary session. */
export function createSubSession(title?: string): Promise<SessionMeta> {
return request<SessionMeta>("/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<CreateSessionResult> {
return request<CreateSessionResult>("/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
+50 -1
View File
@@ -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<string, SessionMeta>,
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),
};
}
+50
View File
@@ -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<unknown>;
}
/** 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<boolean> {
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;
}
}
+15
View File
@@ -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 =
+9 -2
View File
@@ -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": {