mirror of
https://github.com/moeru-ai/airi.git
synced 2026-08-14 08:52:42 +00:00
fix(stage-tamagotchi): refresh chats after import (#2088)
This commit is contained in:
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user