-
+
diff --git a/apps/stage-tamagotchi/src/renderer/stores/chat-sync-lifecycle.test.ts b/apps/stage-tamagotchi/src/renderer/stores/chat-sync-lifecycle.test.ts
index 32f9bf5f5..371753ae8 100644
--- a/apps/stage-tamagotchi/src/renderer/stores/chat-sync-lifecycle.test.ts
+++ b/apps/stage-tamagotchi/src/renderer/stores/chat-sync-lifecycle.test.ts
@@ -62,24 +62,25 @@ describe('createChatSyncWindowLifecycle', async () => {
expect(chatSyncStoreMock.dispose).not.toHaveBeenCalled()
})
- it('does not initialize chat sync for settings windows', () => {
+ // https://github.com/moeru-ai/airi/issues/2087
+ it('issue #2087: initializes settings windows as command clients', () => {
const lifecycle = createChatSyncWindowLifecycle('/', '#/settings')
lifecycle.initialize()
lifecycle.dispose()
- expect(chatSyncStoreMock.initialize).not.toHaveBeenCalled()
- expect(chatSyncStoreMock.dispose).not.toHaveBeenCalled()
+ expect(chatSyncStoreMock.initialize).toHaveBeenCalledWith('client')
+ expect(chatSyncStoreMock.dispose).toHaveBeenCalledTimes(1)
})
- it('does not initialize chat sync for nested settings windows', () => {
+ it('initializes nested settings windows as command clients', () => {
const lifecycle = createChatSyncWindowLifecycle('/', '#/settings/unrelated')
lifecycle.initialize()
lifecycle.dispose()
- expect(chatSyncStoreMock.initialize).not.toHaveBeenCalled()
- expect(chatSyncStoreMock.dispose).not.toHaveBeenCalled()
+ expect(chatSyncStoreMock.initialize).toHaveBeenCalledWith('client')
+ expect(chatSyncStoreMock.dispose).toHaveBeenCalledTimes(1)
})
it('normalizes hash query strings when resolving the initial route', () => {
diff --git a/apps/stage-tamagotchi/src/renderer/stores/chat-sync-lifecycle.ts b/apps/stage-tamagotchi/src/renderer/stores/chat-sync-lifecycle.ts
index ae66949b4..2993a8541 100644
--- a/apps/stage-tamagotchi/src/renderer/stores/chat-sync-lifecycle.ts
+++ b/apps/stage-tamagotchi/src/renderer/stores/chat-sync-lifecycle.ts
@@ -1,6 +1,6 @@
import { useChatSyncStore } from './chat-sync'
-type ChatSyncWindowRole = 'authority' | 'follower'
+type ChatSyncWindowRole = 'authority' | 'follower' | 'client'
function normalizeRoutePath(routePath: string) {
const [path = ''] = routePath.split(/[?#]/)
@@ -21,6 +21,8 @@ function resolveChatSyncWindowRole(routePath: string): ChatSyncWindowRole | null
return 'authority'
if (path === '/chat' || path === '/spotlight')
return 'follower'
+ if (path === '/settings' || path.startsWith('/settings/'))
+ return 'client'
return null
}
diff --git a/apps/stage-tamagotchi/src/renderer/stores/chat-sync.test.ts b/apps/stage-tamagotchi/src/renderer/stores/chat-sync.test.ts
index a50e16dae..6094fb803 100644
--- a/apps/stage-tamagotchi/src/renderer/stores/chat-sync.test.ts
+++ b/apps/stage-tamagotchi/src/renderer/stores/chat-sync.test.ts
@@ -1,5 +1,6 @@
// @vitest-environment jsdom
+import type { ChatSessionsExport } from '@proj-airi/stage-ui/types/chat-session'
import type { Tool } from '@xsai/shared-chat'
import type { Ref } from 'vue'
@@ -25,6 +26,8 @@ interface MockChatMessage {
tool_results?: Array<{ id: string, isError?: boolean, result: unknown }>
}
+type MockImportSessions = ReturnType Promise>>
+
class MockBroadcastChannel {
static channels = new Map>()
static messages: unknown[] = []
@@ -106,6 +109,7 @@ interface MockState {
applyRemoteSnapshot: ReturnType
setSessionMessages: ReturnType
getSessionMessages: ReturnType
+ importSessions: MockImportSessions
ingest: ReturnType
}
@@ -123,6 +127,7 @@ vi.mock('@proj-airi/stage-ui/stores/chat/session-store', () => ({
sessionMetas: mockState.sessionMetas.value,
})),
getSessionMessages: mockState.getSessionMessages,
+ importSessions: mockState.importSessions,
setSessionMessages: mockState.setSessionMessages,
}),
}))
@@ -219,6 +224,7 @@ describe('useChatSyncStore', async () => {
})
const getSessionMessages = vi.fn((sessionId: string) => sessionMessages.value[sessionId] ?? [])
+ const importSessions = vi.fn<(payload: ChatSessionsExport) => Promise>().mockResolvedValue(undefined)
const ingest = vi.fn(async () => {
throw new Error('Remote sent 403 response: {"error":{"message":"This model is not available in your region.","code":403}}')
@@ -240,6 +246,7 @@ describe('useChatSyncStore', async () => {
applyRemoteSnapshot,
setSessionMessages,
getSessionMessages,
+ importSessions,
ingest,
}
@@ -251,6 +258,82 @@ describe('useChatSyncStore', async () => {
MockBroadcastChannel.reset()
})
+ // https://github.com/moeru-ai/airi/issues/2087
+ it('issue #2087: imports settings-window chats through the authority store', async () => {
+ // ROOT CAUSE:
+ //
+ // The settings window previously never joined the desktop chat channel.
+ // Its import updated only that renderer's Pinia store and IndexedDB, so
+ // the authority kept broadcasting its stale session snapshot until an
+ // app restart hydrated the persisted import.
+ const importedMeta = {
+ sessionId: 'imported-session',
+ userId: 'local',
+ characterId: 'default',
+ createdAt: 1,
+ updatedAt: 2,
+ }
+ const payload: ChatSessionsExport = {
+ format: 'chat-sessions-index:v1',
+ index: {
+ userId: 'local',
+ characters: {
+ default: {
+ activeSessionId: 'imported-session',
+ sessions: {
+ 'imported-session': importedMeta,
+ },
+ },
+ },
+ },
+ sessions: {
+ 'imported-session': {
+ meta: importedMeta,
+ messages: [{ id: 'message-1', role: 'user', content: 'Imported chat' }],
+ },
+ },
+ }
+ mockState.importSessions.mockImplementationOnce(async (imported) => {
+ mockState.activeSessionId.value = imported.index.characters.default?.activeSessionId ?? ''
+ mockState.sessionMetas.value = Object.fromEntries(
+ Object.values(imported.index.characters).flatMap(character => Object.entries(character.sessions)),
+ )
+ mockState.sessionMessages.value = Object.fromEntries(
+ Object.entries(imported.sessions).map(([sessionId, session]) => [
+ sessionId,
+ session.messages.map(message => ({
+ id: message.id,
+ role: message.role,
+ content: typeof message.content === 'string' ? message.content : '',
+ })),
+ ]),
+ )
+ })
+ const authorityStore = useChatSyncStore()
+ authorityStore.initialize('authority')
+
+ setActivePinia(createPinia())
+ const settingsStore = useChatSyncStore()
+ settingsStore.initialize('client')
+
+ await settingsStore.requestImportSessions(payload)
+
+ expect(mockState.importSessions).toHaveBeenCalledTimes(1)
+ expect(mockState.importSessions).toHaveBeenCalledWith(payload)
+ await vi.waitFor(() => {
+ expect(postedMessagesOfType('session-snapshot')).toContainEqual(expect.objectContaining({
+ snapshot: expect.objectContaining({
+ sessionMetas: {
+ 'imported-session': importedMeta,
+ },
+ }),
+ }))
+ })
+
+ settingsStore.dispose()
+ authorityStore.dispose()
+ })
+
it('stores command ingest errors in authority session history', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
const store = useChatSyncStore()
diff --git a/apps/stage-tamagotchi/src/renderer/stores/chat-sync.ts b/apps/stage-tamagotchi/src/renderer/stores/chat-sync.ts
index dade97294..a3c1f2738 100644
--- a/apps/stage-tamagotchi/src/renderer/stores/chat-sync.ts
+++ b/apps/stage-tamagotchi/src/renderer/stores/chat-sync.ts
@@ -1,7 +1,7 @@
import type { WebSocketEventInputs } from '@proj-airi/server-sdk'
import type { ToolCallRerunPayload } from '@proj-airi/stage-ui/stores/tool-call-rerun'
import type { ChatHistoryItem, StreamingAssistantMessage } from '@proj-airi/stage-ui/types/chat'
-import type { ChatSessionMeta } from '@proj-airi/stage-ui/types/chat-session'
+import type { ChatSessionMeta, ChatSessionsExport } from '@proj-airi/stage-ui/types/chat-session'
import type { ChatProvider } from '@xsai-ext/providers/utils'
import { errorMessageFrom } from '@moeru/std'
@@ -22,7 +22,7 @@ import { imageJournalTools } from './tools/builtin/image-journal'
import { weatherTools } from './tools/builtin/weather'
import { widgetsTools } from './tools/builtin/widgets'
-type ChatSyncMode = 'inactive' | 'authority' | 'follower'
+type ChatSyncMode = 'inactive' | 'authority' | 'follower' | 'client'
type ToolsetId = 'widgets' | 'artistry'
interface AttachmentPayload {
@@ -88,6 +88,7 @@ type ChatSyncMessage
| ChatCommandMessage<'tool-call-rerun', ToolCallRerunPayload>
| ChatCommandMessage<'cleanup', { sessionId?: string }>
| ChatCommandMessage<'delete-message', { sessionId?: string, messageId?: string, index?: number }>
+ | ChatCommandMessage<'import-sessions', ChatSessionsExport>
| ({ type: 'response', requestId: string, authorityId: string } & ChatResponsePayload)
interface PendingRequest {
@@ -467,6 +468,9 @@ export const useChatSyncStore = defineStore('stage-tamagotchi:chat-sync', () =>
case 'delete-message':
executeDeleteMessage(message.payload)
break
+ case 'import-sessions':
+ await chatSession.importSessions(message.payload)
+ break
}
respond({ ok: true })
@@ -703,6 +707,22 @@ export const useChatSyncStore = defineStore('stage-tamagotchi:chat-sync', () =>
})
}
+ /** Imports persisted chat sessions through the authority so every chat window receives the resulting snapshot. */
+ async function requestImportSessions(payload: ChatSessionsExport) {
+ if (mode.value === 'authority') {
+ await chatSession.importSessions(payload)
+ return
+ }
+
+ return await dispatch({
+ type: 'command',
+ requestId: createRequestId(),
+ senderId: instanceId,
+ command: 'import-sessions',
+ payload,
+ })
+ }
+
function dispose() {
stopWatchers()
clearHeartbeat()
@@ -723,5 +743,6 @@ export const useChatSyncStore = defineStore('stage-tamagotchi:chat-sync', () =>
requestToolCallRerun,
requestCleanup,
requestDeleteMessage,
+ requestImportSessions,
}
})
diff --git a/packages/stage-pages/src/pages/settings/data/components/chats-section.vue b/packages/stage-pages/src/pages/settings/data/components/chats-section.vue
index 213319733..8a67a582d 100644
--- a/packages/stage-pages/src/pages/settings/data/components/chats-section.vue
+++ b/packages/stage-pages/src/pages/settings/data/components/chats-section.vue
@@ -1,4 +1,6 @@