Files
Cyrene_v2/frontend/web/src/lib/api/sessions.ts
T
AskaEth 66a96d2f93
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(web): 会话级角色 — 角色跟随激活会话 + 选角入口 + 面板挂角色(B 批次 1 前端)
按 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)
2026-10-03 20:05:40 +08:00

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 }),
}
);
}