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 { initializeElectronAuthCallbackBridge } from './bridges/electron-auth-callback'
|
||||||
import { initializeStageThreeRuntimeTraceBridge } from './bridges/stage-three-runtime-trace'
|
import { initializeStageThreeRuntimeTraceBridge } from './bridges/stage-three-runtime-trace'
|
||||||
import { useLanguage } from './composables/use-language'
|
import { useLanguage } from './composables/use-language'
|
||||||
|
import { createChatSyncWindowLifecycle } from './stores/chat-sync-lifecycle'
|
||||||
import { useTamagotchiMcpToolsStore } from './stores/mcp-tools'
|
import { useTamagotchiMcpToolsStore } from './stores/mcp-tools'
|
||||||
import { useTamagotchiPluginToolsStore } from './stores/plugin-tools'
|
import { useTamagotchiPluginToolsStore } from './stores/plugin-tools'
|
||||||
import { useServerChannelSettingsStore } from './stores/settings/server-channel'
|
import { useServerChannelSettingsStore } from './stores/settings/server-channel'
|
||||||
@@ -96,6 +97,7 @@ const getMainLocale = useElectronEventaInvoke(i18nGetLocale)
|
|||||||
const setLocale = useElectronEventaInvoke(i18nSetLocale)
|
const setLocale = useElectronEventaInvoke(i18nSetLocale)
|
||||||
const getGodotStageStatus = useElectronEventaInvoke(electronGodotStageGetStatus)
|
const getGodotStageStatus = useElectronEventaInvoke(electronGodotStageGetStatus)
|
||||||
const syncArtistryConfig = useElectronEventaInvoke(artistrySyncConfig)
|
const syncArtistryConfig = useElectronEventaInvoke(artistrySyncConfig)
|
||||||
|
const chatSyncLifecycle = createChatSyncWindowLifecycle(route.path)
|
||||||
const isChatWindowRoute = () => route.path === '/chat'
|
const isChatWindowRoute = () => route.path === '/chat'
|
||||||
const isGodotStageRoute = () => route.path === '/' || route.path.startsWith('/settings')
|
const isGodotStageRoute = () => route.path === '/' || route.path.startsWith('/settings')
|
||||||
const isWidgetsWindowRoute = () => route.path === '/widgets'
|
const isWidgetsWindowRoute = () => route.path === '/widgets'
|
||||||
@@ -196,6 +198,8 @@ context.value.on(electronGodotStageStatusChanged, (event) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
chatSyncLifecycle.initialize()
|
||||||
|
|
||||||
// NOTICE: Issue #1658
|
// NOTICE: Issue #1658
|
||||||
// When Electron restarts, renderer localStorage may not be flushed to disk.
|
// When Electron restarts, renderer localStorage may not be flushed to disk.
|
||||||
// The store's onMounted hook falls back to navigator.language, which triggers
|
// The store's onMounted hook falls back to navigator.language, which triggers
|
||||||
@@ -256,6 +260,10 @@ onMounted(async () => {
|
|||||||
inferencePreload.triggerPreload()
|
inferencePreload.triggerPreload()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
chatSyncLifecycle.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
watch(themeColorsHue, () => {
|
watch(themeColorsHue, () => {
|
||||||
document.documentElement.style.setProperty('--chromatic-hue', themeColorsHue.value.toString())
|
document.documentElement.style.setProperty('--chromatic-hue', themeColorsHue.value.toString())
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|||||||
@@ -1,20 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, onUnmounted } from 'vue'
|
|
||||||
|
|
||||||
import InteractiveArea from '../components/InteractiveArea.vue'
|
import InteractiveArea from '../components/InteractiveArea.vue'
|
||||||
import WindowTitleBar from '../components/Window/TitleBar.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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -406,7 +406,6 @@ watch(enabled, async (val) => {
|
|||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
chatSyncStore.initialize('authority')
|
|
||||||
if (onboardingStore.needsOnboarding) {
|
if (onboardingStore.needsOnboarding) {
|
||||||
openOnboarding()
|
openOnboarding()
|
||||||
}
|
}
|
||||||
@@ -418,7 +417,6 @@ onUnmounted(() => {
|
|||||||
ownerInstanceId: modelSettingsRuntimeOwnerInstanceId,
|
ownerInstanceId: modelSettingsRuntimeOwnerInstanceId,
|
||||||
})
|
})
|
||||||
stopAudioInteraction()
|
stopAudioInteraction()
|
||||||
chatSyncStore.dispose()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(stream, async (currentStream) => {
|
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