mirror of
https://github.com/moeru-ai/airi.git
synced 2026-08-14 00:48:06 +00:00
fix(stage-tamagotchi): keep chat sync alive across stage routes (#1821)
This commit is contained in:
@@ -51,6 +51,7 @@ import {
|
||||
import { initializeElectronAuthCallbackBridge } from './bridges/electron-auth-callback'
|
||||
import { initializeStageThreeRuntimeTraceBridge } from './bridges/stage-three-runtime-trace'
|
||||
import { useLanguage } from './composables/use-language'
|
||||
import { createChatSyncWindowLifecycle } from './stores/chat-sync-lifecycle'
|
||||
import { useTamagotchiMcpToolsStore } from './stores/mcp-tools'
|
||||
import { useTamagotchiPluginToolsStore } from './stores/plugin-tools'
|
||||
import { useServerChannelSettingsStore } from './stores/settings/server-channel'
|
||||
@@ -96,6 +97,7 @@ const getMainLocale = useElectronEventaInvoke(i18nGetLocale)
|
||||
const setLocale = useElectronEventaInvoke(i18nSetLocale)
|
||||
const getGodotStageStatus = useElectronEventaInvoke(electronGodotStageGetStatus)
|
||||
const syncArtistryConfig = useElectronEventaInvoke(artistrySyncConfig)
|
||||
const chatSyncLifecycle = createChatSyncWindowLifecycle(route.path)
|
||||
const isChatWindowRoute = () => route.path === '/chat'
|
||||
const isGodotStageRoute = () => route.path === '/' || route.path.startsWith('/settings')
|
||||
const isWidgetsWindowRoute = () => route.path === '/widgets'
|
||||
@@ -196,6 +198,8 @@ context.value.on(electronGodotStageStatusChanged, (event) => {
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
chatSyncLifecycle.initialize()
|
||||
|
||||
// NOTICE: Issue #1658
|
||||
// When Electron restarts, renderer localStorage may not be flushed to disk.
|
||||
// The store's onMounted hook falls back to navigator.language, which triggers
|
||||
@@ -256,6 +260,10 @@ onMounted(async () => {
|
||||
inferencePreload.triggerPreload()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
chatSyncLifecycle.dispose()
|
||||
})
|
||||
|
||||
watch(themeColorsHue, () => {
|
||||
document.documentElement.style.setProperty('--chromatic-hue', themeColorsHue.value.toString())
|
||||
}, { immediate: true })
|
||||
|
||||
@@ -1,20 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted } from 'vue'
|
||||
|
||||
import InteractiveArea from '../components/InteractiveArea.vue'
|
||||
import WindowTitleBar from '../components/Window/TitleBar.vue'
|
||||
|
||||
import { useChatSyncStore } from '../stores/chat-sync'
|
||||
|
||||
const chatSyncStore = useChatSyncStore()
|
||||
|
||||
onMounted(() => {
|
||||
chatSyncStore.initialize('follower')
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
chatSyncStore.dispose()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -406,7 +406,6 @@ watch(enabled, async (val) => {
|
||||
}, { immediate: true })
|
||||
|
||||
onMounted(() => {
|
||||
chatSyncStore.initialize('authority')
|
||||
if (onboardingStore.needsOnboarding) {
|
||||
openOnboarding()
|
||||
}
|
||||
@@ -418,7 +417,6 @@ onUnmounted(() => {
|
||||
ownerInstanceId: modelSettingsRuntimeOwnerInstanceId,
|
||||
})
|
||||
stopAudioInteraction()
|
||||
chatSyncStore.dispose()
|
||||
})
|
||||
|
||||
watch(stream, async (currentStream) => {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const chatSyncStoreMock = vi.hoisted(() => ({
|
||||
dispose: vi.fn(),
|
||||
initialize: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('./chat-sync', () => ({
|
||||
useChatSyncStore: () => chatSyncStoreMock,
|
||||
}))
|
||||
|
||||
describe('createChatSyncWindowLifecycle', async () => {
|
||||
const {
|
||||
createChatSyncWindowLifecycle,
|
||||
resolveInitialChatSyncRoutePath,
|
||||
} = await import('./chat-sync-lifecycle')
|
||||
|
||||
beforeEach(() => {
|
||||
chatSyncStoreMock.dispose.mockClear()
|
||||
chatSyncStoreMock.initialize.mockClear()
|
||||
})
|
||||
|
||||
it('issue #1743: keeps main window chat sync owned by the renderer root', () => {
|
||||
// https://github.com/moeru-ai/airi/issues/1743
|
||||
const lifecycle = createChatSyncWindowLifecycle('/', '')
|
||||
|
||||
lifecycle.initialize()
|
||||
lifecycle.dispose()
|
||||
|
||||
expect(chatSyncStoreMock.initialize).toHaveBeenCalledWith('authority')
|
||||
expect(chatSyncStoreMock.dispose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('issue #1743: resolves chat windows from the initial hash before router readiness', () => {
|
||||
// https://github.com/moeru-ai/airi/issues/1743
|
||||
const lifecycle = createChatSyncWindowLifecycle('/', '#/chat')
|
||||
|
||||
lifecycle.initialize()
|
||||
lifecycle.dispose()
|
||||
|
||||
expect(chatSyncStoreMock.initialize).toHaveBeenCalledWith('follower')
|
||||
expect(chatSyncStoreMock.dispose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not initialize chat sync for unrelated windows', () => {
|
||||
const lifecycle = createChatSyncWindowLifecycle('/', '#/widgets')
|
||||
|
||||
lifecycle.initialize()
|
||||
lifecycle.dispose()
|
||||
|
||||
expect(chatSyncStoreMock.initialize).not.toHaveBeenCalled()
|
||||
expect(chatSyncStoreMock.dispose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not initialize chat sync for settings windows', () => {
|
||||
const lifecycle = createChatSyncWindowLifecycle('/', '#/settings')
|
||||
|
||||
lifecycle.initialize()
|
||||
lifecycle.dispose()
|
||||
|
||||
expect(chatSyncStoreMock.initialize).not.toHaveBeenCalled()
|
||||
expect(chatSyncStoreMock.dispose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not initialize chat sync for nested settings windows', () => {
|
||||
const lifecycle = createChatSyncWindowLifecycle('/', '#/settings/unrelated')
|
||||
|
||||
lifecycle.initialize()
|
||||
lifecycle.dispose()
|
||||
|
||||
expect(chatSyncStoreMock.initialize).not.toHaveBeenCalled()
|
||||
expect(chatSyncStoreMock.dispose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('normalizes hash query strings when resolving the initial route', () => {
|
||||
expect(resolveInitialChatSyncRoutePath('/', '#/chat?source=tray')).toBe('/chat')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useChatSyncStore } from './chat-sync'
|
||||
|
||||
type ChatSyncWindowRole = 'authority' | 'follower'
|
||||
|
||||
function normalizeRoutePath(routePath: string) {
|
||||
const [path = ''] = routePath.split(/[?#]/)
|
||||
return path || '/'
|
||||
}
|
||||
|
||||
export function resolveInitialChatSyncRoutePath(routePath: string, hash = globalThis.location?.hash ?? '') {
|
||||
const hashPath = hash.startsWith('#') ? hash.slice(1) : ''
|
||||
return normalizeRoutePath(hashPath || routePath)
|
||||
}
|
||||
|
||||
function resolveChatSyncWindowRole(routePath: string): ChatSyncWindowRole | null {
|
||||
const path = normalizeRoutePath(routePath)
|
||||
if (path === '/')
|
||||
return 'authority'
|
||||
if (path === '/chat')
|
||||
return 'follower'
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns chat-sync BroadcastChannel lifecycle for one Electron renderer window.
|
||||
*
|
||||
* The role is captured from the initial window route and must be initialized
|
||||
* from the renderer root. Route pages should not dispose the channel because
|
||||
* in-window navigation can unmount them while the BrowserWindow is still alive.
|
||||
*/
|
||||
export function createChatSyncWindowLifecycle(routePath: string, hash?: string) {
|
||||
const chatSyncStore = useChatSyncStore()
|
||||
const role = resolveChatSyncWindowRole(resolveInitialChatSyncRoutePath(routePath, hash))
|
||||
|
||||
return {
|
||||
role,
|
||||
initialize() {
|
||||
if (role)
|
||||
chatSyncStore.initialize(role)
|
||||
},
|
||||
dispose() {
|
||||
if (role)
|
||||
chatSyncStore.dispose()
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user