From 7a0f5bc391112845461c8b1d51f85f792135d66c Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Wed, 21 Oct 2026 01:56:16 +0800 Subject: [PATCH] fix(api,stage-ui): keep the character of synchronized chats (#2874) Co-authored-by: Claude Opus 5.5 --- .../2026-10-09-character-as-chat-member.md | 112 ++++++++++++++++++ .../src/libs/chat-sync/cloud-mapper.test.ts | 95 ++++++++++++++- .../src/libs/chat-sync/cloud-mapper.ts | 59 +++++++-- packages/stage-ui/src/libs/chat-sync/index.ts | 2 + .../session-store-lifecycle.browser.test.ts | 98 +++++++++++++++ .../stage-ui/src/stores/chat/session-store.ts | 44 ++++++- .../api/src/services/domain/chats.test.ts | 29 +++++ server/apps/api/src/services/domain/chats.ts | 13 +- .../ai/adr/2026-10-09-chat-list-members.md | 87 ++++++++++++++ 9 files changed, 521 insertions(+), 18 deletions(-) create mode 100644 docs/ai/adr/2026-10-09-character-as-chat-member.md create mode 100644 server/docs/ai/adr/2026-10-09-chat-list-members.md diff --git a/docs/ai/adr/2026-10-09-character-as-chat-member.md b/docs/ai/adr/2026-10-09-character-as-chat-member.md new file mode 100644 index 0000000000..e54521e09b --- /dev/null +++ b/docs/ai/adr/2026-10-09-character-as-chat-member.md @@ -0,0 +1,112 @@ +# A character is a member of a chat + +Status: proposed + +## Context + +The product goal is a contact list, where each character is a contact, as in Discord. +A later goal is a group chat with one or more users and many characters. +The client is local-first. A user without an account must get every function in this document. + +The code calls the entity a "card". A card holds the persona, the voice, the chat model, and a set of display models. +A chat session holds one `characterId`. The local index groups sessions by that id. + +These parts are already on `main`: + +- A character holds a set of Avatar Model references and one default (#2458). +- Display model files synchronize to the account, and a downloaded model keeps its id (#2869). +- Text turns, voice turns, and each model step read the settings of the session character through `getModules(characterId)` (#2725). + +The current code has these gaps: + +- Chat synchronization loses the character. A second device puts every received chat under the `default` character (`session-store.ts`, the `adopt` step). +- The activation of a card writes its modules into the global runtime stores (`writeRuntimeModules`). +- Code reads `meta.characterId` directly. No function tells which characters are members of a session. + +## Decision + +### Names + +- A **character** is the entity. It is a member of a chat. +- A **character card** is the import and export format of a character. +- This change renames the concept and the text that users see. It does not rename code identifiers. + +### Relations + +```mermaid +erDiagram + CHAT ||--|{ MEMBER : has + CHAT ||--o{ MESSAGE : has + USER ||--o{ MEMBER : "is" + CHARACTER ||--o{ MEMBER : "is" + CHARACTER }o--o{ DISPLAY_MODEL : "can show" + CHARACTER ||--|| VOICE : has + CHARACTER ||--|| PERSONA : has + CHARACTER ||--|| CHAT_MODEL : has + CHARACTER ||--o{ MEMORY : "has (later)" +``` + +- Today, a chat has one user and one character. +- A group chat uses the same relations with more members. +- A character does not change after its chat exists. A chat of one character has no member conflict. + +### Chat members + +- Chat synchronization sends and receives the members of each chat. See `server/docs/ai/adr/2026-10-09-chat-list-members.md`. +- One function answers "which characters are members of this session". New code calls this function. Step 2 adds it. +- New code does not read `meta.characterId` or the character groups of the local index. +- The local storage shape stays. A later change for group chats replaces the body of that one function. + +### Character settings + +- Turns read the settings of the session character through `getModules(characterId)`. This is on `main`. +- The activation of a card stops the writes into the global runtime stores. Those stores hold only the global defaults. +- The order of the sources is: character value, then global default. A later change can put a value of the chat member first. + +### Display models + +- A character holds `avatarModels` and `defaultAvatarModelId` (#2458). +- Many characters can refer to one display model. + +### Local-first rules + +- The local data is the source. The server is a mirror that carries the data to other devices. +- If a device does not have a display model that a character refers to, the device keeps the reference. +- The device then shows a placeholder and asks the user to import the model. It does not write the default model into the character. +- If a received chat refers to a character that the device does not have, the device keeps the character id. +- A provider that a device did not configure follows the same rule. The device uses the next source and keeps the stored value. + +### Repair of old sessions + +- Earlier versions put received chats under `default`. +- During a reconcile, a device compares the local character of a session with the character member on the server. +- If the two differ, the device applies the server value and moves the session to that character group. +- The rule is safe because a character does not change after its chat exists. +- The move keeps the stored messages and the time of the session. +- The move does not change the session that a window shows. + +## Non-goals + +- The UI of a group chat, and the order in which many characters speak. +- Chats with many users. They need permissions and an order that the server decides. +- Character memory. `stores/character/notebook.ts` stays global. New memory data must use a character id as its key. +- A new name for `airi-card` identifiers in code. +- A change to the chat model of a character. + +## Steps + +| Step | Content | Workspaces | State | +| --- | --- | --- | --- | +| 1 | Synchronize chat members. Repair old sessions. | `server/apps/api`, `packages/stage-ui` | This change | +| 2 | Add the function for the members of a session. | `packages/stage-ui` | To do | +| 3 | Stop the writes into the global runtime stores when a card becomes active. | `packages/stage-ui`, settings pages | To do | +| 4 | Build the contact list for the mobile layout. | `packages/stage-ui`, `packages/stage-layouts` | To do | + +Turns that read the session character (#2725) and a set of display models (#2458) are on `main`. + +## Consequences + +- A contact list can group sessions by character on every device of an account. +- Two windows can show different characters, because a turn no longer reads a global selection. +- A character on a second device can show a placeholder until the user imports its display model. +- Step 3 changes what the settings pages edit. A page must tell whether it edits the global default or the character. diff --git a/packages/stage-ui/src/libs/chat-sync/cloud-mapper.test.ts b/packages/stage-ui/src/libs/chat-sync/cloud-mapper.test.ts index bd8b825e0c..cfa4454fba 100644 --- a/packages/stage-ui/src/libs/chat-sync/cloud-mapper.test.ts +++ b/packages/stage-ui/src/libs/chat-sync/cloud-mapper.test.ts @@ -1,9 +1,9 @@ import type { ChatSessionMeta } from '../../types/chat-session' -import type { CloudChatMapper, RemoteChat } from './cloud-mapper' +import type { CloudChatMapper, ListedRemoteChat } from './cloud-mapper' import { describe, expect, it, vi } from 'vitest' -import { applyCreateActions, createCloudChatMapper, reconcileLocalAndRemote } from './cloud-mapper' +import { applyCreateActions, characterIdOfRemoteChat, createCloudChatMapper, reconcileLocalAndRemote } from './cloud-mapper' function makeMeta(partial: Partial): ChatSessionMeta { return { @@ -16,16 +16,38 @@ function makeMeta(partial: Partial): ChatSessionMeta { } } -function makeRemote(partial: Partial): RemoteChat { +function makeRemote(partial: Partial): ListedRemoteChat { return { id: partial.id ?? 'chat-1', type: partial.type ?? 'bot', title: partial.title ?? null, createdAt: partial.createdAt ?? '2026-01-01T00:00:00.000Z', updatedAt: partial.updatedAt ?? '2026-01-01T00:00:00.000Z', + members: partial.members ?? membersOf('char-1'), } } +function membersOf(...characterIds: string[]): ListedRemoteChat['members'] { + return [ + { memberType: 'user', characterId: null }, + ...characterIds.map(characterId => ({ memberType: 'character' as const, characterId })), + ] +} + +describe('characterIdOfRemoteChat', () => { + it('returns the character member of a chat', () => { + expect(characterIdOfRemoteChat(makeRemote({ members: membersOf('luna') }))).toBe('luna') + }) + + it('returns undefined for a chat without a character member', () => { + expect(characterIdOfRemoteChat(makeRemote({ members: membersOf() }))).toBeUndefined() + }) + + it('returns undefined for a chat with many character members', () => { + expect(characterIdOfRemoteChat(makeRemote({ members: membersOf('luna', 'sol') }))).toBeUndefined() + }) +}) + describe('reconcileLocalAndRemote', () => { /** * @example @@ -152,6 +174,52 @@ function emptyResponse(init: { status?: number, statusText?: string } = {}): Res }) } +describe('reconcileLocalAndRemote · reassign', () => { + /** + * @example + * An earlier version put the received chat "abc" under `default`. + * The remote chat has the character member "luna". + * Expected: the session moves to "luna". + */ + it('reassigns a mapped session whose remote chat has another character', () => { + const plan = reconcileLocalAndRemote( + [makeMeta({ sessionId: 'abc', cloudChatId: 'abc', characterId: 'default' })], + [makeRemote({ id: 'abc', members: membersOf('luna') })], + ) + expect(plan.reassign).toEqual([{ sessionId: 'abc', characterId: 'luna' }]) + }) + + it('leaves a session whose remote chat has the same character', () => { + const plan = reconcileLocalAndRemote( + [makeMeta({ sessionId: 'abc', cloudChatId: 'abc', characterId: 'luna' })], + [makeRemote({ id: 'abc', members: membersOf('luna') })], + ) + expect(plan.reassign).toEqual([]) + }) + + it('leaves a session whose remote chat has no single character member', () => { + const plan = reconcileLocalAndRemote( + [ + makeMeta({ sessionId: 'none', cloudChatId: 'none', characterId: 'default' }), + makeMeta({ sessionId: 'many', cloudChatId: 'many', characterId: 'default' }), + ], + [ + makeRemote({ id: 'none', members: membersOf() }), + makeRemote({ id: 'many', members: membersOf('luna', 'sol') }), + ], + ) + expect(plan.reassign).toEqual([]) + }) + + it('leaves a session that has no remote chat', () => { + const plan = reconcileLocalAndRemote( + [makeMeta({ sessionId: 'abc', cloudChatId: 'gone', characterId: 'default' })], + [], + ) + expect(plan.reassign).toEqual([]) + }) +}) + describe('createCloudChatMapper.listChats', () => { /** * @example @@ -159,11 +227,15 @@ describe('createCloudChatMapper.listChats', () => { */ it('returns the chats array on 2xx', async () => { const fetchMock = vi.fn(async () => jsonResponse({ - chats: [{ id: 'a', type: 'bot', title: null, createdAt: '2026-01-01', updatedAt: '2026-01-01' }], + chats: [{ id: 'a', type: 'bot', title: null, createdAt: '2026-01-01', updatedAt: '2026-01-01', members: [ + { id: 'm1', chatId: 'a', memberType: 'user', userId: 'u1', characterId: null }, + { id: 'm2', chatId: 'a', memberType: 'character', userId: null, characterId: 'luna' }, + ] }], })) const mapper = createCloudChatMapper({ serverUrl: 'https://api.example.com', fetch: fetchMock as unknown as typeof fetch }) const chats = await mapper.listChats() expect(chats.map(c => c.id)).toEqual(['a']) + expect(chats.map(characterIdOfRemoteChat)).toEqual(['luna']) expect(fetchMock).toHaveBeenCalledWith( 'https://api.example.com/api/v1/chats', expect.objectContaining({ method: 'GET' }), @@ -175,6 +247,19 @@ describe('createCloudChatMapper.listChats', () => { * Server schema drift: `chats` is `null` instead of an array. Without * boundary validation this would feed `null.length` down into reconcile. */ + /** + * @example + * A server older than this client returns chats without `members`. + * A parse that accepted them would put every received chat under `default`. + */ + it('rejects a chat without members', async () => { + const fetchMock = vi.fn(async () => jsonResponse({ + chats: [{ id: 'a', type: 'bot', title: null, createdAt: '2026-01-01', updatedAt: '2026-01-01' }], + })) + const mapper = createCloudChatMapper({ serverUrl: 'https://api.example.com', fetch: fetchMock as unknown as typeof fetch }) + await expect(mapper.listChats()).rejects.toThrow() + }) + it('rejects malformed responses on 2xx via schema validation', async () => { const fetchMock = vi.fn(async () => jsonResponse({ chats: null })) const mapper = createCloudChatMapper({ serverUrl: 'https://api.example.com', fetch: fetchMock as unknown as typeof fetch }) @@ -237,7 +322,7 @@ describe('createCloudChatMapper.createChat', () => { const fetchMock = vi.fn() .mockResolvedValueOnce(jsonResponse({ message: 'already exists' }, { status: 409, statusText: 'Conflict' })) .mockResolvedValueOnce(jsonResponse({ - chats: [{ id: 'minted', type: 'bot', title: null, createdAt: '2026-01-01', updatedAt: '2026-01-01' }], + chats: [{ id: 'minted', type: 'bot', title: null, createdAt: '2026-01-01', updatedAt: '2026-01-01', members: [] }], })) const mapper = createCloudChatMapper({ serverUrl: 'https://api.example.com', fetch: fetchMock as unknown as typeof fetch }) const chat = await mapper.createChat({ id: 'minted', type: 'bot' }) diff --git a/packages/stage-ui/src/libs/chat-sync/cloud-mapper.ts b/packages/stage-ui/src/libs/chat-sync/cloud-mapper.ts index 0b71a5d24f..a2e7430195 100644 --- a/packages/stage-ui/src/libs/chat-sync/cloud-mapper.ts +++ b/packages/stage-ui/src/libs/chat-sync/cloud-mapper.ts @@ -14,13 +14,24 @@ const RemoteChatSchema = v.object({ updatedAt: v.string(), }) -const ListChatsResponseSchema = v.object({ - chats: v.array(RemoteChatSchema), +const ListedRemoteChatSchema = v.object({ + ...RemoteChatSchema.entries, + members: v.array(v.object({ + memberType: v.picklist(['user', 'character', 'bot']), + characterId: v.nullable(v.string()), + })), }) -/** Minimal shape of a chat returned by `GET /api/v1/chats`. */ +const ListChatsResponseSchema = v.object({ + chats: v.array(ListedRemoteChatSchema), +}) + +/** A chat as `POST /api/v1/chats` returns it. The response does not include the members. */ export type RemoteChat = v.InferOutput +/** A chat as `GET /api/v1/chats` returns it, with the members that tell which character the chat belongs to. */ +export type ListedRemoteChat = v.InferOutput + export interface CreateRemoteChatInput { id?: string type?: 'private' | 'bot' | 'group' | 'channel' @@ -57,7 +68,7 @@ export interface CreateCloudChatMapperOptions { export interface CloudChatMapper { /** GET /api/v1/chats — returns the full list for the current user. */ - listChats: () => Promise + listChats: () => Promise /** * POST /api/v1/chats — server may auto-generate id if not provided. A * 409 Conflict (id already exists) is treated as an idempotent claim and @@ -199,11 +210,26 @@ export function createCloudChatMapper(options: CreateCloudChatMapperOptions): Cl * POST `/api/v1/chats` to mint a chat for it. * - `adopt`: remote chat exists with no local session at all; need to create * a local session shell so future `pullMessages` can populate it. + * - `reassign`: local session and remote chat are the same chat, but the + * local session is under another character than the remote chat. */ export interface ReconcilePlan { claim: Array<{ sessionId: string, cloudChatId: string }> create: Array<{ sessionId: string, characterId: string }> - adopt: RemoteChat[] + adopt: ListedRemoteChat[] + reassign: Array<{ sessionId: string, characterId: string }> +} + +/** + * Returns the character that a remote chat belongs to. + * + * Every chat that this client creates has one character member. The result is + * `undefined` for a chat with no character member or with many, because a + * local session holds one character. + */ +export function characterIdOfRemoteChat(chat: ListedRemoteChat): string | undefined { + const characterIds = chat.members.flatMap(member => member.memberType === 'character' && member.characterId ? [member.characterId] : []) + return characterIds.length === 1 ? characterIds[0] : undefined } /** @@ -219,14 +245,14 @@ export interface ReconcilePlan { * out by the caller before reconcile — they are not cloud-eligible. * * Returns: - * - A plan of three lists. The caller applies them in any order; `create` - * actions need the network, `claim` / `adopt` are pure store mutations. + * - A plan of four lists. The caller applies them in any order; `create` + * actions need the network, the other lists are pure store mutations. */ export function reconcileLocalAndRemote( localSessions: ChatSessionMeta[], - remoteChats: RemoteChat[], + remoteChats: ListedRemoteChat[], ): ReconcilePlan { - const remoteById = new Map() + const remoteById = new Map() for (const chat of remoteChats) remoteById.set(chat.id, chat) @@ -261,7 +287,7 @@ export function reconcileLocalAndRemote( create.push({ sessionId: meta.sessionId, characterId: meta.characterId }) } - const adopt: RemoteChat[] = [] + const adopt: ListedRemoteChat[] = [] for (const chat of remoteChats) { if (localByCloudId.has(chat.id)) continue @@ -270,7 +296,18 @@ export function reconcileLocalAndRemote( adopt.push(chat) } - return { claim, create, adopt } + // The character of a chat does not change after the chat exists, so a + // difference means that an earlier version put a received chat under + // `default`. The server holds the character that the creating device sent. + const reassign: ReconcilePlan['reassign'] = [] + for (const meta of localSessions) { + const remote = remoteById.get(meta.cloudChatId ?? meta.sessionId) + const characterId = remote && characterIdOfRemoteChat(remote) + if (characterId && characterId !== meta.characterId) + reassign.push({ sessionId: meta.sessionId, characterId }) + } + + return { claim, create, adopt, reassign } } /** diff --git a/packages/stage-ui/src/libs/chat-sync/index.ts b/packages/stage-ui/src/libs/chat-sync/index.ts index 56daf4ada1..304c304f74 100644 --- a/packages/stage-ui/src/libs/chat-sync/index.ts +++ b/packages/stage-ui/src/libs/chat-sync/index.ts @@ -2,11 +2,13 @@ export type { CloudChatMapper, CreateCloudChatMapperOptions, CreateRemoteChatInput, + ListedRemoteChat, ReconcilePlan, RemoteChat, } from './cloud-mapper' export { applyCreateActions, + characterIdOfRemoteChat, createCloudChatMapper, reconcileLocalAndRemote, } from './cloud-mapper' diff --git a/packages/stage-ui/src/stores/chat/session-store-lifecycle.browser.test.ts b/packages/stage-ui/src/stores/chat/session-store-lifecycle.browser.test.ts index 52ad673c0d..e8cc974958 100644 --- a/packages/stage-ui/src/stores/chat/session-store-lifecycle.browser.test.ts +++ b/packages/stage-ui/src/stores/chat/session-store-lifecycle.browser.test.ts @@ -496,6 +496,7 @@ describe('chat-session-store · cloud placeholder hydration', () => { title: null, createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', + members: [], } userIdRef.value = 'cloud-user' getIndexMock.mockResolvedValue({ @@ -549,6 +550,103 @@ describe('chat-session-store · cloud placeholder hydration', () => { }) }) +describe('chat-session-store · cloud character membership', () => { + function remoteChatOf(id: string, characterId: string) { + return { + id, + type: 'bot' as const, + title: null, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + members: [ + { memberType: 'user' as const, characterId: null }, + { memberType: 'character' as const, characterId }, + ], + } + } + + async function reconcile() { + const store = useChatSessionStore() + await store.initialize() + cloudWsStatus = 'open' + cloudStatusListener?.('open') + await vi.waitFor(() => { + expect(store.cloudSyncReady).toBe(true) + }) + return store + } + + it('puts a received chat under the character of its member, also when the device does not have that character', async () => { + const localMeta: ChatSessionMeta = { sessionId: 'local-session', userId: 'cloud-user', characterId: 'default', createdAt: 1, updatedAt: 1, cloudChatId: 'local-session' } + userIdRef.value = 'cloud-user' + getIndexMock.mockResolvedValue({ + userId: 'cloud-user', + characters: { default: { activeSessionId: localMeta.sessionId, sessions: { [localMeta.sessionId]: localMeta } } }, + }) + getSessionMock.mockResolvedValue({ meta: localMeta, messages: [] }) + listChatsMock.mockResolvedValue([remoteChatOf('local-session', 'default'), remoteChatOf('luna-session', 'luna')]) + + const store = await reconcile() + + expect(store.sessionMetas['luna-session'].characterId).toBe('luna') + const savedIndex = saveIndexMock.mock.calls.at(-1)![0] + expect(Object.keys(savedIndex.characters.luna.sessions)).toEqual(['luna-session']) + expect(Object.keys(savedIndex.characters.default.sessions)).toEqual(['local-session']) + }) + + it('moves a session from default to the character of its remote chat and keeps its messages', async () => { + // ROOT CAUSE: + // + // The chat list did not include the members, so the adopt step put every + // received chat under `default`. Those sessions stay under `default`. + // + // We fixed this by a comparison with the character member of the remote + // chat during each reconcile. + const misfiled: ChatSessionMeta = { sessionId: 'misfiled', userId: 'cloud-user', characterId: 'default', createdAt: 1, updatedAt: 1, cloudChatId: 'misfiled' } + const kept: ChatSessionMeta = { sessionId: 'kept', userId: 'cloud-user', characterId: 'default', createdAt: 2, updatedAt: 2, cloudChatId: 'kept' } + const misfiledMessages: ChatSessionRecord['messages'] = [{ id: 'message-1', role: 'user', content: 'Hello Luna' }] + userIdRef.value = 'cloud-user' + getIndexMock.mockResolvedValue({ + userId: 'cloud-user', + characters: { default: { activeSessionId: kept.sessionId, sessions: { misfiled, kept } } }, + }) + getSessionMock.mockImplementation(sessionId => Promise.resolve(sessionId === 'misfiled' + ? { meta: misfiled, messages: misfiledMessages } + : { meta: kept, messages: [] })) + listChatsMock.mockResolvedValue([remoteChatOf('misfiled', 'luna'), remoteChatOf('kept', 'default')]) + + const store = await reconcile() + + expect(store.sessionMetas.misfiled.characterId).toBe('luna') + expect(store.sessionMetas.kept.characterId).toBe('default') + expect(saveSessionMock).toHaveBeenCalledWith('misfiled', { + meta: { ...misfiled, characterId: 'luna' }, + messages: misfiledMessages, + }) + const savedIndex = saveIndexMock.mock.calls.at(-1)![0] + expect(Object.keys(savedIndex.characters.luna.sessions)).toEqual(['misfiled']) + expect(Object.keys(savedIndex.characters.default.sessions)).toEqual(['kept']) + expect(savedIndex.characters.default.activeSessionId).toBe('kept') + }) + + it('selects another session for the previous character when its active session moves', async () => { + const misfiled: ChatSessionMeta = { sessionId: 'misfiled', userId: 'cloud-user', characterId: 'default', createdAt: 1, updatedAt: 1, cloudChatId: 'misfiled' } + const kept: ChatSessionMeta = { sessionId: 'kept', userId: 'cloud-user', characterId: 'default', createdAt: 2, updatedAt: 2, cloudChatId: 'kept' } + userIdRef.value = 'cloud-user' + getIndexMock.mockResolvedValue({ + userId: 'cloud-user', + characters: { default: { activeSessionId: misfiled.sessionId, sessions: { misfiled, kept } } }, + }) + getSessionMock.mockImplementation(sessionId => Promise.resolve({ meta: sessionId === 'misfiled' ? misfiled : kept, messages: [] })) + listChatsMock.mockResolvedValue([remoteChatOf('misfiled', 'luna'), remoteChatOf('kept', 'default')]) + + await reconcile() + + const savedIndex = saveIndexMock.mock.calls.at(-1)![0] + expect(savedIndex.characters.default.activeSessionId).toBe('kept') + }) +}) + describe('chat-session-store · cloud deletion', () => { it('tombstones an unmapped cloud session before an in-flight create can finish', async () => { // ROOT CAUSE: diff --git a/packages/stage-ui/src/stores/chat/session-store.ts b/packages/stage-ui/src/stores/chat/session-store.ts index 131bb3d179..9f538e4894 100644 --- a/packages/stage-ui/src/stores/chat/session-store.ts +++ b/packages/stage-ui/src/stores/chat/session-store.ts @@ -17,6 +17,7 @@ import { authedFetch } from '../../libs/auth-fetch' import { chatAssetIdsOf, inlineChatAssets } from '../../libs/chat-assets' import { applyCreateActions, + characterIdOfRemoteChat, createChatWsClient, createCloudChatMapper, extractMessageText, @@ -864,6 +865,46 @@ export const useChatSessionStore = defineStore('chat-session', () => { void persistSession(action.sessionId) } + // reassign: move a session to the character of its remote chat. + for (const action of plan.reassign) { + const meta = sessionMetas.value[action.sessionId] + if (!meta || !index.value) + continue + + const previousCharacterIndex = index.value.characters[meta.characterId] + if (previousCharacterIndex) { + delete previousCharacterIndex.sessions[action.sessionId] + // The previous character opens another of its sessions next time. An + // empty id makes it create a session, as after a deletion. + if (previousCharacterIndex.activeSessionId === action.sessionId) + previousCharacterIndex.activeSessionId = Object.keys(previousCharacterIndex.sessions).find(id => sessionMetas.value[id]) ?? '' + } + + const reassignedMeta: ChatSessionMeta = { ...meta, characterId: action.characterId } + sessionMetas.value[action.sessionId] = reassignedMeta + const characterIndex = index.value.characters[action.characterId] ?? { + activeSessionId: '', + sessions: {}, + } + characterIndex.sessions[action.sessionId] = reassignedMeta + index.value.characters[action.characterId] = characterIndex + + // `loadSession` takes the meta of the stored record, so the record + // must change too. `persistSession` is not used here, because it + // writes the messages in memory, and this session is possibly not loaded. + await enqueuePersist(async () => { + const stored = await chatSessionsRepo.getSession(action.sessionId) + if (stored) { + await chatSessionsRepo.saveSession(action.sessionId, { + meta: { ...stored.meta, characterId: action.characterId }, + messages: stored.messages, + }) + } + }) + } + if (isStaleEpoch()) + return + // create: POST /api/v1/chats and bind. Mapper handles 409-as-claim. const createResults = await applyCreateActions(mapper, plan.create) if (isStaleEpoch()) @@ -912,7 +953,8 @@ export const useChatSessionStore = defineStore('chat-session', () => { const adoptedMeta: ChatSessionMeta = { sessionId: remote.id, userId: currentUserId, - characterId: 'default', + // A chat without one character member stays under the built-in character. + characterId: characterIdOfRemoteChat(remote) ?? 'default', title: remote.title ?? undefined, createdAt: new Date(remote.createdAt).getTime() || now, updatedAt: new Date(remote.updatedAt).getTime() || now, diff --git a/server/apps/api/src/services/domain/chats.test.ts b/server/apps/api/src/services/domain/chats.test.ts index 36b8cfb8e2..686d502138 100644 --- a/server/apps/api/src/services/domain/chats.test.ts +++ b/server/apps/api/src/services/domain/chats.test.ts @@ -284,3 +284,32 @@ describe('pushMessages', () => { expect(messages).toHaveLength(0) }) }) + +describe('listChats', () => { + let db: Database + + beforeEach(async () => { + db = await mockDB(schema) + }) + + it('returns the members of each chat of the user', async () => { + const service = createChatService(db) + await service.createChat('owner', { id: 'luna-chat', type: 'bot', members: [{ type: 'character', characterId: 'luna' }] }) + await service.createChat('owner', { id: 'sol-chat', type: 'bot', members: [{ type: 'character', characterId: 'sol' }] }) + await service.createChat('stranger', { id: 'other-chat', type: 'bot', members: [{ type: 'character', characterId: 'luna' }] }) + + const chats = await service.listChats('owner') + + const characterIdsByChatId = Object.fromEntries(chats.map(chat => [ + chat.id, + chat.members.filter(member => member.memberType === 'character').map(member => member.characterId), + ])) + expect(characterIdsByChatId).toEqual({ 'luna-chat': ['luna'], 'sol-chat': ['sol'] }) + }) + + it('returns an empty list for a user without chats', async () => { + const service = createChatService(db) + + expect(await service.listChats('owner')).toEqual([]) + }) +}) diff --git a/server/apps/api/src/services/domain/chats.ts b/server/apps/api/src/services/domain/chats.ts index 0e376e5bba..8a7997486e 100644 --- a/server/apps/api/src/services/domain/chats.ts +++ b/server/apps/api/src/services/domain/chats.ts @@ -6,6 +6,7 @@ import type { EngagementMetrics } from '../../otel' import { useLogger } from '@guiiai/logg' import { createBadRequestError, createConflictError, createForbiddenError, createNotFoundError } from '@proj-airi/http-error-shared' import { and, eq, gt, inArray, isNull, sql } from 'drizzle-orm' +import { groupBy } from 'es-toolkit' import { nanoid } from '../../utils/id' @@ -137,7 +138,17 @@ export function createChatService(db: Database, metrics?: EngagementMetrics | nu isNull(schema.chats.deletedAt), )) - return rows.map(r => r.chat) + if (rows.length === 0) + return [] + + // A second device reads the character of each chat from its members. + const members = await db + .select() + .from(schema.chatMembers) + .where(inArray(schema.chatMembers.chatId, rows.map(r => r.chat.id))) + const membersByChatId = groupBy(members, member => member.chatId) + + return rows.map(r => ({ ...r.chat, members: membersByChatId[r.chat.id] })) }, async updateChat(userId: string, chatId: string, updates: { title?: string }) { diff --git a/server/docs/ai/adr/2026-10-09-chat-list-members.md b/server/docs/ai/adr/2026-10-09-chat-list-members.md new file mode 100644 index 0000000000..9127d6e65f --- /dev/null +++ b/server/docs/ai/adr/2026-10-09-chat-list-members.md @@ -0,0 +1,87 @@ +# The chat list includes the members of each chat + +Status: proposed + +## Context + +A client sends the character of a chat when it creates the chat. `POST /api/v1/chats` stores it in `chat_members`. +`GET /api/v1/chats` returns only the rows of `chats`. +A second device then cannot tell which character a chat belongs to. It puts every received chat under the `default` character. +`GET /api/v1/chats/:id` returns the members, but one request for each chat is too slow for a reconcile. + +`docs/ai/adr/2026-10-09-character-as-chat-member.md` gives the client design. + +## Decision + +- `GET /api/v1/chats` returns a `members` array for each chat. +- A member is a row of `chat_members`, the same shape that `GET /api/v1/chats/:id` returns. +- `listChats` reads the members of all listed chats with one more query. It does not query once for each chat. +- The client schema for a listed chat requires `members`. The response of `POST /api/v1/chats` stays without members. +- The client reads the character of a chat from the member with the `memberType` of `character`. +- A chat with no character member, or with many, has no single character. The client keeps it under `default`. +- The tables do not change. `chat_members` already holds many members for one chat, which a group chat needs. + +## Scope + +- The response of `GET /api/v1/chats`. +- `listChats` in the chat service. +- The response schema and the `adopt` step of the client. + +## Non-goals + +- A `character_id` column on `chats`. One column cannot hold the characters of a group chat. +- A check that `characterId` refers to a row of `character_cards`. Card synchronization is an optional experiment on each device. +- Changes to the routes that add or remove a member. +- Chats with many users. + +## Module dependency graph + +```mermaid +flowchart LR + Store[stage-ui: session-store] --> Mapper[stage-ui: cloud-mapper] + Mapper -->|GET /api/v1/chats| Route[api: routes/chats] + Route --> Service[api: services/domain/chats] + Service --> Chats[(chats)] + Service --> Members[(chat_members)] +``` + +## Affected files + +```text +server/apps/api/src/ +├── services/domain/chats.ts listChats returns members +└── services/domain/chats.test.ts members in the list +packages/stage-ui/src/ +├── libs/chat-sync/cloud-mapper.ts listed chats require members, the plan gets a reassign list +└── stores/chat/session-store.ts adopt and reassign use the character member +``` + +## Sequence + +```mermaid +sequenceDiagram + participant A as Device A + participant S as API + participant B as Device B + A->>S: POST /api/v1/chats (id, members: character X) + S->>S: Insert chats row and chat_members rows + B->>S: GET /api/v1/chats + S->>S: Select chats of the user, then members of those chats + S-->>B: chats, each with members + B->>B: Adopt the chat under character X + B->>B: Move an old session from default to character X +``` + +## Deployment + +- Deploy the server before a client that requires `members`. +- An older client ignores the new property, so the server change is safe alone. + +## Test plan + +- `chats.test.ts`: the list returns the character member of each chat, and no chats of another user. +- `cloud-mapper.test.ts`: a response without `members` fails the parse. +- `cloud-mapper.test.ts`: the plan reassigns a session only when its remote chat has one other character. +- `session-store-lifecycle.browser.test.ts`: a received chat goes under the character of its member. +- The same test uses a character that the device does not have, and the id stays. +- `session-store-lifecycle.browser.test.ts`: a session under `default` moves to its character and keeps its messages.