diff --git a/apps/stage-pocket/src/App.vue b/apps/stage-pocket/src/App.vue index 41c6a767a..de839f99f 100644 --- a/apps/stage-pocket/src/App.vue +++ b/apps/stage-pocket/src/App.vue @@ -2,6 +2,7 @@ import { OnboardingDialog, OnboardingStepAnalyticsNotice, ToasterRoot } from '@proj-airi/stage-ui/components' import { useAuthProviderSync } from '@proj-airi/stage-ui/composables/use-auth-provider-sync' import { initializeAnalytics, isAnalyticsAvailableInBuild } from '@proj-airi/stage-ui/libs/analytics' +import { usePiniaSynced } from '@proj-airi/stage-ui/libs/pinia' import { useCharacterOrchestratorStore } from '@proj-airi/stage-ui/stores/character' import { useDisplayModelsStore } from '@proj-airi/stage-ui/stores/display-models' import { useModsServerChannelStore } from '@proj-airi/stage-ui/stores/mods/api/channel-server' @@ -35,6 +36,7 @@ const settingsAudioDeviceStore = useSettingsAudioDevice() const { showingSetup } = storeToRefs(onboardingStore) const { isDark } = useTheme() const cardStore = useAiriCardStore() +const syncedPinia = usePiniaSynced() const primaryColor = computed(() => { return isDark.value @@ -74,7 +76,8 @@ watch(settings.themeColorsHueDynamic, () => { onMounted(async () => { initializeAnalytics() await displayModelsStore.initialize() - cardStore.initialize() + cardStore.startRuntime(syncedPinia) + await cardStore.initialize() if (onboardingStore.needsOnboarding) { onboardingStore.showingSetup = true @@ -93,6 +96,7 @@ onMounted(async () => { }) onUnmounted(() => { + cardStore.disposeRuntime() contextBridgeStore.dispose() }) diff --git a/apps/stage-pocket/src/main.ts b/apps/stage-pocket/src/main.ts index f7dc182ea..ba2eecb67 100644 --- a/apps/stage-pocket/src/main.ts +++ b/apps/stage-pocket/src/main.ts @@ -8,6 +8,7 @@ import { autoAnimatePlugin } from '@formkit/auto-animate/vue' import { isEnvTruthy } from '@proj-airi/stage-shared' import { trackButtonPlugin } from '@proj-airi/stage-ui/directives/track-button' import { configureAnalyticsAdapter } from '@proj-airi/stage-ui/libs/analytics' +import { setupSynced } from '@proj-airi/stage-ui/libs/pinia' import { MotionPlugin } from '@vueuse/motion' import { createPinia } from 'pinia' import { setupLayouts } from 'virtual:generated-layouts' @@ -34,6 +35,8 @@ configureAnalyticsAdapter(async (options) => { }) const pinia = createPinia() +const synced = setupSynced() +pinia.use(synced.pinia) // TODO: vite-plugin-vue-layouts is long deprecated, replace with another layout solution const routeRecords = setupLayouts(routes as RouteRecordRaw[]) @@ -60,6 +63,7 @@ window.addEventListener('unhandledrejection', (event) => { installDeepLinks(router) createApp(App) + .use(synced.vue) .use(MotionPlugin) // TODO: Fix autoAnimatePlugin type error .use(autoAnimatePlugin as unknown as Plugin) diff --git a/apps/stage-tamagotchi/src/main/libs/electron/location.test.ts b/apps/stage-tamagotchi/src/main/libs/electron/location.test.ts index 44e5e6f3c..5800282b0 100644 --- a/apps/stage-tamagotchi/src/main/libs/electron/location.test.ts +++ b/apps/stage-tamagotchi/src/main/libs/electron/location.test.ts @@ -25,4 +25,24 @@ describe('withHashRoute', () => { const result = withHashRoute({ url: 'file:////home/workspace/project/index.html' }, '/test/inner-test') expect(result).toEqual({ url: `file:////home/workspace/project/index.html#/test/inner-test` }) }) + + it('adds query options before the hash route for development URLs', () => { + expect(withHashRoute({ url: 'http://localhost:5173' }, '/about', { + query: { 'synced-leader': 'false' }, + })).toEqual({ + url: 'http://localhost:5173/?synced-leader=false#/about', + }) + }) + + it('passes query options to Electron for packaged renderer URLs', () => { + expect(withHashRoute({ file: '/opt/airi/renderer/index.html' }, '/settings', { + query: { 'synced-leader': 'false' }, + })).toEqual({ + file: '/opt/airi/renderer/index.html', + options: { + hash: '/settings', + query: { 'synced-leader': 'false' }, + }, + }) + }) }) diff --git a/apps/stage-tamagotchi/src/main/libs/electron/location.ts b/apps/stage-tamagotchi/src/main/libs/electron/location.ts index 5e4e97021..1c1c721a1 100644 --- a/apps/stage-tamagotchi/src/main/libs/electron/location.ts +++ b/apps/stage-tamagotchi/src/main/libs/electron/location.ts @@ -92,9 +92,19 @@ export async function load(window: BrowserWindow, url: string | { url: string, o } /** - * A helper function to construct URL with hash route, which is commonly used in our app since we are using hash-based routing in renderer. + * Adds a hash route and optional query to an Electron renderer location. + * + * @example + * withHashRoute({ url: 'http://localhost:5173' }, '/about', { + * query: { 'synced-leader': 'false' }, + * }) + * // => { url: 'http://localhost:5173/?synced-leader=false#/about' } */ -export function withHashRoute(baseUrl: string | { url: string } | { file: string }, hashRoute: string) { +export function withHashRoute( + baseUrl: string | { url: string } | { file: string }, + hashRoute: string, + options: Pick = {}, +) { if (typeof baseUrl === 'object' && 'url' in baseUrl) { // trim `/` suffix const baseURLinURL = new URL(baseUrl.url) @@ -103,12 +113,15 @@ export function withHashRoute(baseUrl: string | { url: string } | { file: string const trimmedPathname = pathname.endsWith('/') ? pathname.slice(0, -1) : pathname baseURLinURL.pathname = trimmedPathname + for (const [key, value] of Object.entries(options.query ?? {})) + baseURLinURL.searchParams.set(key, value) + baseURLinURL.hash = hashRoute return { url: baseURLinURL.toString() } satisfies { url: string, options?: LoadURLOptions } } if (typeof baseUrl === 'object' && 'file' in baseUrl) { - return { file: `${baseUrl.file}`, options: { hash: hashRoute } } satisfies { file: string, options?: LoadFileOptions } + return { file: `${baseUrl.file}`, options: { hash: hashRoute, ...options } } satisfies { file: string, options?: LoadFileOptions } } // trim `/` suffix @@ -118,6 +131,9 @@ export function withHashRoute(baseUrl: string | { url: string } | { file: string const trimmedPathname = pathname.endsWith('/') ? pathname.slice(0, -1) : pathname baseURLinURL.pathname = trimmedPathname + for (const [key, value] of Object.entries(options.query ?? {})) + baseURLinURL.searchParams.set(key, value) + baseURLinURL.hash = hashRoute return { url: baseURLinURL.toString() } satisfies { url: string, options?: LoadURLOptions } diff --git a/apps/stage-tamagotchi/src/main/windows/about/index.ts b/apps/stage-tamagotchi/src/main/windows/about/index.ts index 0306df29d..52fe4abe5 100644 --- a/apps/stage-tamagotchi/src/main/windows/about/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/about/index.ts @@ -44,7 +44,9 @@ export function setupAboutWindowReusable(params: { serverChannel: params.serverChannel, }) - await load(window, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/about')) + await load(window, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/about', { + query: { 'synced-leader': 'false' }, + })) return window }).getWindow diff --git a/apps/stage-tamagotchi/src/main/windows/caption/index.ts b/apps/stage-tamagotchi/src/main/windows/caption/index.ts index 0b822d44c..5e254ee74 100644 --- a/apps/stage-tamagotchi/src/main/windows/caption/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/caption/index.ts @@ -344,7 +344,9 @@ export function setupCaptionWindowManager(params: { const cleanupGetAttached = defineInvokeHandler(context, captionGetIsFollowingWindow, async () => isFollowing) - await load(window, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/caption')) + await load(window, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/caption', { + query: { 'synced-leader': 'false' }, + })) try { context.emit(captionIsFollowingWindowChanged, isFollowing) diff --git a/apps/stage-tamagotchi/src/main/windows/chat/index.ts b/apps/stage-tamagotchi/src/main/windows/chat/index.ts index c3dca9ddc..52aede3ca 100644 --- a/apps/stage-tamagotchi/src/main/windows/chat/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/chat/index.ts @@ -44,7 +44,12 @@ export function setupChatWindowReusableFunc(params: { i18n: params.i18n, }) - await load(window, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/chat')) + await load(window, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/chat', { + query: { + 'stage-runtime': 'minimal', + 'synced-leader': 'false', + }, + })) return window }).getWindow diff --git a/apps/stage-tamagotchi/src/main/windows/dashboard/index.ts b/apps/stage-tamagotchi/src/main/windows/dashboard/index.ts index cdb4205cc..c52a9498e 100644 --- a/apps/stage-tamagotchi/src/main/windows/dashboard/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/dashboard/index.ts @@ -137,7 +137,9 @@ export async function setupDashboardWindow(params: { serverChannel: params.serverChannel, }) - await load(window, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/dashboard')) + await load(window, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/dashboard', { + query: { 'synced-leader': 'false' }, + })) /** * This is a know issue (or expected behavior maybe) to Electron. diff --git a/apps/stage-tamagotchi/src/main/windows/desktop-overlay/index.ts b/apps/stage-tamagotchi/src/main/windows/desktop-overlay/index.ts index e2c81e92c..eef5f658f 100644 --- a/apps/stage-tamagotchi/src/main/windows/desktop-overlay/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/desktop-overlay/index.ts @@ -123,6 +123,7 @@ export async function setupDesktopOverlayWindow(params: { isDesktopOverlayPollHeartbeatEnabled() ? `/desktop-overlay?${desktopOverlayPollHeartbeatQueryParam}=1` : '/desktop-overlay', + { query: { 'synced-leader': 'false' } }, ), ) diff --git a/apps/stage-tamagotchi/src/main/windows/devtools/index.ts b/apps/stage-tamagotchi/src/main/windows/devtools/index.ts index ef635d6f8..17e9bf965 100644 --- a/apps/stage-tamagotchi/src/main/windows/devtools/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/devtools/index.ts @@ -50,7 +50,9 @@ export function setupDevtoolsWindow(): DevtoolsWindowManager { }) protectPrivilegedWindowNavigation(window) - await load(window, withHashRoute(rendererBase, route)) + await load(window, withHashRoute(rendererBase, route, { + query: { 'synced-leader': 'false' }, + })) return window }) diff --git a/apps/stage-tamagotchi/src/main/windows/editor/index.ts b/apps/stage-tamagotchi/src/main/windows/editor/index.ts index f63caf13c..b724e6779 100644 --- a/apps/stage-tamagotchi/src/main/windows/editor/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/editor/index.ts @@ -55,7 +55,12 @@ export function setupEditorWindowManager(params: { i18n: params.i18n, serverChannel: params.serverChannel, }) - await load(window, withHashRoute(rendererBase, '/editor')) + await load(window, withHashRoute(rendererBase, '/editor', { + query: { + 'stage-runtime': 'minimal', + 'synced-leader': 'false', + }, + })) return window }) diff --git a/apps/stage-tamagotchi/src/main/windows/inlay/index.ts b/apps/stage-tamagotchi/src/main/windows/inlay/index.ts index 5e5c431a7..154d809ae 100644 --- a/apps/stage-tamagotchi/src/main/windows/inlay/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/inlay/index.ts @@ -66,7 +66,9 @@ export async function setupInlayWindow(params: { await setupInlayWindowInvokes({ inlayWindow: window, serverChannel: params.serverChannel, i18n: params.i18n }) - await load(window, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/inlay')) + await load(window, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/inlay', { + query: { 'synced-leader': 'false' }, + })) return window } diff --git a/apps/stage-tamagotchi/src/main/windows/main/index.ts b/apps/stage-tamagotchi/src/main/windows/main/index.ts index 708736e77..6e9b32590 100644 --- a/apps/stage-tamagotchi/src/main/windows/main/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/main/index.ts @@ -29,7 +29,7 @@ import icon from '../../../../resources/icon.png?asset' import { electronStartDraggingWindow } from '../../../shared/eventa' import { onAppBeforeQuit } from '../../libs/bootkit/lifecycle' -import { baseUrl, getElectronMainDirname, load } from '../../libs/electron/location' +import { baseUrl, getElectronMainDirname, load, withHashRoute } from '../../libs/electron/location' import { createConfig } from '../../libs/electron/persistence' import { protectPrivilegedWindowNavigation, transparentWindowConfig } from '../shared' import { setupMainWindowElectronInvokes } from './rpc/index.electron' @@ -186,7 +186,9 @@ export async function setupMainWindow(params: { onboardingWindowManager: params.onboardingWindowManager, }) - await load(window, baseUrl(resolve(getElectronMainDirname(), '..', 'renderer'))) + await load(window, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/', { + query: { 'synced-leader': 'true' }, + })) /** * This is a know issue (or expected behavior maybe) to Electron. diff --git a/apps/stage-tamagotchi/src/main/windows/notice/index.ts b/apps/stage-tamagotchi/src/main/windows/notice/index.ts index 7087e4de4..f1acb1e26 100644 --- a/apps/stage-tamagotchi/src/main/windows/notice/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/notice/index.ts @@ -47,7 +47,9 @@ export function setupNoticeWindowManager(params: { async function loadNoticeRoute(window: BrowserWindow, payload: RequestWindowPayload & { id: string }) { const routeWithId = `${payload.route}?id=${payload.id}` - await load(window, withHashRoute(rendererBase, routeWithId)) + await load(window, withHashRoute(rendererBase, routeWithId, { + query: { 'synced-leader': 'false' }, + })) } const manager = createReferencedWindowManager({ diff --git a/apps/stage-tamagotchi/src/main/windows/onboarding/index.ts b/apps/stage-tamagotchi/src/main/windows/onboarding/index.ts index cbb1d71c9..722f8981f 100644 --- a/apps/stage-tamagotchi/src/main/windows/onboarding/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/onboarding/index.ts @@ -74,7 +74,9 @@ export function setupOnboardingWindowManager(params: { await setupBaseWindowElectronInvokes({ context, window: newWindow, i18n: params.i18n, serverChannel: params.serverChannel }) createAuthService({ context, window: newWindow }) - await load(newWindow, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/onboarding')) + await load(newWindow, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/onboarding', { + query: { 'synced-leader': 'false' }, + })) newWindow.on('closed', () => { for (const cb of closeCallbacks) { diff --git a/apps/stage-tamagotchi/src/main/windows/settings/index.ts b/apps/stage-tamagotchi/src/main/windows/settings/index.ts index 2ffc7310e..fe449364e 100644 --- a/apps/stage-tamagotchi/src/main/windows/settings/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/settings/index.ts @@ -78,7 +78,9 @@ export function setupSettingsWindowReusableFunc(params: { spotlightWindow: params.spotlightWindow, }) - await load(window, withHashRoute(rendererBase, currentRoute)) + await load(window, withHashRoute(rendererBase, currentRoute, { + query: { 'synced-leader': 'false' }, + })) window.on('closed', () => { if (settingsContext) diff --git a/apps/stage-tamagotchi/src/main/windows/spotlight/index.ts b/apps/stage-tamagotchi/src/main/windows/spotlight/index.ts index 36e28083f..4979f45b1 100644 --- a/apps/stage-tamagotchi/src/main/windows/spotlight/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/spotlight/index.ts @@ -140,7 +140,12 @@ export function setupSpotlightWindowManager(params: { showNotification(payload.body, () => void openChatWindowFromNotification()) }) - await load(window, withHashRoute(rendererBase, '/spotlight')) + await load(window, withHashRoute(rendererBase, '/spotlight', { + query: { + 'stage-runtime': 'minimal', + 'synced-leader': 'false', + }, + })) return window }) diff --git a/apps/stage-tamagotchi/src/main/windows/widgets/index.ts b/apps/stage-tamagotchi/src/main/windows/widgets/index.ts index 997405c0d..0a3cceb8a 100644 --- a/apps/stage-tamagotchi/src/main/windows/widgets/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/widgets/index.ts @@ -440,7 +440,9 @@ export function setupWidgetsWindowManager(params: { } async function loadWithRoute(window: BrowserWindow, route: string) { - await load(window, withHashRoute(rendererBase, route)) + await load(window, withHashRoute(rendererBase, route, { + query: { 'synced-leader': 'false' }, + })) currentRoute = route } diff --git a/apps/stage-tamagotchi/src/renderer/App.vue b/apps/stage-tamagotchi/src/renderer/App.vue index 845388cc4..b3b415108 100644 --- a/apps/stage-tamagotchi/src/renderer/App.vue +++ b/apps/stage-tamagotchi/src/renderer/App.vue @@ -62,7 +62,7 @@ import { useTamagotchiMcpToolsStore, useTamagotchiPluginToolsStore, } from './stores/tools' -import { resolveInitialWindowRoutePath } from './window-route' +import { resolveInitialRendererRoutePath, resolveRendererWindowContext } from './window-context' const { isDark: dark } = useTheme() const settingsStore = useSettings() @@ -73,20 +73,17 @@ const chatSessionStore = useChatSessionStore() const context = useElectronEventaContext() const getMainLocale = useElectronEventaInvoke(i18nGetLocale) const setLocale = useElectronEventaInvoke(i18nSetLocale) -const initialWindowRoutePath = resolveInitialWindowRoutePath(route.path) +const windowContext = resolveRendererWindowContext() +const initialRoutePath = resolveInitialRendererRoutePath(route.path) useChatStore() const builtinToolsStore = useTamagotchiBuiltinToolsStore() const mcpToolsStore = useTamagotchiMcpToolsStore() const pluginToolsStore = useTamagotchiPluginToolsStore() const syncedPinia = usePiniaSynced() chatSessionStore.setCloudSyncOwnership(syncedPinia.isLeader()) -const isSpotlightWindowRoute = initialWindowRoutePath === '/spotlight' -const isSettingsWindowRoute = initialWindowRoutePath === '/settings' || initialWindowRoutePath.startsWith('/settings/') -const isEditorWindowRoute = initialWindowRoutePath === '/editor' +const isSpotlightWindow = initialRoutePath === '/spotlight' +const isSettingsWindow = initialRoutePath === '/settings' || initialRoutePath.startsWith('/settings/') -// Every renderer participates in leader election. Keep provider state ready in -// auxiliary windows so a newly elected leader can execute chat actions after -// the previous window closes. useAuthProviderSync() async function refreshPluginRuntimeTools() { @@ -98,8 +95,8 @@ async function refreshPluginRuntimeTools() { } } -// Every renderer creates the runtime tool stores because every renderer can -// become the leader. Only the leader discovers tools and keeps executors. +// Every renderer creates the runtime tool stores for synchronized state. Only +// the main Stage renderer discovers tools and keeps executors. const stopLeadershipListener = syncedPinia.onLeadershipChange((isLeader) => { chatSessionStore.setCloudSyncOwnership(isLeader) if (!isLeader) @@ -139,9 +136,8 @@ function createFullStageRuntime() { const reportPluginCapability = useElectronEventaInvoke(electronPluginUpdateCapability) const getGodotStageStatus = useElectronEventaInvoke(electronGodotStageGetStatus) const syncArtistryConfig = useElectronEventaInvoke(artistrySyncConfig) - const isAuxiliaryChatRoute = initialWindowRoutePath === '/chat' - const isGodotStageRoute = () => route.path === '/' || route.path.startsWith('/settings') - const isWidgetsWindowRoute = () => route.path === '/widgets' + const usesGodotStage = initialRoutePath === '/' || initialRoutePath.startsWith('/settings') + const isWidgetsWindow = initialRoutePath === '/widgets' function syncGodotStageRenderer(state: { state: 'stopped' | 'starting' | 'running' | 'stopping' | 'error' }) { if (state.state === 'running') { @@ -155,12 +151,13 @@ function createFullStageRuntime() { usePerfTracerBridgeStore() initializeStageThreeRuntimeTraceBridge() + // The main process returns the callback only to the renderer that started + // sign-in. Each login-capable window listens locally, while the synchronized + // auth action still executes once in the main Stage leader. initializeElectronAuthCallbackBridge() void stageWindowLifecycleStore.initializeWindowLifecycleBridge() - watch(() => route.path, () => { - contextBridgeStore.setSparkNotifyHostRole(isWidgetsWindowRoute() ? 'client' : 'main') - }, { immediate: true }) + contextBridgeStore.setSparkNotifyHostRole(isWidgetsWindow ? 'client' : 'main') // NOTICE: register plugin host bridge during setup to avoid race with pages using it in immediate watchers. pluginHostInspectorStore.setBridge({ @@ -217,13 +214,14 @@ function createFullStageRuntime() { async initialize() { initializeAnalytics() await displayModelsStore.initialize() - cardStore.initialize() + cardStore.startRuntime(syncedPinia) + await cardStore.initialize() await displayModelsStore.loadDisplayModelsFromIndexedDB() await settingsStore.initializeStageModel() await settingsAudioDeviceStore.initialize() - if (isGodotStageRoute()) { + if (usesGodotStage) { try { syncGodotStageRenderer(await getGodotStageStatus()) } @@ -241,12 +239,10 @@ function createFullStageRuntime() { token: serverChannelConfig.authToken || undefined, possibleEvents: ['ui:configure'], }).catch(err => console.error('Failed to initialize Mods Server Channel in App.vue:', err)) - if (!isAuxiliaryChatRoute) { - contextBridgeStore.initialize() - if (!isWidgetsWindowRoute()) { - characterOrchestratorStore.initialize() - await startTrackingCursorPoint() - } + contextBridgeStore.initialize() + if (!isWidgetsWindow) { + characterOrchestratorStore.initialize() + await startTrackingCursorPoint() } defineInvokeHandler(context.value, pluginProtocolListProviders, async () => listProvidersForPluginHost()) @@ -264,13 +260,15 @@ function createFullStageRuntime() { inferencePreload.triggerPreload() }, dispose() { - if (!isAuxiliaryChatRoute) - contextBridgeStore.dispose() + cardStore.disposeRuntime() + contextBridgeStore.dispose() }, } } -const fullStageRuntime = isSpotlightWindowRoute || isEditorWindowRoute ? null : createFullStageRuntime() +const fullStageRuntime = windowContext.stageRuntime === 'full' + ? createFullStageRuntime() + : null const { restore: restoreLocale } = useLanguage(language, getMainLocale, setLocale) @@ -279,7 +277,7 @@ watch(dark, () => updateThemeColor(), { immediate: true }) watch(route, () => updateThemeColor(), { immediate: true }) onMounted(() => updateThemeColor()) -if (isSettingsWindowRoute) { +if (isSettingsWindow) { context.value.on(electronSettingsNavigate, (event) => { const targetRoute = event?.body?.route if (!targetRoute || route.fullPath === targetRoute) { @@ -324,7 +322,7 @@ onUnmounted(() => { - + diff --git a/apps/stage-tamagotchi/src/renderer/main.ts b/apps/stage-tamagotchi/src/renderer/main.ts index a42a0f198..9354bce3b 100644 --- a/apps/stage-tamagotchi/src/renderer/main.ts +++ b/apps/stage-tamagotchi/src/renderer/main.ts @@ -18,6 +18,7 @@ import { handleHotUpdate, routes } from 'vue-router/auto-routes' import App from './App.vue' import { i18n } from './modules/i18n' +import { resolveRendererWindowContext } from './window-context' import '@unocss/reset/tailwind.css' import 'splitpanes/dist/splitpanes.css' @@ -45,7 +46,9 @@ configureAnalyticsAdapter(async (options) => { }) const pinia = createPinia() -const synced = setupSynced() +const synced = setupSynced({ + leadership: resolveRendererWindowContext().leadership, +}) pinia.use(synced.pinia) pinia.use(piniaPluginTracing) diff --git a/apps/stage-tamagotchi/src/renderer/window-context.test.ts b/apps/stage-tamagotchi/src/renderer/window-context.test.ts new file mode 100644 index 000000000..cebfa5314 --- /dev/null +++ b/apps/stage-tamagotchi/src/renderer/window-context.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' + +import { resolveInitialRendererRoutePath, resolveRendererWindowContext } from './window-context' + +describe('resolveInitialRendererRoutePath', () => { + // ROOT CAUSE: + // + // Vue Router reports `/` during App setup, before it hydrates the hash + // route. Window-specific setup therefore used the main-window behavior in + // widgets and settings renderers. + // + // https://github.com/moeru-ai/airi/pull/2304 + it('uses the hash route before Vue Router hydrates', () => { + expect(resolveInitialRendererRoutePath('/', '#/widgets')).toBe('/widgets') + expect(resolveInitialRendererRoutePath('/', '#/settings/providers?source=tray')).toBe('/settings/providers') + }) + + it('uses the router path when no hash route exists', () => { + expect(resolveInitialRendererRoutePath('/settings/data', '')).toBe('/settings/data') + }) +}) + +describe('resolveRendererWindowContext', () => { + it('assigns synchronized leadership from the explicit query', () => { + expect(resolveRendererWindowContext('?synced-leader=true')).toMatchObject({ + leadership: 'leader-only', + }) + expect(resolveRendererWindowContext('?synced-leader=false')).toMatchObject({ + leadership: 'follower-only', + }) + }) + + it('uses the full Stage runtime unless the query selects the minimal runtime', () => { + expect(resolveRendererWindowContext('?synced-leader=true').stageRuntime).toBe('full') + expect(resolveRendererWindowContext('?synced-leader=false').stageRuntime).toBe('full') + expect(resolveRendererWindowContext('?synced-leader=false&stage-runtime=minimal').stageRuntime).toBe('minimal') + }) + + it('rejects a renderer URL without an explicit leadership query', () => { + expect(() => resolveRendererWindowContext('')).toThrow('Missing synced-leader query') + expect(() => resolveRendererWindowContext('?synced-leader=unknown')).toThrow('Invalid synced-leader query: unknown') + expect(() => resolveRendererWindowContext('?synced-leader=false&stage-runtime=unknown')).toThrow('Invalid stage-runtime query: unknown') + }) +}) diff --git a/apps/stage-tamagotchi/src/renderer/window-context.ts b/apps/stage-tamagotchi/src/renderer/window-context.ts new file mode 100644 index 000000000..602e2b3b0 --- /dev/null +++ b/apps/stage-tamagotchi/src/renderer/window-context.ts @@ -0,0 +1,51 @@ +import type { LeadershipMode } from '@proj-airi/stage-ui/libs/pinia' + +/** Describes the synchronization and Stage runtime policy for one renderer. */ +export interface RendererWindowContext { + /** Determines whether this renderer can own synchronized actions. */ + leadership: LeadershipMode + /** Determines whether this renderer initializes Stage integrations. */ + stageRuntime: 'full' | 'minimal' +} + +function normalizeRoutePath(routePath: string) { + const [path = ''] = routePath.split(/[?#]/) + return path || '/' +} + +/** + * Resolves the initial renderer route before Vue Router hydrates the hash. + * + * @example + * resolveInitialRendererRoutePath('/', '#/widgets?source=tray') + * // => '/widgets' + */ +export function resolveInitialRendererRoutePath(routePath: string, hash = globalThis.location?.hash ?? ''): string { + const hashPath = hash.startsWith('#') ? hash.slice(1) : '' + return normalizeRoutePath(hashPath || routePath) +} + +/** + * Resolves renderer ownership from the query that the main process supplies. + * + * @example + * resolveRendererWindowContext('?synced-leader=false&stage-runtime=minimal') + * // => { leadership: 'follower-only', stageRuntime: 'minimal' } + */ +export function resolveRendererWindowContext(search = globalThis.location?.search ?? ''): RendererWindowContext { + const query = new URLSearchParams(search) + const syncedLeader = query.get('synced-leader') + if (syncedLeader === null) + throw new TypeError('Missing synced-leader query') + if (syncedLeader !== 'true' && syncedLeader !== 'false') + throw new TypeError(`Invalid synced-leader query: ${syncedLeader}`) + + const stageRuntime = query.get('stage-runtime') + if (stageRuntime !== null && stageRuntime !== 'minimal') + throw new TypeError(`Invalid stage-runtime query: ${stageRuntime}`) + + return { + leadership: syncedLeader === 'true' ? 'leader-only' : 'follower-only', + stageRuntime: stageRuntime === 'minimal' ? 'minimal' : 'full', + } +} diff --git a/apps/stage-tamagotchi/src/renderer/window-route.test.ts b/apps/stage-tamagotchi/src/renderer/window-route.test.ts deleted file mode 100644 index f22b14d78..000000000 --- a/apps/stage-tamagotchi/src/renderer/window-route.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { resolveInitialWindowRoutePath } from './window-route' - -describe('resolveInitialWindowRoutePath', () => { - it('uses the hash route before Vue Router hydrates', () => { - expect(resolveInitialWindowRoutePath('/', '#/chat?source=tray')).toBe('/chat') - }) - - it('uses the router path when no hash route exists', () => { - expect(resolveInitialWindowRoutePath('/settings/data', '')).toBe('/settings/data') - }) -}) diff --git a/apps/stage-tamagotchi/src/renderer/window-route.ts b/apps/stage-tamagotchi/src/renderer/window-route.ts deleted file mode 100644 index cd5b307c2..000000000 --- a/apps/stage-tamagotchi/src/renderer/window-route.ts +++ /dev/null @@ -1,10 +0,0 @@ -function normalizeRoutePath(routePath: string) { - const [path = ''] = routePath.split(/[?#]/) - return path || '/' -} - -/** Resolves the initial hash route before Vue Router hydrates `route.path`. */ -export function resolveInitialWindowRoutePath(routePath: string, hash = globalThis.location?.hash ?? '') { - const hashPath = hash.startsWith('#') ? hash.slice(1) : '' - return normalizeRoutePath(hashPath || routePath) -} diff --git a/apps/stage-web/src/App.vue b/apps/stage-web/src/App.vue index 96aae0266..4a3ec9e3c 100644 --- a/apps/stage-web/src/App.vue +++ b/apps/stage-web/src/App.vue @@ -89,7 +89,8 @@ watch(settings.themeColorsHueDynamic, () => { onMounted(async () => { initializeAnalytics() await displayModelsStore.initialize() - cardStore.initialize() + cardStore.startRuntime(syncedPinia) + await cardStore.initialize() if (onboardingStore.needsOnboarding) { onboardingStore.showingSetup = true @@ -110,6 +111,7 @@ onMounted(async () => { onUnmounted(() => { stopLeadershipListener() + cardStore.disposeRuntime() contextBridgeStore.dispose() }) diff --git a/packages/stage-ui/src/composables/use-auth-provider-sync.test.ts b/packages/stage-ui/src/composables/use-auth-provider-sync.test.ts index e8fba0ed8..78a7b4f30 100644 --- a/packages/stage-ui/src/composables/use-auth-provider-sync.test.ts +++ b/packages/stage-ui/src/composables/use-auth-provider-sync.test.ts @@ -4,6 +4,7 @@ import { OFFICIAL_TRANSCRIPTION_PROVIDER_ID } from '../libs/providers' import { useAuthProviderSync } from './use-auth-provider-sync' const syncState = vi.hoisted(() => ({ + isLeader: false, authenticatedHook: undefined as (() => Promise) | undefined, logoutHook: undefined as (() => void) | undefined, activeProvider: '', @@ -20,6 +21,8 @@ const syncState = vi.hoisted(() => ({ const syncMocks = vi.hoisted(() => ({ initializeAuth: vi.fn(async () => {}), leadershipHook: undefined as ((isLeader: boolean) => void) | undefined, + disposeAuthenticatedHook: vi.fn(), + disposeLogoutHook: vi.fn(), forceProviderConfigured: vi.fn(), setProviderUnconfigured: vi.fn(), setProviderAvailabilityOverride: vi.fn(), @@ -35,6 +38,7 @@ vi.mock('../libs/auth', () => ({ vi.mock('../libs/pinia', () => ({ usePiniaSynced: () => ({ + isLeader: () => syncState.isLeader, onLeadershipChange: (hook: (isLeader: boolean) => void) => { syncMocks.leadershipHook = hook return vi.fn() @@ -51,9 +55,17 @@ vi.mock('../stores/auth', () => ({ useAuthStore: () => ({ onAuthenticated: (hook: () => Promise) => { syncState.authenticatedHook = hook + return () => { + syncState.authenticatedHook = undefined + syncMocks.disposeAuthenticatedHook() + } }, onLogout: (hook: () => void) => { syncState.logoutHook = hook + return () => { + syncState.logoutHook = undefined + syncMocks.disposeLogoutHook() + } }, }), })) @@ -118,6 +130,7 @@ vi.mock('./use-analytics', () => ({ describe('useAuthProviderSync', () => { beforeEach(() => { + syncState.isLeader = false syncState.authenticatedHook = undefined syncState.logoutHook = undefined syncMocks.leadershipHook = undefined @@ -134,17 +147,57 @@ describe('useAuthProviderSync', () => { syncMocks.fetchModelsForProvider.mockResolvedValue([]) }) - it('restores auth initialization when this renderer becomes the leader', async () => { + it('starts auth initialization when this renderer becomes the leader', async () => { useAuthProviderSync() - expect(syncMocks.initializeAuth).toHaveBeenCalledTimes(1) + expect(syncMocks.initializeAuth).not.toHaveBeenCalled() + syncState.isLeader = true syncMocks.leadershipHook?.(true) await Promise.resolve() + expect(syncMocks.initializeAuth).toHaveBeenCalledTimes(1) + }) + + it('does not activate providers in a follower renderer', () => { + useAuthProviderSync() + + expect(syncState.authenticatedHook).toBeUndefined() + expect(syncMocks.initializeAuth).not.toHaveBeenCalled() + expect(syncMocks.forceProviderConfigured).not.toHaveBeenCalled() + }) + + // ROOT CAUSE: + // + // A renderer kept its auth hooks after it lost leadership. Those hooks + // could mutate provider state while the new leader handled the same auth + // transition. + // + // https://github.com/moeru-ai/airi/pull/2304 + it('removes auth hooks on demotion and restores them after reacquiring leadership', () => { + syncState.isLeader = true + useAuthProviderSync() + + expect(syncState.authenticatedHook).toBeDefined() + expect(syncState.logoutHook).toBeDefined() + + syncState.isLeader = false + syncMocks.leadershipHook?.(false) + + expect(syncMocks.disposeAuthenticatedHook).toHaveBeenCalledTimes(1) + expect(syncMocks.disposeLogoutHook).toHaveBeenCalledTimes(1) + expect(syncState.authenticatedHook).toBeUndefined() + expect(syncState.logoutHook).toBeUndefined() + + syncState.isLeader = true + syncMocks.leadershipHook?.(true) + expect(syncMocks.initializeAuth).toHaveBeenCalledTimes(2) + expect(syncState.authenticatedHook).toBeDefined() + expect(syncState.logoutHook).toBeDefined() }) it('activates every official provider after direct sign-in when no custom provider is selected', async () => { + syncState.isLeader = true useAuthProviderSync() await syncState.authenticatedHook?.() @@ -165,6 +218,7 @@ describe('useAuthProviderSync', () => { // The auth hook marked the session synchronized before model and streaming // provider bootstrap completed. A transient failure therefore made every // later authentication notification return early for the whole session. + syncState.isLeader = true syncMocks.fetchModelsForProvider.mockImplementation(async (providerId: string) => { if (providerId === 'official-provider-speech-streaming') throw new Error('temporary catalog failure') diff --git a/packages/stage-ui/src/composables/use-auth-provider-sync.ts b/packages/stage-ui/src/composables/use-auth-provider-sync.ts index ddff649ad..b092114d1 100644 --- a/packages/stage-ui/src/composables/use-auth-provider-sync.ts +++ b/packages/stage-ui/src/composables/use-auth-provider-sync.ts @@ -38,15 +38,39 @@ const STREAMING_SPEECH_PROVIDER_ID = 'official-provider-speech-streaming' * auxiliary windows do not depend on the transient Stage scene lifecycle. */ export function useAuthProviderSync() { - void initializeAuth() + const syncedPinia = usePiniaSynced() + let leaderSyncInitialized = false + let disposeAuthenticatedProviderSync: (() => void) | undefined - // A replacement leader has no active refresh timer. Restore the auth - // lifecycle when this renderer acquires leadership after another closes. - usePiniaSynced().onLeadershipChange((isLeader) => { + function initializeLeaderSync() { + if (!syncedPinia.isLeader() || leaderSyncInitialized) + return + + leaderSyncInitialized = true + void initializeAuth() + disposeAuthenticatedProviderSync = setupAuthenticatedProviderSync() + } + + function disposeLeaderSync() { + if (!leaderSyncInitialized) + return + + disposeAuthenticatedProviderSync?.() + disposeAuthenticatedProviderSync = undefined + leaderSyncInitialized = false + } + + syncedPinia.onLeadershipChange((isLeader) => { if (isLeader) - void initializeAuth() + initializeLeaderSync() + else + disposeLeaderSync() }) + initializeLeaderSync() +} + +function setupAuthenticatedProviderSync() { const authStore = useAuthStore() const providersStore = useProviderStore() const consciousnessStore = useConsciousnessStore() @@ -62,7 +86,7 @@ export function useAuthProviderSync() { let authGeneration = 0 let syncInFlight: Promise | undefined - authStore.onAuthenticated(async () => { + const stopAuthenticatedHook = authStore.onAuthenticated(async () => { if (hasSynced) return @@ -202,7 +226,7 @@ export function useAuthProviderSync() { speechStore.activeSpeechVoiceId = '' } - authStore.onLogout(() => { + const stopLogoutHook = authStore.onLogout(() => { authGeneration++ hasSynced = false @@ -249,4 +273,10 @@ export function useAuthProviderSync() { } } }) + + return () => { + authGeneration++ + stopAuthenticatedHook() + stopLogoutHook() + } } diff --git a/packages/stage-ui/src/libs/pinia/setup-synced.ts b/packages/stage-ui/src/libs/pinia/setup-synced.ts index 16d16940c..dc0d58d24 100644 --- a/packages/stage-ui/src/libs/pinia/setup-synced.ts +++ b/packages/stage-ui/src/libs/pinia/setup-synced.ts @@ -1,10 +1,12 @@ import type { PiniaPlugin } from 'pinia' -import type { SyncedPiniaRuntime } from 'pinia-plugin-synced' +import type { SyncedOptions, SyncedPiniaRuntime } from 'pinia-plugin-synced' import type { InjectionKey, Plugin } from 'vue' import { createSyncedPiniaPlugin } from 'pinia-plugin-synced' import { inject } from 'vue' +export type { LeadershipMode } from 'pinia-plugin-synced' + /** Provides the synchronization runtime installed by {@link setupSynced}. */ export const injectKeyPiniaSynced: InjectionKey = Symbol('stage-synced-pinia-runtime') @@ -14,13 +16,17 @@ export const injectKeyPiniaSynced: InjectionKey = Symbol('st * Install both plugins on the same application. The Vue plugin provides the * runtime to components and releases its election channel when the page or * Vue application ends. + * + * @param options Leadership policy for this renderer. Defaults to the plugin's + * follower-preferred mode. */ -export function setupSynced(): { pinia: PiniaPlugin, vue: Plugin } { +export function setupSynced(options: Pick = {}): { pinia: PiniaPlugin, vue: Plugin } { const runtime = createSyncedPiniaPlugin({ namespace: 'airi:stage:pinia', // Chat and image-generation actions can outlive the plugin's 30-second // default. Keep the timeout aligned with the previous Electron coordinator. callTimeout: 5 * 60 * 1000, + ...options, onError(error) { console.error('[stage-synced-pinia] Synchronization failed:', error) }, diff --git a/packages/stage-ui/src/stores/modules/airi-card.test.ts b/packages/stage-ui/src/stores/modules/airi-card.test.ts index 1084f974e..0d9267172 100644 --- a/packages/stage-ui/src/stores/modules/airi-card.test.ts +++ b/packages/stage-ui/src/stores/modules/airi-card.test.ts @@ -1,3 +1,5 @@ +import type { SyncedPiniaRuntime } from 'pinia-plugin-synced' + import type { AiriCard } from './airi-card' import { createPinia, setActivePinia } from 'pinia' @@ -6,6 +8,22 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { useSettingsStageModel } from '../settings/stage-model' import { useAiriCardStore } from './airi-card' +const syncedRuntime = { + isLeader: false, + leadershipListener: undefined as ((isLeader: boolean) => void) | undefined, + stopLeadershipListener: vi.fn(() => { + syncedRuntime.leadershipListener = undefined + }), +} + +const syncedPinia = { + onLeadershipChange(listener) { + syncedRuntime.leadershipListener = listener + listener(syncedRuntime.isLeader) + return syncedRuntime.stopLeadershipListener + }, +} satisfies Pick + // NOTICE: // Vitest runs these store tests in Node, where localforage cannot select a // browser storage driver. The stage-model watcher legitimately asks the @@ -96,6 +114,91 @@ vi.mock('vue-i18n', () => ({ describe('airi-card store', () => { beforeEach(() => { setActivePinia(createPinia()) + syncedRuntime.isLeader = false + syncedRuntime.leadershipListener = undefined + syncedRuntime.stopLeadershipListener.mockClear() + }) + + // ROOT CAUSE: + // + // A follower forwards its startup initialization to the current leader. + // The follower therefore has no local active-card watcher when it becomes + // the next leader. + // + // https://github.com/moeru-ai/airi/pull/2304 + it('reinstalls the card watcher when a follower becomes the leader', async () => { + const stageModelStore = useSettingsStageModel() + const cardStore = useAiriCardStore() + await cardStore.initialize() + + const vrmCardId = cardStore.addCard({ + name: 'VRM card', + version: '1.0.0', + description: 'Card for the promoted leader.', + extensions: { + airi: { + modules: { + consciousness: { provider: 'mock-consciousness-provider', model: 'mock-consciousness-model' }, + vision: { provider: 'mock-vision-provider', model: 'mock-vision-model' }, + speech: { provider: 'mock-speech-provider', model: 'mock-speech-model', voice_id: 'mock-speech-voice' }, + displayModelId: 'preset-vrm-1', + }, + agents: {}, + }, + }, + }, 'scratch') + const live2dCardId = cardStore.addCard({ + name: 'Live2D card', + version: '1.0.0', + description: 'Card for the active leader.', + extensions: { + airi: { + modules: { + consciousness: { provider: 'mock-consciousness-provider', model: 'mock-consciousness-model' }, + vision: { provider: 'mock-vision-provider', model: 'mock-vision-model' }, + speech: { provider: 'mock-speech-provider', model: 'mock-speech-model', voice_id: 'mock-speech-voice' }, + displayModelId: 'preset-live2d-1', + }, + agents: {}, + }, + }, + }, 'scratch') + + stageModelStore.stageModelSelected = 'preset-live2d-1' + cardStore.startRuntime(syncedPinia) + cardStore.activeCardId = vrmCardId + + expect(stageModelStore.stageModelSelected).toBe('preset-live2d-1') + + syncedRuntime.leadershipListener?.(true) + expect(stageModelStore.stageModelSelected).toBe('preset-vrm-1') + + cardStore.activeCardId = live2dCardId + expect(stageModelStore.stageModelSelected).toBe('preset-live2d-1') + + syncedRuntime.leadershipListener?.(false) + cardStore.activeCardId = vrmCardId + expect(stageModelStore.stageModelSelected).toBe('preset-live2d-1') + + cardStore.disposeRuntime() + expect(syncedRuntime.stopLeadershipListener).toHaveBeenCalledTimes(1) + expect(syncedRuntime.leadershipListener).toBeUndefined() + }) + + it('does not create runtime module stores for metadata-only consumers', () => { + const pinia = createPinia() + setActivePinia(pinia) + + // ROOT CAUSE: + // + // The chat session store only reads the active card ID and system prompt, + // but creating the card store also created every runtime module store. + // The speech store then loaded provider voices in each auxiliary window. + useAiriCardStore(pinia) + + expect(pinia.state.value.speech).toBeUndefined() + expect(pinia.state.value.consciousness).toBeUndefined() + expect(pinia.state.value.vision).toBeUndefined() }) /** diff --git a/packages/stage-ui/src/stores/modules/airi-card.ts b/packages/stage-ui/src/stores/modules/airi-card.ts index 8b23439c6..76a71fd3c 100644 --- a/packages/stage-ui/src/stores/modules/airi-card.ts +++ b/packages/stage-ui/src/stores/modules/airi-card.ts @@ -1,10 +1,11 @@ import type { Card, ccv3 } from '@proj-airi/ccc' +import type { SyncedPiniaRuntime } from 'pinia-plugin-synced' import type { AiriCard, AiriExtension } from '../../types/airiCard' import { useLocalStorageManualReset } from '@proj-airi/stage-shared/composables' import { nanoid } from 'nanoid' -import { defineStore, storeToRefs } from 'pinia' +import { defineStore } from 'pinia' import { computed, watch } from 'vue' import { useI18n } from 'vue-i18n' @@ -45,27 +46,15 @@ export const useAiriCardStore = defineStore('airi-card', () => { const activeCard = computed(() => cards.value.get(activeCardId.value)) - const consciousnessStore = useConsciousnessStore() - const visionStore = useVisionStore() - const speechStore = useSpeechStore() - const artistryStore = useArtistryStore() - const stageModelStore = useSettingsStageModel() - - const { - activeProvider: activeConsciousnessProvider, - activeModel: activeConsciousnessModel, - } = storeToRefs(consciousnessStore) - - const { - activeProvider: activeVisionProvider, - activeModel: activeVisionModel, - } = storeToRefs(visionStore) - - const { - activeSpeechProvider, - activeSpeechVoiceId, - activeSpeechModel, - } = storeToRefs(speechStore) + function useRuntimeModuleStores() { + return { + artistry: useArtistryStore(), + consciousness: useConsciousnessStore(), + speech: useSpeechStore(), + stageModel: useSettingsStageModel(), + vision: useVisionStore(), + } + } /** * `source` feeds the `card_created` analytics event: `scratch` = built in @@ -162,6 +151,14 @@ export const useAiriCardStore = defineStore('airi-card', () => { } function resolveAiriExtension(card: Card | ccv3.CharacterCardV3): AiriExtension { + const { + artistry, + consciousness, + speech, + stageModel, + vision, + } = useRuntimeModuleStores() + // Get existing extension if available const existingExtension = ('data' in card ? card.data?.extensions?.airi @@ -170,27 +167,27 @@ export const useAiriCardStore = defineStore('airi-card', () => { // Create default modules config const defaultModules = { consciousness: { - provider: activeConsciousnessProvider.value, - model: activeConsciousnessModel.value, + provider: consciousness.activeProvider, + model: consciousness.activeModel, }, vision: { - provider: activeVisionProvider.value, - model: activeVisionModel.value, + provider: vision.activeProvider, + model: vision.activeModel, }, speech: { - provider: activeSpeechProvider.value, - model: activeSpeechModel.value, - voice_id: activeSpeechVoiceId.value, + provider: speech.activeSpeechProvider, + model: speech.activeSpeechModel, + voice_id: speech.activeSpeechVoiceId, }, - displayModelId: stageModelStore.stageModelSelected, + displayModelId: stageModel.stageModelSelected, artistry: { enabled: false, - provider: artistryStore.globalProvider, - model: artistryStore.globalModel, - promptPrefix: artistryStore.globalPromptPrefix, + provider: artistry.globalProvider, + model: artistry.globalModel, + promptPrefix: artistry.globalPromptPrefix, widgetInstruction: DEFAULT_ARTISTRY_WIDGET_SPAWNING_PROMPT, spawnMode: 'bg_widget' as const, - options: artistryStore.globalProviderOptions, + options: artistry.globalProviderOptions, autonomousEnabled: false, autonomousThreshold: 70, autonomousTarget: 'assistant' as const, @@ -295,7 +292,7 @@ export const useAiriCardStore = defineStore('airi-card', () => { } } - function initialize() { + async function initialize() { if (!cards.value.has('default')) { cards.value.set('default', newAiriCard({ name: 'ReLU', @@ -312,11 +309,19 @@ export const useAiriCardStore = defineStore('airi-card', () => { if (!cards.value.has(activeCardId.value)) activeCardId.value = 'default' - applyActiveCardSettings() + initializeRuntimeModules() } function applyActiveCardSettings(newCard = activeCard.value) { - artistryStore.resetToGlobal() + const { + artistry, + consciousness, + speech, + stageModel, + vision, + } = useRuntimeModuleStores() + + artistry.resetToGlobal() if (!newCard) return @@ -326,41 +331,79 @@ export const useAiriCardStore = defineStore('airi-card', () => { if (!extension) return - activeConsciousnessProvider.value = extension?.modules?.consciousness?.provider - activeConsciousnessModel.value = extension?.modules?.consciousness?.model + consciousness.activeProvider = extension?.modules?.consciousness?.provider + consciousness.activeModel = extension?.modules?.consciousness?.model - activeVisionProvider.value = extension?.modules?.vision?.provider - activeVisionModel.value = extension?.modules?.vision?.model + vision.activeProvider = extension?.modules?.vision?.provider + vision.activeModel = extension?.modules?.vision?.model - activeSpeechProvider.value = extension?.modules?.speech?.provider - activeSpeechModel.value = extension?.modules?.speech?.model - activeSpeechVoiceId.value = extension?.modules?.speech?.voice_id + speech.activeSpeechProvider = extension?.modules?.speech?.provider + speech.activeSpeechModel = extension?.modules?.speech?.model + speech.activeSpeechVoiceId = extension?.modules?.speech?.voice_id // Apply body model if the card has a display model configured. // NOTICE: must set via store property directly (not storeToRefs .value) so Pinia's // proxy correctly calls the writable computed setter → stageModelSelectedState → updateStageModel(). if (extension.modules?.displayModelId) { - stageModelStore.stageModelSelected = extension.modules.displayModelId + stageModel.stageModelSelected = extension.modules.displayModelId } if (extension.modules?.artistry) { if (extension.modules.artistry.provider) - artistryStore.activeProvider = extension.modules.artistry.provider + artistry.activeProvider = extension.modules.artistry.provider if (extension.modules.artistry.model) - artistryStore.activeModel = extension.modules.artistry.model + artistry.activeModel = extension.modules.artistry.model if (extension.modules.artistry.promptPrefix) - artistryStore.defaultPromptPrefix = extension.modules.artistry.promptPrefix + artistry.defaultPromptPrefix = extension.modules.artistry.promptPrefix if (extension.modules.artistry.options) - artistryStore.providerOptions = extension.modules.artistry.options + artistry.providerOptions = extension.modules.artistry.options } } - // Activation changes the stable card ID, while card editors replace the - // active card object without changing that ID. Observe both transitions so - // switching cards and saving edits to the current card apply consistently. - watch([activeCardId, activeCard], ([, newCard]) => { - applyActiveCardSettings(newCard) - }, { flush: 'sync', immediate: true }) + let stopLeadershipListener: (() => void) | undefined + let stopRuntimeModuleWatcher: (() => void) | undefined + + function initializeRuntimeModules() { + if (stopRuntimeModuleWatcher) + return + + applyActiveCardSettings() + + // Activation changes the stable card ID, while card editors replace the + // active card object without changing that ID. Only the Stage lifecycle + // owner applies those settings; metadata-only consumers stay lightweight. + stopRuntimeModuleWatcher = watch([activeCardId, activeCard], ([, newCard]) => { + applyActiveCardSettings(newCard) + }, { flush: 'sync' }) + } + + function stopRuntimeModules() { + stopRuntimeModuleWatcher?.() + stopRuntimeModuleWatcher = undefined + } + + /** + * Keeps renderer-local card settings active only in the current leader. + * Repeated calls keep the first listener until {@link disposeRuntime} runs. + */ + function startRuntime(syncedPinia: Pick) { + if (stopLeadershipListener) + return + + stopLeadershipListener = syncedPinia.onLeadershipChange((isLeader) => { + if (isLeader) + initializeRuntimeModules() + else + stopRuntimeModules() + }) + } + + /** Stops renderer-local card settings and leadership tracking. */ + function disposeRuntime() { + stopRuntimeModules() + stopLeadershipListener?.() + stopLeadershipListener = undefined + } function resetState() { // Clear card data before the selected ID. Otherwise the synchronous @@ -384,26 +427,40 @@ export const useAiriCardStore = defineStore('airi-card', () => { getCard, resetState, initialize, + startRuntime, + disposeRuntime, currentModels: computed(() => { + const { + consciousness, + speech, + stageModel, + vision, + } = useRuntimeModuleStores() + return { consciousness: { - provider: activeConsciousnessProvider.value, - model: activeConsciousnessModel.value, + provider: consciousness.activeProvider, + model: consciousness.activeModel, }, vision: { - provider: activeVisionProvider.value, - model: activeVisionModel.value, + provider: vision.activeProvider, + model: vision.activeModel, }, speech: { - provider: activeSpeechProvider.value, - model: activeSpeechModel.value, - voice_id: activeSpeechVoiceId.value, + provider: speech.activeSpeechProvider, + model: speech.activeSpeechModel, + voice_id: speech.activeSpeechVoiceId, }, - displayModelId: stageModelStore.stageModelSelected, + displayModelId: stageModel.stageModelSelected, activeBackgroundId: activeCard.value?.extensions?.airi?.modules?.activeBackgroundId, } satisfies AiriExtension['modules'] }), systemPrompt: computed(() => resolveSystemPrompt(activeCard.value)), } +}, { + synced: { + actions: ['initialize'], + state: true, + }, }) diff --git a/packages/stage-ui/src/stores/modules/artistry.ts b/packages/stage-ui/src/stores/modules/artistry.ts index 43be6d45f..355b70d68 100644 --- a/packages/stage-ui/src/stores/modules/artistry.ts +++ b/packages/stage-ui/src/stores/modules/artistry.ts @@ -1,3 +1,5 @@ +import type {} from 'pinia-plugin-synced' + import { useLocalStorageManualReset } from '@proj-airi/stage-shared/composables' import { defineStore } from 'pinia' import { computed, isRef, ref, watch } from 'vue' @@ -184,6 +186,10 @@ export const useArtistryStore = defineStore('artistry', () => { resetToGlobal, resetState, } +}, { + synced: { + state: true, + }, }) /** diff --git a/packages/stage-ui/src/stores/modules/consciousness.ts b/packages/stage-ui/src/stores/modules/consciousness.ts index c0ba9c194..124c0a4ba 100644 --- a/packages/stage-ui/src/stores/modules/consciousness.ts +++ b/packages/stage-ui/src/stores/modules/consciousness.ts @@ -1,3 +1,5 @@ +import type {} from 'pinia-plugin-synced' + import { useLocalStorageManualReset } from '@proj-airi/stage-shared/composables' import { refManualReset } from '@vueuse/core' import { defineStore } from 'pinia' @@ -117,4 +119,8 @@ export const useConsciousnessStore = defineStore('consciousness', () => { getModelsForProvider, resetState, } +}, { + synced: { + state: true, + }, }) diff --git a/packages/stage-ui/src/stores/modules/hearing.ts b/packages/stage-ui/src/stores/modules/hearing.ts index a604c0460..0b638338c 100644 --- a/packages/stage-ui/src/stores/modules/hearing.ts +++ b/packages/stage-ui/src/stores/modules/hearing.ts @@ -2,6 +2,7 @@ import type { Span } from '@opentelemetry/api' import type { TranscriptionProviderWithExtraOptions } from '@xsai-ext/providers/utils' import type { WithUnknown } from '@xsai/shared' import type { StreamTranscriptionOptions as XSAIStreamTranscriptionOptions } from '@xsai/stream-transcription' +import type {} from 'pinia-plugin-synced' import type { AIRIStreamTranscriptionResult } from '../../libs/providers/stream-transcription' import type { StreamingTranscriptionCallbacks, StreamingTranscriptionConsumer } from './streaming-transcription-consumers' @@ -561,6 +562,10 @@ export const useHearingStore = defineStore('hearing-store', () => { getModelsForProvider, resetState, } +}, { + synced: { + state: true, + }, }) export const useHearingSpeechInputPipeline = defineStore('modules:hearing:speech:audio-input-pipeline', () => { diff --git a/packages/stage-ui/src/stores/modules/speech.ts b/packages/stage-ui/src/stores/modules/speech.ts index c91b337eb..f4bbffa40 100644 --- a/packages/stage-ui/src/stores/modules/speech.ts +++ b/packages/stage-ui/src/stores/modules/speech.ts @@ -1,4 +1,5 @@ import type { SpeechProviderWithExtraOptions } from '@xsai-ext/providers/utils' +import type {} from 'pinia-plugin-synced' import type { VoiceInfo } from '../providers/provider' @@ -7,7 +8,7 @@ import { useLocalStorageManualReset } from '@proj-airi/stage-shared/composables' import { refManualReset } from '@vueuse/core' import { generateSpeech } from '@xsai/generate-speech' import { defineStore, storeToRefs } from 'pinia' -import { computed, onMounted, watch } from 'vue' +import { computed, watch } from 'vue' import { useI18n } from 'vue-i18n' import { toXml } from 'xast-util-to-xml' import { x } from 'xastscript' @@ -242,15 +243,6 @@ export const useSpeechStore = defineStore('speech', () => { }, ) - onMounted(() => { - ensureActiveSpeechModel() - loadVoicesForProvider(activeSpeechProvider.value, activeSpeechModel.value || undefined).then(() => { - if (activeSpeechVoiceId.value) { - activeSpeechVoice.value = availableVoices.value[activeSpeechProvider.value]?.find(voice => voice.id === activeSpeechVoiceId.value) - } - }) - }) - setupOfficialSpeechAutoPick({ activeSpeechProvider, activeSpeechVoiceId, @@ -469,4 +461,8 @@ export const useSpeechStore = defineStore('speech', () => { resolveSpeechInput, resetState, } +}, { + synced: { + state: true, + }, }) diff --git a/packages/stage-ui/src/stores/modules/vision/store.ts b/packages/stage-ui/src/stores/modules/vision/store.ts index e0608508e..282f36808 100644 --- a/packages/stage-ui/src/stores/modules/vision/store.ts +++ b/packages/stage-ui/src/stores/modules/vision/store.ts @@ -1,3 +1,5 @@ +import type {} from 'pinia-plugin-synced' + import { useLocalStorageManualReset } from '@proj-airi/stage-shared/composables' import { refManualReset } from '@vueuse/core' import { defineStore } from 'pinia' @@ -86,4 +88,8 @@ export const useVisionStore = defineStore('vision', () => { getModelsForProvider, resetState, } +}, { + synced: { + state: true, + }, }) diff --git a/packages/stage-ui/src/stores/providers/provider.test.ts b/packages/stage-ui/src/stores/providers/provider.test.ts index d383a38a6..69967cb3b 100644 --- a/packages/stage-ui/src/stores/providers/provider.test.ts +++ b/packages/stage-ui/src/stores/providers/provider.test.ts @@ -1,6 +1,7 @@ import { createPinia, setActivePinia } from 'pinia' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { OFFICIAL_SPEECH_PROVIDER_ID } from '../../libs/providers/providers/official' import { useProviderStore } from './provider' vi.mock('vue-i18n', () => ({ @@ -64,4 +65,38 @@ describe('provider store synchronization boundary', () => { expect.objectContaining({ id: 'auto' }), ]) }) + + // ROOT CAUSE: + // + // Speech startup previously had both an immediate watcher and a mounted + // refresh. Multiple renderers could also request the same catalog through + // the synchronized provider action. Each caller created its own request. + // + // We keep one leader-owned request per provider, model, and configuration + // until it settles, so concurrent callers share the same result. + it('shares concurrent voice catalog requests', async () => { + const store = useProviderStore() + let resolveRequest: ((response: Response) => void) | undefined + const fetchMock = vi.fn(() => new Promise((resolve) => { + resolveRequest = resolve + })) + vi.stubGlobal('fetch', fetchMock) + + try { + const first = store.listProviderVoices(OFFICIAL_SPEECH_PROVIDER_ID, 'auto') + const second = store.listProviderVoices(OFFICIAL_SPEECH_PROVIDER_ID, 'auto') + + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)) + resolveRequest?.(new Response(JSON.stringify({ voices: [], recommended: {} }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })) + + await expect(Promise.all([first, second])).resolves.toEqual([[], []]) + expect(fetchMock).toHaveBeenCalledTimes(1) + } + finally { + vi.unstubAllGlobals() + } + }) }) diff --git a/packages/stage-ui/src/stores/providers/provider.ts b/packages/stage-ui/src/stores/providers/provider.ts index 6a26613e0..eb18a1c3e 100644 --- a/packages/stage-ui/src/stores/providers/provider.ts +++ b/packages/stage-ui/src/stores/providers/provider.ts @@ -11,7 +11,7 @@ import type { import type {} from 'pinia-plugin-synced' import type { ProviderMetadata, ProviderValidationPlan } from '../../libs/providers' -import type { ModelInfo, ProviderDefinition, ProviderInstance } from '../../libs/providers/types' +import type { ModelInfo, ProviderDefinition, ProviderInstance, VoiceInfo } from '../../libs/providers/types' import { errorMessageFrom } from '@moeru/std' import { isCustomProvidersDisabled, isStageCapacitor, isStageTamagotchi } from '@proj-airi/stage-shared' @@ -202,6 +202,7 @@ export const useProviderStore = defineStore('provider', () => { set: value => providerStateStore.runtime = value, }) const providerValidationInFlight = new Map>() + const providerVoiceListInFlight = new Map>() const providerRevalidationLoops = new Map void, resume: () => void }>() // Server-driven availability overrides for providers whose visibility can @@ -610,13 +611,25 @@ export const useProviderStore = defineStore('provider', () => { return [] const config = providerConfigStore.getProviderConfig(providerId) ?? {} - const provider = await definition.createProvider(config) - try { - return await listVoices(config, provider, model) - } - finally { - await disposeTemporaryProvider(provider) - } + const requestKey = JSON.stringify([providerId, model ?? null, config]) + const pending = providerVoiceListInFlight.get(requestKey) + if (pending) + return pending + + const task = (async () => { + const provider = await definition.createProvider(config) + try { + return await listVoices(config, provider, model) + } + finally { + await disposeTemporaryProvider(provider) + } + })() + providerVoiceListInFlight.set(requestKey, task) + + return task.finally(() => { + providerVoiceListInFlight.delete(requestKey) + }) } async function loadProviderModel( diff --git a/packages/stage-ui/src/stores/settings/stage-model.ts b/packages/stage-ui/src/stores/settings/stage-model.ts index 8e1f4691f..399f65fa9 100644 --- a/packages/stage-ui/src/stores/settings/stage-model.ts +++ b/packages/stage-ui/src/stores/settings/stage-model.ts @@ -1,8 +1,10 @@ +import type {} from 'pinia-plugin-synced' + import type { DisplayModel } from '../display-models' import { useLocalStorageManualReset } from '@proj-airi/stage-shared/composables' import { refManualReset, useEventListener } from '@vueuse/core' -import { defineStore } from 'pinia' +import { defineStore, storeToRefs } from 'pinia' import { computed, watch } from 'vue' import { DisplayModelFormat, useDisplayModelsStore } from '../display-models' @@ -10,13 +12,29 @@ import { DisplayModelFormat, useDisplayModelsStore } from '../display-models' export type StageModelRenderer = 'live2d' | 'vrm' | 'spine' | 'tachie' | 'mmd' | 'godot' | 'disabled' | undefined type BuiltInStageModelRenderer = Exclude +const useStageModelSelectionStore = defineStore('settings-stage-model-selection', () => { + const selected = useLocalStorageManualReset('settings/stage/model', 'preset-live2d-1') + + function resetState() { + selected.reset() + } + + return { + selected, + resetState, + } +}, { + synced: { + state: true, + }, +}) + export const useSettingsStageModel = defineStore('settings-stage-model', () => { const displayModelsStore = useDisplayModelsStore() + const stageModelSelectionStore = useStageModelSelectionStore() + const { selected: stageModelSelectedState } = storeToRefs(stageModelSelectionStore) let stageModelUpdateSequence = 0 - const stageModelStorageKey = 'settings/stage/model' const defaultStageModelId = 'preset-live2d-1' - - const stageModelSelectedState = useLocalStorageManualReset(stageModelStorageKey, defaultStageModelId) const stageModelSelected = computed({ get: () => stageModelSelectedState.value, set: (value) => { @@ -142,7 +160,7 @@ export const useSettingsStageModel = defineStore('settings-stage-model', () => { async function resetState() { revokeStageModelUrl(stageModelSelectedUrl.value) - stageModelSelectedState.reset() + stageModelSelectionStore.resetState() stageModelSelectedDisplayModel.reset() stageModelSelectedUrl.reset() stageModelRenderer.reset() diff --git a/patches/tab-election@4.6.2.patch b/patches/tab-election@4.6.2.patch new file mode 100644 index 000000000..24c05aee7 --- /dev/null +++ b/patches/tab-election@4.6.2.patch @@ -0,0 +1,107 @@ +diff --git a/dist/tab.js b/dist/tab.js +index 9fe1d1b6fe8b0172251ce7688f742bbb6c3b6da9..5dba20d6f551ec644eb168db95b45e48935879db 100644 +--- a/dist/tab.js ++++ b/dist/tab.js +@@ -111,8 +111,8 @@ export class Tab extends EventTarget { + // leader when it died — we are the leader now, so dispatch them to + // ourselves (see the matching re-delivery for non-leader tabs in + // `_onLeader`; the same at-least-once caveat applies). +- this._callDeferreds.forEach(({ name, rest }, callNumber) => { +- if (queued.has(`${this._id}:${callNumber}`)) ++ this._callDeferreds.forEach(({ awaitingLeader, name, rest }, callNumber) => { ++ if (awaitingLeader || queued.has(`${this._id}:${callNumber}`)) + return; + this._clearSentCall(callNumber); + this._onCall(this._id, callNumber, name, ...rest); +@@ -141,8 +141,12 @@ export class Tab extends EventTarget { + this._clearSentCall(callNumber); + reject(new Error('Call timed out')); + }, this._callTimeout); +- this._callDeferreds.set(callNumber, { resolve, reject, timeout, name, rest }); ++ this._callDeferreds.set(callNumber, { awaitingLeader: true, resolve, reject, timeout, name, rest }); + const hasLeader = await this.hasLeader(); ++ const deferred = this._callDeferreds.get(callNumber); ++ if (!deferred) ++ return; ++ deferred.awaitingLeader = false; + try { + if (this.isLeader && this._isLeaderReady) { + this._onCall(this._id, callNumber, name, ...rest); +@@ -294,8 +298,9 @@ export class Tab extends EventTarget { + if (this._sentCalls.get(callNumber)) + this._callReceived(callNumber); + const deferred = this._callDeferreds.get(callNumber); ++ // Delivery is at-least-once, so a duplicate or late return can arrive after the call has already settled. + if (!deferred) +- return console.error('No deferred found for call', callNumber); ++ return; + clearTimeout(deferred.timeout); + this._callDeferreds.delete(callNumber); + if (error) +@@ -345,8 +350,8 @@ export class Tab extends EventTarget { + // The same duplication happens inside a single election: a call queued while the leader was still initializing + // is dispatched by that leader AND re-sent from here, because the leader's `onLeader` broadcast goes out + // synchronously while the queued call's `onReturn` is still awaiting the handler. +- this._callDeferreds.forEach(({ name, rest }, callNumber) => { +- if (queued.has(`${this._id}:${callNumber}`) || this._sentCalls.has(callNumber)) ++ this._callDeferreds.forEach(({ awaitingLeader, name, rest }, callNumber) => { ++ if (awaitingLeader || queued.has(`${this._id}:${callNumber}`) || this._sentCalls.has(callNumber)) + return; + try { + this._sendCall(callNumber, name, rest); +diff --git a/src/tab.ts b/src/tab.ts +index 3d42674d6276ff707b890d8009d0af22298f00c3..57d4fa50d46a7e2195ea9e51de4586c90a62de2e 100644 +--- a/src/tab.ts ++++ b/src/tab.ts +@@ -7,4 +7,6 @@ interface Deferred { + interface Deferred { ++ /** The original call is still waiting for `hasLeader()` to select its first delivery path. */ ++ awaitingLeader: boolean; + resolve: (value: any) => void; + reject: (reason?: any) => void; + timeout: number; +@@ -166,8 +168,8 @@ export class Tab> extends EventTarget implements Tab { + // leader when it died — we are the leader now, so dispatch them to + // ourselves (see the matching re-delivery for non-leader tabs in + // `_onLeader`; the same at-least-once caveat applies). +- this._callDeferreds.forEach(({ name, rest }, callNumber) => { +- if (queued.has(`${this._id}:${callNumber}`)) return; ++ this._callDeferreds.forEach(({ awaitingLeader, name, rest }, callNumber) => { ++ if (awaitingLeader || queued.has(`${this._id}:${callNumber}`)) return; + this._clearSentCall(callNumber); + this._onCall(this._id, callNumber, name, ...rest); + }); +@@ -194,8 +196,11 @@ export class Tab> extends EventTarget implements Tab { + this._clearSentCall(callNumber); + reject(new Error('Call timed out')); + }, this._callTimeout); +- this._callDeferreds.set(callNumber, { resolve, reject, timeout, name, rest }); ++ this._callDeferreds.set(callNumber, { awaitingLeader: true, resolve, reject, timeout, name, rest }); + const hasLeader = await this.hasLeader(); ++ const deferred = this._callDeferreds.get(callNumber); ++ if (!deferred) return; ++ deferred.awaitingLeader = false; + try { + if (this.isLeader && this._isLeaderReady) { + this._onCall(this._id, callNumber, name, ...rest); +@@ -337,7 +342,8 @@ export class Tab> extends EventTarget implements Tab { + _onReturn(callNumber: number, error: any, results: any) { + if (this._sentCalls.get(callNumber)) this._callReceived(callNumber); + const deferred = this._callDeferreds.get(callNumber); +- if (!deferred) return console.error('No deferred found for call', callNumber); ++ // Delivery is at-least-once, so a duplicate or late return can arrive after the call has already settled. ++ if (!deferred) return; + clearTimeout(deferred.timeout); + this._callDeferreds.delete(callNumber); + if (error) deferred.reject(error); +@@ -386,8 +392,8 @@ export class Tab> extends EventTarget implements Tab { + // The same duplication happens inside a single election: a call queued while the leader was still initializing + // is dispatched by that leader AND re-sent from here, because the leader's `onLeader` broadcast goes out + // synchronously while the queued call's `onReturn` is still awaiting the handler. +- this._callDeferreds.forEach(({ name, rest }, callNumber) => { +- if (queued.has(`${this._id}:${callNumber}`) || this._sentCalls.has(callNumber)) return; ++ this._callDeferreds.forEach(({ awaitingLeader, name, rest }, callNumber) => { ++ if (awaitingLeader || queued.has(`${this._id}:${callNumber}`) || this._sentCalls.has(callNumber)) return; + try { + this._sendCall(callNumber, name, rest); + } catch (e) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f526d1a27..aad7ea29a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1220,6 +1220,9 @@ patchedDependencies: sponsorkit@17.1.0: hash: f5887da52a29ae85732e01644d05c0c274231fe76b95aebeafbfab6dc4444f88 path: patches/sponsorkit@17.1.0.patch + tab-election@4.6.2: + hash: ff8fe3c9aed66469fa00180f039fbf9f4f8e682430bfb3f6a07d9baf3f850674 + path: patches/tab-election@4.6.2.patch uiohook-napi@1.5.5: hash: 012c5e5ad881d5ae40fc0af58832e31a0772ab0a74c038c8f4dfe51efaf398dd path: patches/uiohook-napi@1.5.5.patch @@ -33029,7 +33032,7 @@ snapshots: '@moeru/std': 0.1.0-beta.20 es-toolkit: 1.50.0 pinia: 3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)) - tab-election: 4.6.2 + tab-election: 4.6.2(patch_hash=ff8fe3c9aed66469fa00180f039fbf9f4f8e682430bfb3f6a07d9baf3f850674) vue: 3.5.32(typescript@5.9.3) pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)): @@ -34673,7 +34676,7 @@ snapshots: dependencies: '@pkgr/core': 0.2.9 - tab-election@4.6.2: {} + tab-election@4.6.2(patch_hash=ff8fe3c9aed66469fa00180f039fbf9f4f8e682430bfb3f6a07d9baf3f850674): {} tabbable@6.4.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 4edfe4bb2..ffff88f25 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -34,6 +34,7 @@ patchedDependencies: mineflayer-pathfinder: patches/mineflayer-pathfinder.patch pixi-live2d-display: patches/pixi-live2d-display.patch sponsorkit@17.1.0: patches/sponsorkit@17.1.0.patch + tab-election@4.6.2: patches/tab-election@4.6.2.patch uiohook-napi@1.5.5: patches/uiohook-napi@1.5.5.patch catalog: '@alexanderolsen/libsamplerate-js': ^2.1.2