fix(stage-tamagotchi): refresh chats after import (#2088)

This commit is contained in:
0xSelenicDove
2026-07-20 16:10:11 +08:00
committed by GitHub
parent dbf8124888
commit 38f7460558
7 changed files with 137 additions and 11 deletions
@@ -1,4 +1,6 @@
<script setup lang="ts">
import type { ChatSessionsExport } from '@proj-airi/stage-ui/types/chat-session'
import ChatsSection from '@proj-airi/stage-pages/pages/settings/data/components/chats-section.vue'
import DangerSection from '@proj-airi/stage-pages/pages/settings/data/components/danger-section.vue'
import ModelsModulesSection from '@proj-airi/stage-pages/pages/settings/data/components/models-modules-section.vue'
@@ -9,14 +11,21 @@ import { createDataSettingsStatusState } from '@proj-airi/stage-pages/pages/sett
import DesktopFolderSection from './components/desktop-folder-section.vue'
import DesktopResetSection from './components/desktop-reset-section.vue'
import { useChatSyncStore } from '../../../stores/chat-sync'
const { statusMessage, statusTone, handleStatus } = createDataSettingsStatusState()
const chatSync = useChatSyncStore()
async function syncImportedChats(payload: ChatSessionsExport) {
await chatSync.requestImportSessions(payload)
}
</script>
<template>
<div :class="['flex flex-col gap-4 pb-4']">
<StatusBanner v-if="statusMessage" :message="statusMessage" :tone="statusTone" />
<DesktopFolderSection @status="handleStatus" />
<ChatsSection @status="handleStatus" />
<ChatsSection :sync-imported-chats="syncImportedChats" @status="handleStatus" />
<ModelsModulesSection @status="handleStatus" />
<DesktopResetSection @status="handleStatus" />
<DangerSection @status="handleStatus" />
@@ -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', () => {
@@ -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
}
@@ -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<typeof vi.fn<(payload: ChatSessionsExport) => Promise<void>>>
class MockBroadcastChannel {
static channels = new Map<string, Set<MockBroadcastChannel>>()
static messages: unknown[] = []
@@ -106,6 +109,7 @@ interface MockState {
applyRemoteSnapshot: ReturnType<typeof vi.fn>
setSessionMessages: ReturnType<typeof vi.fn>
getSessionMessages: ReturnType<typeof vi.fn>
importSessions: MockImportSessions
ingest: ReturnType<typeof vi.fn>
}
@@ -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<void>>().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()
@@ -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<ToolsetId>>
| 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<void>({
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,
}
})
@@ -1,4 +1,6 @@
<script setup lang="ts">
import type { ChatSessionsExport } from '@proj-airi/stage-ui/types/chat-session'
import type { DataSettingsStatusEmits } from '../status'
import { useAnalytics } from '@proj-airi/stage-ui/composables'
@@ -9,6 +11,12 @@ import { useI18n } from 'vue-i18n'
import { createDataSettingsStatusHelpers } from '../status'
interface Props {
/** Mirrors a validated desktop import into the chat authority renderer. */
syncImportedChats?: (payload: ChatSessionsExport) => Promise<void>
}
const props = defineProps<Props>()
const emit = defineEmits<DataSettingsStatusEmits>()
const { t } = useI18n()
const { trackDataAction } = useAnalytics()
@@ -62,7 +70,8 @@ async function handleImport(event: Event) {
try {
const raw = await file.text()
const parsed = JSON.parse(raw) as Record<string, unknown>
await importChatSessions(parsed)
const imported = await importChatSessions(parsed)
await props.syncImportedChats?.(imported)
importError.value = ''
trackDataAction({ action: 'chats_imported' })
emitStatus(t('settings.pages.data.status.imported'))
@@ -84,6 +84,7 @@ export function useDataMaintenance() {
if (!isChatSessionsPayload(payload))
throw new Error('Invalid chat session export format')
await chatStore.importSessions(payload)
return payload
}
async function resetSettingsState() {