mirror of
https://github.com/moeru-ai/airi.git
synced 2026-10-30 19:34:10 +08:00
fix(api,stage-ui): keep the character of synchronized chats (#2874)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
bc41885f0b
commit
7a0f5bc391
@@ -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.
|
||||
@@ -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>): ChatSessionMeta {
|
||||
return {
|
||||
@@ -16,16 +16,38 @@ function makeMeta(partial: Partial<ChatSessionMeta>): ChatSessionMeta {
|
||||
}
|
||||
}
|
||||
|
||||
function makeRemote(partial: Partial<RemoteChat>): RemoteChat {
|
||||
function makeRemote(partial: Partial<ListedRemoteChat>): 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' })
|
||||
|
||||
@@ -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<typeof RemoteChatSchema>
|
||||
|
||||
/** 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<typeof ListedRemoteChatSchema>
|
||||
|
||||
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<RemoteChat[]>
|
||||
listChats: () => Promise<ListedRemoteChat[]>
|
||||
/**
|
||||
* 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<string, RemoteChat>()
|
||||
const remoteById = new Map<string, ListedRemoteChat>()
|
||||
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 }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,11 +2,13 @@ export type {
|
||||
CloudChatMapper,
|
||||
CreateCloudChatMapperOptions,
|
||||
CreateRemoteChatInput,
|
||||
ListedRemoteChat,
|
||||
ReconcilePlan,
|
||||
RemoteChat,
|
||||
} from './cloud-mapper'
|
||||
export {
|
||||
applyCreateActions,
|
||||
characterIdOfRemoteChat,
|
||||
createCloudChatMapper,
|
||||
reconcileLocalAndRemote,
|
||||
} from './cloud-mapper'
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user