按 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)
148 lines
5.3 KiB
TypeScript
148 lines
5.3 KiB
TypeScript
import type { ChatMessage, Mode, SessionMeta } from "../types";
|
|
import { request } from "./core";
|
|
|
|
/** One chat message as returned by GET /api/v1/sessions/{id}/messages. Mirrors
|
|
* the `chat_messages` row; `mode`/`mood`/`image`/`thinking` are optional. */
|
|
export interface MessageDto {
|
|
id: string;
|
|
sessionId: string;
|
|
role: "user" | "assistant" | "system";
|
|
content: string;
|
|
mode?: Mode;
|
|
mood?: string;
|
|
image?: string;
|
|
thinking?: string;
|
|
/** Source-platform tag (design/16 §4.4): "web" or an IM channel platform. */
|
|
platform?: string;
|
|
/** Concrete client/device that produced the message (design/16 §4.5). */
|
|
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;
|
|
}
|
|
|
|
/** Map a wire message into a display ChatMessage (carrying its sessionId). */
|
|
function toChatMessage(m: MessageDto): ChatMessage {
|
|
return {
|
|
id: m.id,
|
|
sessionId: m.sessionId,
|
|
role: m.role,
|
|
content: m.content,
|
|
mode: m.mode,
|
|
mood: m.mood,
|
|
image: m.image,
|
|
thinking: m.thinking,
|
|
platform: m.platform,
|
|
deviceId: m.deviceId,
|
|
senderName: m.senderName,
|
|
personaId: m.personaId,
|
|
createdAt: m.createdAt,
|
|
};
|
|
}
|
|
|
|
/** GET /api/v1/sessions — the main + secondary sessions for this web account. */
|
|
export function fetchSessions(): 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({
|
|
...(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
|
|
* reverse here: page 1 is the most recent `limit` messages in display order, and
|
|
* `before` (the oldest message's createdAt) walks upward. The Web client omits
|
|
* `platform` for the full shared main session; an IM adapter passes its own
|
|
* platform to see only its lane. */
|
|
export async function fetchMessages(
|
|
sessionId: string,
|
|
before?: number,
|
|
limit = 50,
|
|
platform?: string
|
|
): Promise<ChatMessage[]> {
|
|
const qs = new URLSearchParams();
|
|
qs.set("limit", String(limit));
|
|
if (before !== undefined) qs.set("before", String(before));
|
|
if (platform) qs.set("platform", platform);
|
|
const list = await request<MessageDto[]>(
|
|
`/sessions/${encodeURIComponent(sessionId)}/messages?${qs.toString()}`
|
|
);
|
|
return list.map(toChatMessage).reverse();
|
|
}
|
|
|
|
/** DELETE /api/v1/sessions/{id} — removes a secondary session (and its messages). */
|
|
export function deleteSession(
|
|
sessionId: string
|
|
): Promise<{ id: string; deleted: true }> {
|
|
return request<{ id: string; deleted: true }>(
|
|
`/sessions/${encodeURIComponent(sessionId)}`,
|
|
{ method: "DELETE" }
|
|
);
|
|
}
|
|
|
|
/** POST /api/v1/sessions/{id}/read — advance this account's read cursor for the
|
|
* session to `before` (ms since epoch; omitted = "now"). One cursor per session,
|
|
* forward only: a stale client can never re-open messages as unread. Unknown
|
|
* session → 404 (ApiError). */
|
|
export function markSessionRead(
|
|
sessionId: string,
|
|
before?: number
|
|
): Promise<{ sessionId: string; readAt: number }> {
|
|
return request<{ sessionId: string; readAt: number }>(
|
|
`/sessions/${encodeURIComponent(sessionId)}/read`,
|
|
{
|
|
method: "POST",
|
|
body: JSON.stringify(before === undefined ? {} : { before }),
|
|
}
|
|
);
|
|
}
|