diff --git a/apps/stage-pocket/src/App.vue b/apps/stage-pocket/src/App.vue index de839f99f..a5526c406 100644 --- a/apps/stage-pocket/src/App.vue +++ b/apps/stage-pocket/src/App.vue @@ -1,15 +1,19 @@ 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 deleted file mode 100644 index 78a7b4f30..000000000 --- a/packages/stage-ui/src/composables/use-auth-provider-sync.test.ts +++ /dev/null @@ -1,238 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -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: '', - activeModel: '', - activeVisionProvider: '', - activeVisionModel: '', - activeSpeechProvider: 'speech-noop', - activeSpeechModel: '', - activeSpeechVoiceId: '', - activeTranscriptionProvider: '', - activeTranscriptionModel: '', -})) - -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(), - fetchModelsForProvider: vi.fn(async (_providerId: string): Promise => []), - loadConsciousnessModels: vi.fn(async () => []), - loadVisionModels: vi.fn(async () => []), - trackOfficialProviderSelected: vi.fn(), -})) - -vi.mock('../libs/auth', () => ({ - initializeAuth: syncMocks.initializeAuth, -})) - -vi.mock('../libs/pinia', () => ({ - usePiniaSynced: () => ({ - isLeader: () => syncState.isLeader, - onLeadershipChange: (hook: (isLeader: boolean) => void) => { - syncMocks.leadershipHook = hook - return vi.fn() - }, - }), -})) - -vi.mock('../libs/providers', () => ({ - getStreamingTtsAvailable: () => true, - OFFICIAL_TRANSCRIPTION_PROVIDER_ID: 'official-provider-transcription', -})) - -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() - } - }, - }), -})) - -vi.mock('../stores/providers/provider', () => ({ - useProviderStore: () => ({ - findProviderDefinition: () => ({}), - forceProviderConfigured: syncMocks.forceProviderConfigured, - setProviderUnconfigured: syncMocks.setProviderUnconfigured, - setProviderAvailabilityOverride: syncMocks.setProviderAvailabilityOverride, - fetchModelsForProvider: syncMocks.fetchModelsForProvider, - }), -})) - -vi.mock('../stores/modules/consciousness', () => ({ - useConsciousnessStore: () => ({ - get activeProvider() { return syncState.activeProvider }, - set activeProvider(value: string) { syncState.activeProvider = value }, - get activeModel() { return syncState.activeModel }, - set activeModel(value: string) { syncState.activeModel = value }, - loadModelsForProvider: syncMocks.loadConsciousnessModels, - }), -})) - -vi.mock('../stores/modules/vision', () => ({ - useVisionStore: () => ({ - get activeProvider() { return syncState.activeVisionProvider }, - set activeProvider(value: string) { syncState.activeVisionProvider = value }, - get activeModel() { return syncState.activeVisionModel }, - set activeModel(value: string) { syncState.activeVisionModel = value }, - loadModelsForProvider: syncMocks.loadVisionModels, - }), -})) - -vi.mock('../stores/modules/speech', () => ({ - useSpeechStore: () => ({ - get activeSpeechProvider() { return syncState.activeSpeechProvider }, - set activeSpeechProvider(value: string) { syncState.activeSpeechProvider = value }, - get activeSpeechModel() { return syncState.activeSpeechModel }, - set activeSpeechModel(value: string) { syncState.activeSpeechModel = value }, - get activeSpeechVoiceId() { return syncState.activeSpeechVoiceId }, - set activeSpeechVoiceId(value: string) { syncState.activeSpeechVoiceId = value }, - ensureStreamingDefaultModel: vi.fn(), - loadVoicesForProvider: vi.fn(async () => []), - }), -})) - -vi.mock('../stores/modules/hearing', () => ({ - useHearingStore: () => ({ - get activeTranscriptionProvider() { return syncState.activeTranscriptionProvider }, - set activeTranscriptionProvider(value: string) { syncState.activeTranscriptionProvider = value }, - get activeTranscriptionModel() { return syncState.activeTranscriptionModel }, - set activeTranscriptionModel(value: string) { syncState.activeTranscriptionModel = value }, - }), -})) - -vi.mock('./use-analytics', () => ({ - useAnalytics: () => ({ - trackOfficialProviderSelected: syncMocks.trackOfficialProviderSelected, - }), -})) - -describe('useAuthProviderSync', () => { - beforeEach(() => { - syncState.isLeader = false - syncState.authenticatedHook = undefined - syncState.logoutHook = undefined - syncMocks.leadershipHook = undefined - syncState.activeProvider = '' - syncState.activeModel = '' - syncState.activeVisionProvider = '' - syncState.activeVisionModel = '' - syncState.activeSpeechProvider = 'speech-noop' - syncState.activeSpeechModel = '' - syncState.activeSpeechVoiceId = '' - syncState.activeTranscriptionProvider = '' - syncState.activeTranscriptionModel = '' - vi.clearAllMocks() - syncMocks.fetchModelsForProvider.mockResolvedValue([]) - }) - - it('starts auth initialization when this renderer becomes the leader', async () => { - useAuthProviderSync() - 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?.() - - expect(syncMocks.forceProviderConfigured).toHaveBeenCalledWith('official-provider') - expect(syncMocks.forceProviderConfigured).toHaveBeenCalledWith('vision-official-provider') - expect(syncMocks.forceProviderConfigured).toHaveBeenCalledWith('official-provider-speech') - expect(syncMocks.forceProviderConfigured).toHaveBeenCalledWith(OFFICIAL_TRANSCRIPTION_PROVIDER_ID) - expect(syncState.activeProvider).toBe('official-provider') - expect(syncState.activeModel).toBe('auto') - expect(syncState.activeSpeechProvider).toBe('official-provider-speech') - expect(syncState.activeTranscriptionProvider).toBe(OFFICIAL_TRANSCRIPTION_PROVIDER_ID) - }) - - it('retries provider activation when the first authenticated sync fails', async () => { - // ROOT CAUSE: - // - // 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') - return [] - }) - useAuthProviderSync() - - await expect(syncState.authenticatedHook?.()).rejects.toThrow('temporary catalog failure') - syncMocks.fetchModelsForProvider.mockResolvedValue([]) - await expect(syncState.authenticatedHook?.()).resolves.toBeUndefined() - - expect(syncMocks.forceProviderConfigured).toHaveBeenCalledTimes(9) - expect(syncMocks.forceProviderConfigured.mock.calls.filter(([providerId]) => providerId === 'official-provider')).toHaveLength(2) - expect(syncMocks.forceProviderConfigured.mock.calls.filter(([providerId]) => providerId === 'official-provider-speech')).toHaveLength(2) - expect(syncMocks.forceProviderConfigured.mock.calls.filter(([providerId]) => providerId === OFFICIAL_TRANSCRIPTION_PROVIDER_ID)).toHaveLength(2) - }) -}) diff --git a/packages/stage-ui/src/composables/use-auth-provider-sync.ts b/packages/stage-ui/src/composables/use-auth-provider-sync.ts deleted file mode 100644 index b092114d1..000000000 --- a/packages/stage-ui/src/composables/use-auth-provider-sync.ts +++ /dev/null @@ -1,282 +0,0 @@ -import { nextTick } from 'vue' - -import { initializeAuth } from '../libs/auth' -import { usePiniaSynced } from '../libs/pinia' -import { getStreamingTtsAvailable, OFFICIAL_TRANSCRIPTION_PROVIDER_ID } from '../libs/providers' -import { useAuthStore } from '../stores/auth' -import { useConsciousnessStore } from '../stores/modules/consciousness' -import { useHearingStore } from '../stores/modules/hearing' -import { useSpeechStore } from '../stores/modules/speech' -import { useVisionStore } from '../stores/modules/vision' -import { useProviderStore } from '../stores/providers/provider' -import { useAnalytics } from './use-analytics' - -/** - * Provider IDs to auto-activate on sign-in. - * Edit this list to enable/disable official providers. - */ -const AUTH_ACTIVATED_PROVIDERS: Array<{ id: string, module: 'consciousness' | 'speech' | 'hearing' | 'vision' }> = [ - { id: 'official-provider', module: 'consciousness' }, - { id: 'vision-official-provider', module: 'vision' }, - { id: 'official-provider-speech', module: 'speech' }, - { id: OFFICIAL_TRANSCRIPTION_PROVIDER_ID, module: 'hearing' }, -] - -// The streaming TTS provider is NOT in the static list above because its -// visibility is operator-controlled: `UNSPEECH_UPSTREAM.streaming` may be -// unconfigured server-side. It's bootstrapped separately (see -// `syncStreamingSpeechProvider`) — probed on sign-in, then force-configured -// only when the server reports it available, mirroring how the HTTP TTS -// provider uses `forceProviderConfigured` but gating it on a server signal. -const STREAMING_SPEECH_PROVIDER_ID = 'official-provider-speech-streaming' - -/** - * Glue layer: uses auth lifecycle hooks to activate/deactivate - * official providers. Providers themselves know nothing about auth. - * - * Call once from each app renderer root so direct sign-in routes and - * auxiliary windows do not depend on the transient Stage scene lifecycle. - */ -export function useAuthProviderSync() { - const syncedPinia = usePiniaSynced() - let leaderSyncInitialized = false - let disposeAuthenticatedProviderSync: (() => void) | undefined - - 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) - initializeLeaderSync() - else - disposeLeaderSync() - }) - - initializeLeaderSync() -} - -function setupAuthenticatedProviderSync() { - const authStore = useAuthStore() - const providersStore = useProviderStore() - const consciousnessStore = useConsciousnessStore() - const visionStore = useVisionStore() - const speechStore = useSpeechStore() - const hearingStore = useHearingStore() - const { trackOfficialProviderSelected } = useAnalytics() - - // Track the completed and in-flight work separately. Authentication can be - // announced more than once while a catalog request is still pending; those - // notifications share one task, while a failed task remains retryable. - let hasSynced = false - let authGeneration = 0 - let syncInFlight: Promise | undefined - - const stopAuthenticatedHook = authStore.onAuthenticated(async () => { - if (hasSynced) - return - - if (syncInFlight) - return syncInFlight - - const generation = authGeneration - const task = syncAuthenticatedProviders(generation) - syncInFlight = task - - try { - await task - if (generation === authGeneration) - hasSynced = true - } - finally { - if (syncInFlight === task) - syncInFlight = undefined - } - }) - - async function syncAuthenticatedProviders(generation: number) { - if (generation !== authGeneration) - return - - const toActivate = AUTH_ACTIVATED_PROVIDERS.filter( - p => providersStore.findProviderDefinition(p.id) != null, - ) - - for (const { id } of toActivate) { - providersStore.forceProviderConfigured(id) - } - - // Only set official provider as active when the user hasn't configured - // any provider for that module yet. - for (const { id, module } of toActivate) { - switch (module) { - case 'consciousness': - if (!consciousnessStore.activeProvider) { - consciousnessStore.activeProvider = id - consciousnessStore.activeModel = 'auto' - trackOfficialProviderSelected({ - provider_id: id, - provider_mode: 'official', - source: 'default_auto', - auto_selected: true, - model_id: 'auto', - }) - } - break - case 'vision': - if (!visionStore.activeProvider) { - visionStore.activeProvider = id - visionStore.activeModel = 'auto' - } - break - case 'speech': - if (!speechStore.activeSpeechProvider || speechStore.activeSpeechProvider === 'speech-noop') { - speechStore.activeSpeechProvider = id - speechStore.activeSpeechModel = '' - } - break - case 'hearing': - if (!hearingStore.activeTranscriptionProvider) { - hearingStore.activeTranscriptionProvider = id - hearingStore.activeTranscriptionModel = 'auto' - } - break - } - } - - await nextTick() - try { - await Promise.all( - toActivate.map(({ id, module }) => - module === 'consciousness' - ? consciousnessStore.loadModelsForProvider(id) - : module === 'vision' - ? visionStore.loadModelsForProvider(id) - : providersStore.fetchModelsForProvider(id), - ), - ) - } - catch (err) { - console.error('error loading models for official providers', err) - } - - // Logout may happen while provider catalogs are loading. The logout hook - // owns cleanup, so stale work must not re-enable streaming TTS afterward. - if (generation !== authGeneration) - return - - await syncStreamingSpeechProvider() - } - - // Bootstrap the streaming TTS provider from the server's availability signal. - // Probing populates `getStreamingTtsAvailable()` (and the default model / - // voices) via the provider's listModels(). The availability override drives - // the provider's presence in the available/configured lists (and thus the - // settings card + picker); force-configure makes it selectable. It is never - // set as the active speech provider — the HTTP TTS provider stays default. - async function syncStreamingSpeechProvider() { - if (providersStore.findProviderDefinition(STREAMING_SPEECH_PROVIDER_ID) == null) - return - - await providersStore.fetchModelsForProvider(STREAMING_SPEECH_PROVIDER_ID) - - const available = getStreamingTtsAvailable() - providersStore.setProviderAvailabilityOverride(STREAMING_SPEECH_PROVIDER_ID, available) - - if (available) { - providersStore.forceProviderConfigured(STREAMING_SPEECH_PROVIDER_ID) - // The speech-module watcher skips voice loading for streaming until it's - // confirmed configured (avoids a pre-probe request on reload), so when - // streaming is the persisted active provider, load its voices now that - // it's confirmed available. - if (speechStore.activeSpeechProvider === STREAMING_SPEECH_PROVIDER_ID) { - speechStore.ensureStreamingDefaultModel() - await speechStore.loadVoicesForProvider(STREAMING_SPEECH_PROVIDER_ID, speechStore.activeSpeechModel || undefined) - } - return - } - - providersStore.setProviderUnconfigured(STREAMING_SPEECH_PROVIDER_ID) - // `setProviderUnconfigured` blanks `validatedCredentialHash`, which makes - // the speech-module reset watcher skip its own clear. So when the server - // now reports streaming unavailable on an authenticated reload (no logout - // event fires), clear a stale active streaming selection here. - clearActiveStreamingSelection() - } - - function clearActiveStreamingSelection() { - if (speechStore.activeSpeechProvider !== STREAMING_SPEECH_PROVIDER_ID) - return - speechStore.activeSpeechProvider = '' - speechStore.activeSpeechModel = '' - speechStore.activeSpeechVoiceId = '' - } - - const stopLogoutHook = authStore.onLogout(() => { - authGeneration++ - hasSynced = false - - for (const { id } of AUTH_ACTIVATED_PROVIDERS) { - providersStore.setProviderUnconfigured(id) - } - - // Streaming TTS is bootstrapped outside AUTH_ACTIVATED_PROVIDERS, so reset - // it explicitly. `setProviderUnconfigured` blanks `validatedCredentialHash`, - // which makes the speech-module watcher skip its own reset (it guards - // against racing initial validation), so clear the active selection here - // too when streaming was the active provider. - clearActiveStreamingSelection() - providersStore.setProviderUnconfigured(STREAMING_SPEECH_PROVIDER_ID) - providersStore.setProviderAvailabilityOverride(STREAMING_SPEECH_PROVIDER_ID, false) - - // Reset active provider/model if they belong to an auth-activated provider - for (const { id, module } of AUTH_ACTIVATED_PROVIDERS) { - switch (module) { - case 'consciousness': - if (consciousnessStore.activeProvider === id) { - consciousnessStore.activeProvider = '' - consciousnessStore.activeModel = '' - } - break - case 'vision': - if (visionStore.activeProvider === id) { - visionStore.activeProvider = '' - visionStore.activeModel = '' - } - break - case 'speech': - if (speechStore.activeSpeechProvider === id) { - speechStore.activeSpeechProvider = '' - speechStore.activeSpeechModel = '' - } - break - case 'hearing': - if (hearingStore.activeTranscriptionProvider === id) { - hearingStore.activeTranscriptionProvider = '' - hearingStore.activeTranscriptionModel = '' - } - break - } - } - }) - - return () => { - authGeneration++ - stopAuthenticatedHook() - stopLogoutHook() - } -} diff --git a/packages/stage-ui/src/libs/pinia/pinia-plugin-tracing.ts b/packages/stage-ui/src/libs/pinia/pinia-plugin-tracing.ts index 193604732..a9d73a920 100644 --- a/packages/stage-ui/src/libs/pinia/pinia-plugin-tracing.ts +++ b/packages/stage-ui/src/libs/pinia/pinia-plugin-tracing.ts @@ -5,13 +5,105 @@ import { errorMessageFrom } from '@moeru/std' import { piniaActionTracingChannelName } from '@proj-airi/stage-shared/types/pinia-action-event' import { nanoid } from 'nanoid/non-secure' -const piniaActionChannel = new BroadcastChannel(piniaActionTracingChannelName) +const rateTraceStorageKey = 'airi:debug:pinia-tracing' +const rateTraceIntervalMs = 5_000 +const rateTraceLimit = 10 + +let piniaActionChannel: BroadcastChannel | undefined + +interface RateTraceWindow { + actionFailures: number + actions: Map + mutations: Map + mutationTypes: Map + startedAt: number +} + +let rateTraceTimer: ReturnType | undefined +let rateTraceWindow: RateTraceWindow | undefined + +function createRateTraceWindow(): RateTraceWindow { + return { + actionFailures: 0, + actions: new Map(), + mutations: new Map(), + mutationTypes: new Map(), + startedAt: performance.now(), + } +} + +function incrementRateTraceCount(counts: Map, key: string): void { + counts.set(key, (counts.get(key) ?? 0) + 1) +} + +function totalRateTraceCount(counts: Map): number { + let total = 0 + for (const count of counts.values()) + total += count + + return total +} + +function topRateTraceCounts(counts: Map): Array<{ count: number, name: string }> { + return [...counts.entries()] + .sort((left, right) => right[1] - left[1]) + .slice(0, rateTraceLimit) + .map(([name, count]) => ({ count, name })) +} + +function reportRateTraceWindow(): void { + const window = rateTraceWindow + if (!window) + return + + const actionCount = totalRateTraceCount(window.actions) + const mutationCount = totalRateTraceCount(window.mutations) + if (actionCount === 0 && mutationCount === 0) + return + + const elapsedMs = performance.now() - window.startedAt + const summary = { + actionCount, + actionFailures: window.actionFailures, + actionsPerSecond: Number((actionCount * 1_000 / elapsedMs).toFixed(1)), + elapsedMs: Math.round(elapsedMs), + mutationCount, + mutationsPerSecond: Number((mutationCount * 1_000 / elapsedMs).toFixed(1)), + mutationTypes: topRateTraceCounts(window.mutationTypes), + sourceUrl: location.href, + topActions: topRateTraceCounts(window.actions), + topMutations: topRateTraceCounts(window.mutations), + } + console.info(`[DEBUG-pinia-rate] ${JSON.stringify(summary)}`) + window.actionFailures = 0 + window.actions.clear() + window.mutations.clear() + window.mutationTypes.clear() + window.startedAt = performance.now() +} + +function startRateTracing(): RateTraceWindow | undefined { + if (!import.meta.env.DEV || typeof window === 'undefined' || window.localStorage.getItem(rateTraceStorageKey) !== 'true') + return + + rateTraceWindow ??= createRateTraceWindow() + if (!rateTraceTimer) { + rateTraceTimer = setInterval(reportRateTraceWindow, rateTraceIntervalMs) + window.addEventListener('pagehide', () => { + clearInterval(rateTraceTimer) + rateTraceTimer = undefined + }, { once: true }) + } + + return rateTraceWindow +} function emitActionEvent( event: Omit, status: PiniaActionEventStatus, error?: unknown, ): void { + piniaActionChannel ??= new BroadcastChannel(piniaActionTracingChannelName) piniaActionChannel.postMessage({ ...event, status, @@ -24,9 +116,23 @@ function emitActionEvent( * Traces Pinia action lifecycle events through a broadcast channel. * * The plugin never retains action arguments, results, or state snapshots. + * In development, set `airi:debug:pinia-tracing` to `true` in local storage + * before a reload to print one action and mutation rate summary every five seconds. */ export const piniaPluginTracing: PiniaPlugin = ({ store }) => { + const tracedWindow = startRateTracing() + + if (tracedWindow) { + store.$subscribe((mutation) => { + incrementRateTraceCount(tracedWindow.mutations, mutation.storeId) + incrementRateTraceCount(tracedWindow.mutationTypes, mutation.type) + }, { detached: true, flush: 'sync' }) + } + store.$onAction(({ name, after, onError }) => { + if (tracedWindow) + incrementRateTraceCount(tracedWindow.actions, `${store.$id}.${name}`) + const event = { invocationId: nanoid(), storeId: store.$id, @@ -36,6 +142,10 @@ export const piniaPluginTracing: PiniaPlugin = ({ store }) => { emitActionEvent(event, 'started') after(() => emitActionEvent(event, 'completed')) - onError(error => emitActionEvent(event, 'failed', error)) + onError((error) => { + if (tracedWindow) + tracedWindow.actionFailures += 1 + emitActionEvent(event, 'failed', error) + }) }) } diff --git a/packages/stage-ui/src/stores/auth.test.ts b/packages/stage-ui/src/stores/auth.test.ts index ade5169c1..686bd9709 100644 --- a/packages/stage-ui/src/stores/auth.test.ts +++ b/packages/stage-ui/src/stores/auth.test.ts @@ -1,7 +1,7 @@ import type { Session, User } from 'better-auth' import { createPinia, setActivePinia } from 'pinia' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { nextTick } from 'vue' import { triggerSignIn } from '../libs/auth' @@ -23,6 +23,34 @@ vi.mock('../libs/auth-oidc', () => ({ refreshAccessToken: vi.fn(), })) +class MemoryStorage implements Storage { + readonly values = new Map() + + get length() { + return this.values.size + } + + clear() { + this.values.clear() + } + + getItem(key: string) { + return this.values.get(key) ?? null + } + + key(index: number) { + return [...this.values.keys()][index] ?? null + } + + removeItem(key: string) { + this.values.delete(key) + } + + setItem(key: string, value: string) { + this.values.set(key, value) + } +} + const user: User = { id: 'user-1', name: 'AIRI User', @@ -42,7 +70,11 @@ const session: Session = { } describe('auth store sign-in requests', () => { + let storage: MemoryStorage + beforeEach(() => { + storage = new MemoryStorage() + vi.stubGlobal('localStorage', storage) setActivePinia(createPinia()) vi.mocked(triggerSignIn).mockReset() vi.mocked(triggerSignIn).mockResolvedValue() @@ -50,6 +82,10 @@ describe('auth store sign-in requests', () => { vi.mocked(requestAuthSession).mockResolvedValue({ user, session }) }) + afterEach(() => { + vi.unstubAllGlobals() + }) + it('allows sign-in to be requested again after an external flow is canceled', async () => { const authStore = useAuthStore() @@ -94,7 +130,43 @@ describe('auth store sign-in requests', () => { expect(authStore.idToken).toBe('new-id-token') expect(authStore.user).toEqual(user) expect(authStore.session).toEqual(session) + expect(storage.getItem('auth/v1/token')).toBe('new-access-token') + expect(storage.getItem('auth/v1/refresh-token')).toBe('new-refresh-token') + expect(storage.getItem('auth/v1/oidc-id-token')).toBe('new-id-token') + expect(storage.getItem('auth/v1/oidc-client-id')).toBe('airi-stage-electron') + expect(storage.getItem('auth/v1/oidc-token-expiry')).not.toBeNull() await authStore.clearAllAuthState() }) + + it('loads persisted credentials only when the store initializes', async () => { + storage.values.set('auth/v1/token', 'persisted-access-token') + storage.values.set('auth/v1/refresh-token', 'persisted-refresh-token') + storage.values.set('auth/v1/oidc-id-token', 'persisted-id-token') + storage.values.set('auth/v1/oidc-client-id', 'airi-stage-web') + storage.values.set('auth/v1/oidc-token-expiry', String(Date.now() + 60_000)) + const authStore = useAuthStore() + + expect(authStore.token).toBeNull() + + await authStore.initialize() + + expect(requestAuthSession).toHaveBeenCalledWith('persisted-access-token') + expect(authStore.token).toBe('persisted-access-token') + }) + + it('does not persist state patches received from another window', async () => { + const authStore = useAuthStore() + + // ROOT CAUSE: + // + // `useLocalStorage` observed every Pinia patch and wrote it to storage. + // The storage event then reached another window, whose synced Pinia patch + // wrote the same value back. This formed an unbounded cross-window loop. + // Auth persistence now runs only inside auth commands. + authStore.$patch({ token: 'synced-access-token' }) + await nextTick() + + expect(storage.values.size).toBe(0) + }) }) diff --git a/packages/stage-ui/src/stores/auth.ts b/packages/stage-ui/src/stores/auth.ts index dd890afd2..8b3f87c16 100644 --- a/packages/stage-ui/src/stores/auth.ts +++ b/packages/stage-ui/src/stores/auth.ts @@ -3,7 +3,7 @@ import type {} from 'pinia-plugin-synced' import { errorMessageFrom } from '@moeru/std' import { isStageTamagotchi } from '@proj-airi/stage-shared' -import { StorageSerializers, useLocalStorage, useTimeoutFn, whenever } from '@vueuse/core' +import { useTimeoutFn, whenever } from '@vueuse/core' import { defineStore } from 'pinia' import { computed, ref, watch } from 'vue' @@ -14,6 +14,45 @@ import { authClient, requestAuthSession } from '../libs/auth-client' import { refreshAccessToken } from '../libs/auth-oidc' import { SERVER_URL } from '../libs/server' +function createLocalStorageForAuth() { + const keys = { + accessToken: 'auth/v1/token', + refreshToken: 'auth/v1/refresh-token', + idToken: 'auth/v1/oidc-id-token', + oidcClientId: 'auth/v1/oidc-client-id', + tokenExpiry: 'auth/v1/oidc-token-expiry', + } as const + + function setOptional(key: string, value: string | null): void { + if (value === null) { + localStorage.removeItem(key) + return + } + + localStorage.setItem(key, value) + } + + return { + clear() { + for (const key of Object.values(keys)) + localStorage.removeItem(key) + }, + getAccessToken: () => localStorage.getItem(keys.accessToken), + getRefreshToken: () => localStorage.getItem(keys.refreshToken), + getIdToken: () => localStorage.getItem(keys.idToken), + getOidcClientId: () => localStorage.getItem(keys.oidcClientId), + getTokenExpiry() { + const expiry = Number.parseInt(localStorage.getItem(keys.tokenExpiry) ?? '', 10) + return Number.isFinite(expiry) ? expiry : null + }, + setAccessToken: (value: string | null) => setOptional(keys.accessToken, value), + setRefreshToken: (value: string | null) => setOptional(keys.refreshToken, value), + setIdToken: (value: string | null) => setOptional(keys.idToken, value), + setOidcClientId: (value: string | null) => setOptional(keys.oidcClientId, value), + setTokenExpiry: (value: number | null) => setOptional(keys.tokenExpiry, value?.toString() ?? null), + } +} + /** Tokens that complete one OIDC sign-in flow. */ export interface AuthTokenSet { accessToken: string @@ -30,29 +69,29 @@ export interface AuthTokenSet { * `providers` to safely depend on it without creating a circular import. */ export const useAuthStore = defineStore('auth', () => { - const user = useLocalStorage('auth/v1/user', null, { - // Why: https://github.com/vueuse/vueuse/pull/614#issuecomment-875450160 - serializer: StorageSerializers.object, - }) - const session = useLocalStorage('auth/v1/session', null, { serializer: StorageSerializers.object }) - const token = useLocalStorage('auth/v1/token', null) - const refreshToken = useLocalStorage('auth/v1/refresh-token', null) + const storage = createLocalStorageForAuth() + + // Pinia owns live auth state. Persistence is command-driven so a state patch + // received from another window cannot write back into the transport. + const user = ref(null) + const session = ref(null) + const token = ref(null) + const refreshToken = ref(null) // NOTICE: // Persisted to drive `id_token_hint` on RP-Initiated Logout // (`/api/auth/oauth2/end-session`). The `sid` claim inside the ID token is // what lets the OIDC provider locate the server-side session row to delete // — without this we'd be back to relying on cross-site session cookies. - const idToken = useLocalStorage('auth/v1/oidc-id-token', null) + const idToken = ref(null) const isAuthenticated = computed(() => !!user.value && !!session.value) const userId = computed(() => user.value?.id ?? 'local') // --- OIDC token refresh state --- - // Persisted so refresh scheduling survives page reloads. - const oidcClientId = useLocalStorage('auth/v1/oidc-client-id', null) - const tokenExpiry = useLocalStorage('auth/v1/oidc-token-expiry', null) + const oidcClientId = ref(null) + const tokenExpiry = ref(null) const initialized = ref(false) - const credits = useLocalStorage('user/v1/flux', 0) + const credits = ref(0) // Cross-app "user must log in" flag. Setting this to true triggers an // immediate OIDC redirect on web (mobile + desktop). Electron skips this @@ -104,30 +143,6 @@ export const useAuthStore = defineStore('auth', () => { } } - // Dispatch hooks when auth state changes - watch(isAuthenticated, async (val, oldVal) => { - if (val && !oldVal) { - for (const hook of authenticatedHooks) { - try { - await hook() - } - catch (e) { - console.error('auth hook error', e) - } - } - } - if (!val && oldVal) { - for (const hook of logoutHooks) { - try { - await hook() - } - catch (e) { - console.error('logout hook error', e) - } - } - } - }) - // --- OIDC token refresh scheduling --- // Uses useTimeoutFn for automatic cleanup on store teardown. // The delay ref is updated by scheduleTokenRefresh before calling start(). @@ -148,10 +163,14 @@ export const useAuthStore = defineStore('auth', () => { try { const tokens = await refreshAccessToken(oidcClientId.value!, refreshToken.value!) token.value = tokens.access_token - if (tokens.refresh_token) + storage.setAccessToken(tokens.access_token) + if (tokens.refresh_token) { refreshToken.value = tokens.refresh_token + storage.setRefreshToken(tokens.refresh_token) + } if (tokens.expires_in) { tokenExpiry.value = Date.now() + tokens.expires_in * 1000 + storage.setTokenExpiry(tokenExpiry.value) scheduleTokenRefresh(tokens.expires_in) } @@ -217,6 +236,12 @@ export const useAuthStore = defineStore('auth', () => { initialized.value = true + token.value = storage.getAccessToken() + refreshToken.value = storage.getRefreshToken() + idToken.value = storage.getIdToken() + oidcClientId.value = storage.getOidcClientId() + tokenExpiry.value = storage.getTokenExpiry() + const hasRefreshToken = !!refreshToken.value const hasClientId = !!oidcClientId.value if (hasRefreshToken !== hasClientId) { @@ -238,6 +263,11 @@ export const useAuthStore = defineStore('auth', () => { ? Date.now() + tokens.expiresIn * 1000 : null scheduleTokenRefresh(tokens.expiresIn) + storage.setAccessToken(token.value) + storage.setRefreshToken(refreshToken.value) + storage.setIdToken(idToken.value) + storage.setOidcClientId(oidcClientId.value) + storage.setTokenExpiry(tokenExpiry.value) return await fetchSession(tokens.accessToken) } @@ -316,6 +346,7 @@ export const useAuthStore = defineStore('auth', () => { oidcClientId.value = null tokenExpiry.value = null idToken.value = null + storage.clear() } async function clearAllAuthState(): Promise { @@ -332,17 +363,36 @@ export const useAuthStore = defineStore('auth', () => { } } - watch(isAuthenticated, async (val) => { - if (val) { - updateCredits() - + // This is the only watcher that reacts to an auth-state transition. Each + // window runs its own lifecycle hooks, while persistence remains owned by + // the auth commands above. + watch(isAuthenticated, async (authenticated, wasAuthenticated) => { + if (authenticated) { + void updateCredits() needsLogin.value = false + + if (!wasAuthenticated) + await dispatchHooks(authenticatedHooks, 'auth hook error') } else { credits.value = 0 + + if (wasAuthenticated) + await dispatchHooks(logoutHooks, 'logout hook error') } }, { immediate: true }) + async function dispatchHooks(hooks: AuthHook[], errorLabel: string): Promise { + for (const hook of hooks) { + try { + await hook() + } + catch (error) { + console.error(errorLabel, error) + } + } + } + return { user, userId, 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 0d9267172..64c36f1bd 100644 --- a/packages/stage-ui/src/stores/modules/airi-card.test.ts +++ b/packages/stage-ui/src/stores/modules/airi-card.test.ts @@ -1,5 +1,3 @@ -import type { SyncedPiniaRuntime } from 'pinia-plugin-synced' - import type { AiriCard } from './airi-card' import { createPinia, setActivePinia } from 'pinia' @@ -8,21 +6,9 @@ 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 +const { resetArtistryToGlobal } = vi.hoisted(() => ({ + resetArtistryToGlobal: vi.fn(), +})) // NOTICE: // Vitest runs these store tests in Node, where localforage cannot select a @@ -55,7 +41,7 @@ vi.mock('./artistry', async () => { providerOptions: {}, }), actions: { - resetToGlobal() {}, + resetToGlobal: resetArtistryToGlobal, }, }), } @@ -114,24 +100,23 @@ vi.mock('vue-i18n', () => ({ describe('airi-card store', () => { beforeEach(() => { setActivePinia(createPinia()) - syncedRuntime.isLeader = false - syncedRuntime.leadershipListener = undefined - syncedRuntime.stopLeadershipListener.mockClear() + resetArtistryToGlobal.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. + // A synchronized state snapshot replaced `activeCardId`. The old watcher + // interpreted that replicated state as a user command and applied module + // settings, which produced another synchronized snapshot. // - // https://github.com/moeru-ai/airi/pull/2304 - it('reinstalls the card watcher when a follower becomes the leader', async () => { + // We fixed this by applying settings only from the synchronized activation + // action. State replication remains free of runtime side effects. + it('applies card settings only through the activation command', async () => { const stageModelStore = useSettingsStageModel() const cardStore = useAiriCardStore() await cardStore.initialize() - const vrmCardId = cardStore.addCard({ + const vrmCardId = await cardStore.addCard({ name: 'VRM card', version: '1.0.0', description: 'Card for the promoted leader.', @@ -147,7 +132,7 @@ describe('airi-card store', () => { }, }, }, 'scratch') - const live2dCardId = cardStore.addCard({ + const live2dCardId = await cardStore.addCard({ name: 'Live2D card', version: '1.0.0', description: 'Card for the active leader.', @@ -165,24 +150,14 @@ describe('airi-card store', () => { }, 'scratch') stageModelStore.stageModelSelected = 'preset-live2d-1' - cardStore.startRuntime(syncedPinia) - cardStore.activeCardId = vrmCardId - - expect(stageModelStore.stageModelSelected).toBe('preset-live2d-1') - - syncedRuntime.leadershipListener?.(true) + await cardStore.activateCard(vrmCardId) expect(stageModelStore.stageModelSelected).toBe('preset-vrm-1') - cardStore.activeCardId = live2dCardId - expect(stageModelStore.stageModelSelected).toBe('preset-live2d-1') + cardStore.$patch({ activeCardId: live2dCardId }) + expect(stageModelStore.stageModelSelected).toBe('preset-vrm-1') - syncedRuntime.leadershipListener?.(false) - cardStore.activeCardId = vrmCardId + await cardStore.activateCard(live2dCardId) 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', () => { @@ -205,17 +180,17 @@ describe('airi-card store', () => { * @example * it('persists selected module config on active card', () => {}) */ - it('persists selected module config on active card', () => { + it('persists selected module config on active card', async () => { const stageModelStore = useSettingsStageModel() stageModelStore.stageModelSelected = 'preset-live2d-1' const cardStore = useAiriCardStore() - cardStore.initialize() + await cardStore.initialize() - expect(cardStore.updateActiveCardDisplayModel('display-model-iru-v2')).toBe(true) - expect(cardStore.updateActiveCardConsciousness({ provider: 'openrouter-ai', model: 'anthropic/claude-sonnet' })).toBe(true) - expect(cardStore.updateActiveCardVision({ provider: 'ollama', model: 'llava' })).toBe(true) - expect(cardStore.updateActiveCardSpeech({ provider: 'elevenlabs', model: 'eleven_multilingual_v2', voice_id: 'aria' })).toBe(true) + expect(await cardStore.updateActiveCardDisplayModel('display-model-iru-v2')).toBe(true) + expect(await cardStore.updateActiveCardConsciousness({ provider: 'openrouter-ai', model: 'anthropic/claude-sonnet' })).toBe(true) + expect(await cardStore.updateActiveCardVision({ provider: 'ollama', model: 'llava' })).toBe(true) + expect(await cardStore.updateActiveCardSpeech({ provider: 'elevenlabs', model: 'eleven_multilingual_v2', voice_id: 'aria' })).toBe(true) expect(cardStore.activeCard?.extensions.airi.modules).toMatchObject({ displayModelId: 'display-model-iru-v2', consciousness: { provider: 'openrouter-ai', model: 'anthropic/claude-sonnet' }, @@ -234,12 +209,12 @@ describe('airi-card store', () => { // // We fixed this by applying card settings from the stable activation key. // https://github.com/moeru-ai/airi/issues/2089 - it('issue #2089: applies the activated card display model to the stage runtime', () => { + it('issue #2089: applies the activated card display model to the stage runtime', async () => { const stageModelStore = useSettingsStageModel() stageModelStore.stageModelSelected = 'preset-live2d-1' const cardStore = useAiriCardStore() - cardStore.initialize() + await cardStore.initialize() const card: AiriCard = { name: 'VRM card', @@ -257,21 +232,21 @@ describe('airi-card store', () => { }, }, } - const cardId = cardStore.addCard(card, 'scratch') + const cardId = await cardStore.addCard(card, 'scratch') - cardStore.activeCardId = cardId + await cardStore.activateCard(cardId) expect(stageModelStore.stageModelSelected).toBe('preset-vrm-1') }) - it('applies edits to the currently active card display model', () => { + it('applies edits to the currently active card display model', async () => { const stageModelStore = useSettingsStageModel() stageModelStore.stageModelSelected = 'preset-live2d-1' const cardStore = useAiriCardStore() - cardStore.initialize() + await cardStore.initialize() - const cardId = cardStore.addCard({ + const cardId = await cardStore.addCard({ name: 'Editable card', version: '1.0.0', description: 'Card whose model can be edited', @@ -287,11 +262,11 @@ describe('airi-card store', () => { }, }, }, 'scratch') - cardStore.activeCardId = cardId + await cardStore.activateCard(cardId) const card = cardStore.getCard(cardId) expect(card).toBeDefined() - cardStore.updateCard(cardId, { + await cardStore.updateCard(cardId, { ...card!, extensions: { ...card!.extensions, @@ -308,6 +283,44 @@ describe('airi-card store', () => { expect(stageModelStore.stageModelSelected).toBe('preset-vrm-1') }) + // ROOT CAUSE: + // + // pinia-plugin-synced applies a structured clone of every synchronized + // store. The clone replaced the active card object, so the runtime watcher + // treated unchanged card settings as an edit. Applying those settings + // mutated other synchronized stores and committed another full snapshot. + // + // We prevent the feedback loop by applying runtime settings only through an + // explicit card command, never in response to a state snapshot. + it('does not reapply runtime settings for an unchanged synchronized card snapshot', async () => { + const cardStore = useAiriCardStore() + await cardStore.initialize() + + const cardId = await cardStore.addCard({ + name: 'Artistry card', + version: '1.0.0', + description: 'A card with object-valued runtime settings.', + extensions: { + airi: { + modules: { + artistry: { + options: { steps: 20 }, + }, + }, + agents: {}, + }, + }, + }, 'scratch') + await cardStore.activateCard(cardId) + + const applicationsBeforeSnapshot = resetArtistryToGlobal.mock.calls.length + const synchronizedCards = new Map(JSON.parse(JSON.stringify([...cardStore.cards]))) + + cardStore.$patch({ cards: synchronizedCards }) + + expect(resetArtistryToGlobal).toHaveBeenCalledTimes(applicationsBeforeSnapshot) + }) + // ROOT CAUSE: // // The settings reset clears the runtime model before resetting card state. @@ -315,13 +328,13 @@ describe('airi-card store', () => { // card, allowing its display model to overwrite the reset runtime value. // // https://github.com/moeru-ai/airi/pull/2090#discussion_r3610810272 - it('does not restore a stale card model during card state reset', () => { + it('does not restore a stale card model during card state reset', async () => { const stageModelStore = useSettingsStageModel() stageModelStore.stageModelSelected = 'preset-live2d-1' const cardStore = useAiriCardStore() - cardStore.initialize() - cardStore.updateActiveCardDisplayModel('preset-vrm-1') + await cardStore.initialize() + await cardStore.updateActiveCardDisplayModel('preset-vrm-1') stageModelStore.stageModelSelected = 'preset-live2d-1' cardStore.resetState() @@ -333,11 +346,11 @@ describe('airi-card store', () => { * @example * it('updates speech config on the active card', () => {}) */ - it('updates speech config on the active card', () => { + it('updates speech config on the active card', async () => { const cardStore = useAiriCardStore() - cardStore.initialize() + await cardStore.initialize() - expect(cardStore.updateActiveCardSpeech({ provider: 'elevenlabs', model: 'eleven_multilingual_v2', voice_id: 'aria' })).toBe(true) + expect(await cardStore.updateActiveCardSpeech({ provider: 'elevenlabs', model: 'eleven_multilingual_v2', voice_id: 'aria' })).toBe(true) expect(cardStore.activeCard?.extensions.airi.modules.speech).toMatchObject({ provider: 'elevenlabs', model: 'eleven_multilingual_v2', @@ -345,11 +358,11 @@ describe('airi-card store', () => { }) }) - it('keeps position-sensitive CCv3 fields separate from the stable system prompt', () => { + it('keeps position-sensitive CCv3 fields separate from the stable system prompt', async () => { const cardStore = useAiriCardStore() - cardStore.initialize() + await cardStore.initialize() - const cardId = cardStore.addCard({ + const cardId = await cardStore.addCard({ name: 'Runtime context card', version: '1.0.0', systemPrompt: 'Follow the character rules.', @@ -374,7 +387,7 @@ describe('airi-card store', () => { }, }, 'scratch') - cardStore.activeCardId = cardId + await cardStore.activateCard(cardId) expect(cardStore.systemPrompt).toBe([ 'Follow the character rules.', @@ -388,53 +401,53 @@ describe('airi-card store', () => { expect(cardStore.systemPrompt).not.toContain('What did you find?') }) - it('falls back to the default card when the active custom card is deleted', () => { + it('falls back to the default card when the active custom card is deleted', async () => { const cardStore = useAiriCardStore() - cardStore.initialize() + await cardStore.initialize() - const cardId = cardStore.addCard({ + const cardId = await cardStore.addCard({ name: 'Custom card', version: '1.0.0', description: 'A removable card.', }, 'scratch') - cardStore.activeCardId = cardId + await cardStore.activateCard(cardId) - cardStore.removeCard(cardId) + await cardStore.removeCard(cardId) expect(cardStore.cards.has(cardId)).toBe(false) expect(cardStore.activeCardId).toBe('default') expect(cardStore.activeCard?.name).toBe('ReLU') }) - it('keeps the built-in fallback card when deletion is requested directly', () => { + it('keeps the built-in fallback card when deletion is requested directly', async () => { const cardStore = useAiriCardStore() - cardStore.initialize() + await cardStore.initialize() - expect(cardStore.removeCard('default')).toBe(false) + expect(await cardStore.removeCard('default')).toBe(false) expect(cardStore.cards.has('default')).toBe(true) expect(cardStore.activeCardId).toBe('default') }) - it('preserves a valid persisted active card during initialization', () => { + it('preserves a valid persisted active card during initialization', async () => { const cardStore = useAiriCardStore() - const cardId = cardStore.addCard({ + const cardId = await cardStore.addCard({ name: 'Persisted active card', version: '1.0.0', description: 'Keep this selection.', }, 'scratch') cardStore.activeCardId = cardId - cardStore.initialize() + await cardStore.initialize() expect(cardStore.activeCardId).toBe(cardId) expect(cardStore.activeCard?.name).toBe('Persisted active card') }) - it('repairs a dangling persisted active card during initialization', () => { + it('repairs a dangling persisted active card during initialization', async () => { const cardStore = useAiriCardStore() cardStore.activeCardId = 'missing-card' - cardStore.initialize() + await cardStore.initialize() expect(cardStore.activeCardId).toBe('default') expect(cardStore.activeCard?.name).toBe('ReLU') diff --git a/packages/stage-ui/src/stores/modules/airi-card.ts b/packages/stage-ui/src/stores/modules/airi-card.ts index 76a71fd3c..faf6cf629 100644 --- a/packages/stage-ui/src/stores/modules/airi-card.ts +++ b/packages/stage-ui/src/stores/modules/airi-card.ts @@ -1,12 +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 } from 'pinia' -import { computed, watch } from 'vue' +import { computed } from 'vue' import { useI18n } from 'vue-i18n' import SystemPromptV2 from '../../constants/prompts/system-v2' @@ -41,11 +40,13 @@ function resolveSystemPrompt(card: AiriCard | undefined): string { export const useAiriCardStore = defineStore('airi-card', () => { const { t } = useI18n() - const cards = useLocalStorageManualReset>('airi-cards', new Map()) - const activeCardId = useLocalStorageManualReset('airi-card-active-id', 'default') + // Pinia synchronization owns cross-window updates. Local storage only loads + // and saves this renderer's durable copy; listening to storage events here + // would create a second cross-window state channel and echo cloned maps. + const cards = useLocalStorageManualReset>('airi-cards', new Map(), { listenToStorageChanges: false }) + const activeCardId = useLocalStorageManualReset('airi-card-active-id', 'default', { listenToStorageChanges: false }) const activeCard = computed(() => cards.value.get(activeCardId.value)) - function useRuntimeModuleStores() { return { artistry: useArtistryStore(), @@ -62,14 +63,14 @@ export const useAiriCardStore = defineStore('airi-card', () => { * from an existing card (profile switcher). Required so a new call site * can't silently degrade creation attribution. */ - const addCard = (card: AiriCard | Card | ccv3.CharacterCardV3, source: 'scratch' | 'import' | 'duplicate') => { + const addCard = async (card: AiriCard | Card | ccv3.CharacterCardV3, source: 'scratch' | 'import' | 'duplicate') => { const newCardId = nanoid() cards.value.set(newCardId, newAiriCard(card)) captureAnalyticsEvent('card_created', { card_id: newCardId, source }) return newCardId } - const removeCard = (id: string) => { + const removeCard = async (id: string) => { // The built-in card is the guaranteed fallback for every runtime profile. if (id === 'default') return false @@ -80,14 +81,16 @@ export const useAiriCardStore = defineStore('airi-card', () => { // The active id is persisted independently from the card map. Reset it // before consumers observe a dangling runtime profile after deletion. - if (activeCardId.value === id) + if (activeCardId.value === id) { activeCardId.value = 'default' + applyActiveCardSettings() + } captureAnalyticsEvent('character_deleted', { character_id: id }) return true } - const updateCard = (id: string, updates: AiriCard | Card | ccv3.CharacterCardV3) => { + const updateCard = async (id: string, updates: AiriCard | Card | ccv3.CharacterCardV3) => { const existingCard = cards.value.get(id) if (!existingCard) return false @@ -97,7 +100,11 @@ export const useAiriCardStore = defineStore('airi-card', () => { ...updates, } - cards.value.set(id, newAiriCard(updatedCard)) + const card = newAiriCard(updatedCard) + cards.value.set(id, card) + if (id === activeCardId.value) + applyActiveCardSettings(card) + return true } @@ -129,25 +136,37 @@ export const useAiriCardStore = defineStore('airi-card', () => { return true } - function updateActiveCardDisplayModel(displayModelId: string | undefined) { - return updateActiveCardModules(() => ({ displayModelId })) + async function updateActiveCardDisplayModel(displayModelId: string | undefined) { + const updated = updateActiveCardModules(() => ({ displayModelId })) + if (updated) + applyActiveCardSettings() + return updated } - function updateActiveCardConsciousness(consciousness: AiriExtension['modules']['consciousness']) { - return updateActiveCardModules(() => ({ consciousness })) + async function updateActiveCardConsciousness(consciousness: AiriExtension['modules']['consciousness']) { + const updated = updateActiveCardModules(() => ({ consciousness })) + if (updated) + applyActiveCardSettings() + return updated } - function updateActiveCardVision(vision: AiriExtension['modules']['vision']) { - return updateActiveCardModules(() => ({ vision })) + async function updateActiveCardVision(vision: AiriExtension['modules']['vision']) { + const updated = updateActiveCardModules(() => ({ vision })) + if (updated) + applyActiveCardSettings() + return updated } - function updateActiveCardSpeech(speech: Pick) { - return updateActiveCardModules(({ modules }) => ({ + async function updateActiveCardSpeech(speech: Pick) { + const updated = updateActiveCardModules(({ modules }) => ({ speech: { ...modules.speech, ...speech, }, })) + if (updated) + applyActiveCardSettings() + return updated } function resolveAiriExtension(card: Card | ccv3.CharacterCardV3): AiriExtension { @@ -309,7 +328,20 @@ export const useAiriCardStore = defineStore('airi-card', () => { if (!cards.value.has(activeCardId.value)) activeCardId.value = 'default' - initializeRuntimeModules() + applyActiveCardSettings() + } + + /** + * Selects a card and applies its module settings in the synchronization + * leader. Replicated state snapshots never invoke this command. + */ + async function activateCard(id: string) { + if (!cards.value.has(id)) + return false + + activeCardId.value = id + applyActiveCardSettings() + return true } function applyActiveCardSettings(newCard = activeCard.value) { @@ -360,55 +392,7 @@ export const useAiriCardStore = defineStore('airi-card', () => { } } - 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 - // activation watcher can briefly resolve the old default card and restore - // its display model during a full settings reset. cards.reset() activeCardId.reset() } @@ -427,8 +411,7 @@ export const useAiriCardStore = defineStore('airi-card', () => { getCard, resetState, initialize, - startRuntime, - disposeRuntime, + activateCard, currentModels: computed(() => { const { @@ -460,7 +443,17 @@ export const useAiriCardStore = defineStore('airi-card', () => { } }, { synced: { - actions: ['initialize'], + actions: [ + 'activateCard', + 'addCard', + 'initialize', + 'removeCard', + 'updateActiveCardConsciousness', + 'updateActiveCardDisplayModel', + 'updateActiveCardSpeech', + 'updateActiveCardVision', + 'updateCard', + ], state: true, }, }) diff --git a/packages/stage-ui/src/stores/modules/artistry-autonomous.ts b/packages/stage-ui/src/stores/modules/artistry-autonomous.ts index ba5babc51..19b86dde5 100644 --- a/packages/stage-ui/src/stores/modules/artistry-autonomous.ts +++ b/packages/stage-ui/src/stores/modules/artistry-autonomous.ts @@ -271,7 +271,7 @@ LATEST ${target === 'assistant' ? 'COMPANION RESPONSE' : 'USER INPUT'}: switch (spawnMode) { case 'bg': // Update character's active background - cardStore.updateCard(cardId, { + await cardStore.updateCard(cardId, { extensions: { ...activeCard.extensions, airi: { @@ -322,7 +322,7 @@ LATEST ${target === 'assistant' ? 'COMPANION RESPONSE' : 'USER INPUT'}: case 'bg_widget': default: // Both: Update background AND spawn widget - cardStore.updateCard(cardId, { + await cardStore.updateCard(cardId, { extensions: { ...activeCard.extensions, airi: { diff --git a/packages/stage-ui/src/stores/modules/artistry.test.ts b/packages/stage-ui/src/stores/modules/artistry.test.ts index 4cb845772..40ae1eb20 100644 --- a/packages/stage-ui/src/stores/modules/artistry.test.ts +++ b/packages/stage-ui/src/stores/modules/artistry.test.ts @@ -1,5 +1,6 @@ import { createPinia, setActivePinia } from 'pinia' import { beforeEach, describe, expect, it } from 'vitest' +import { nextTick } from 'vue' import { useArtistryStore } from './artistry' @@ -26,4 +27,51 @@ describe('artistry store', () => { // @example expect(artistryStore.configured).toBe(false) }) + + // ROOT CAUSE: + // + // pinia-plugin-synced restores the whole store with structured clones. The + // global and active provider options then have equal values but different + // object identities. Watching the global object and assigning it to the + // active object creates a second mutation after the synchronized patch. + // That mutation broadcasts another full snapshot and repeats indefinitely. + // + // We fixed this by making global-to-active resolution an explicit store + // operation and by preventing persistence from reflecting its own write. + it('does not mutate active options after applying an equal synchronized snapshot', async () => { + const artistryStore = useArtistryStore() + artistryStore.globalProviderOptions = { steps: 20 } + artistryStore.providerOptions = { steps: 20 } + artistryStore.comfyuiSavedWorkflows = [{ + id: 'workflow-1', + name: 'Workflow', + workflow: {}, + exposedFields: {}, + }] + await nextTick() + + let mutationCount = 0 + const stopSubscription = artistryStore.$subscribe(() => { + mutationCount += 1 + }, { flush: 'sync' }) + + artistryStore.$patch((currentState) => { + Object.assign(currentState, { + globalProviderOptions: { steps: 20 }, + providerOptions: { steps: 20 }, + comfyuiSavedWorkflows: [{ + id: 'workflow-1', + name: 'Workflow', + workflow: {}, + exposedFields: {}, + }], + }) + }) + const mutationCountAfterSnapshot = mutationCount + + await nextTick() + + expect(mutationCount).toBe(mutationCountAfterSnapshot) + stopSubscription() + }) }) diff --git a/packages/stage-ui/src/stores/modules/artistry.ts b/packages/stage-ui/src/stores/modules/artistry.ts index 355b70d68..741c84463 100644 --- a/packages/stage-ui/src/stores/modules/artistry.ts +++ b/packages/stage-ui/src/stores/modules/artistry.ts @@ -20,11 +20,16 @@ export interface ComfyUIWorkflowTemplate { } export const useArtistryStore = defineStore('artistry', () => { + // Pinia synchronization is the only cross-window state channel. These refs + // still load and save durable values, but storage events must not echo a + // second copy of the same state between Electron renderers. + const persistenceOptions = { listenToStorageChanges: false } + // --- Persistent Global Settings (User Preferences) --- - const globalProvider = useLocalStorageManualReset('artistry-provider', 'none') - const globalModel = useLocalStorageManualReset('artistry-model', '') - const globalPromptPrefix = useLocalStorageManualReset('artistry-prompt-prefix', '') - const globalProviderOptions = useLocalStorageManualReset | undefined>('artistry-provider-options', undefined) + const globalProvider = useLocalStorageManualReset('artistry-provider', 'none', persistenceOptions) + const globalModel = useLocalStorageManualReset('artistry-model', '', persistenceOptions) + const globalPromptPrefix = useLocalStorageManualReset('artistry-prompt-prefix', '', persistenceOptions) + const globalProviderOptions = useLocalStorageManualReset | undefined>('artistry-provider-options', undefined, persistenceOptions) // --- Active settings (transient, can be overridden by cards) --- const activeProvider = ref(globalProvider.value) @@ -36,40 +41,48 @@ export const useArtistryStore = defineStore('artistry', () => { const comfyuiServerUrl = useLocalStorageManualReset( 'artistry-comfyui-server-url', 'http://localhost:8188', + persistenceOptions, ) const comfyuiSavedWorkflows = useLocalStorageManualReset( 'artistry-comfyui-saved-workflows', [], + persistenceOptions, ) const comfyuiActiveWorkflow = useLocalStorageManualReset( 'artistry-comfyui-active-workflow', '', + persistenceOptions, ) // --- Replicate provider settings --- - const replicateApiKey = useLocalStorageManualReset('artistry-replicate-api-key', '') + const replicateApiKey = useLocalStorageManualReset('artistry-replicate-api-key', '', persistenceOptions) const replicateDefaultModel = useLocalStorageManualReset( 'artistry-replicate-default-model', 'black-forest-labs/flux-schnell', + persistenceOptions, ) const replicateAspectRatio = useLocalStorageManualReset( 'artistry-replicate-aspect-ratio', '16:9', + persistenceOptions, ) const replicateInferenceSteps = useLocalStorageManualReset( 'artistry-replicate-inference-steps', 4, + persistenceOptions, ) // --- Nano Banana (Google AI Studio) provider settings --- - const nanobananaApiKey = useLocalStorageManualReset('artistry-nanobanana-api-key', '') + const nanobananaApiKey = useLocalStorageManualReset('artistry-nanobanana-api-key', '', persistenceOptions) const nanobananaModel = useLocalStorageManualReset( 'artistry-nanobanana-model', 'gemini-3.1-flash-image-preview', + persistenceOptions, ) const nanobananaResolution = useLocalStorageManualReset( 'artistry-nanobanana-resolution', '1K', + persistenceOptions, ) /** @@ -115,7 +128,6 @@ export const useArtistryStore = defineStore('artistry', () => { watch(globalProvider, val => activeProvider.value = val) watch(globalModel, val => activeModel.value = val) watch(globalPromptPrefix, val => defaultPromptPrefix.value = val) - watch(globalProviderOptions, val => providerOptions.value = val) const configured = computed(() => { if (!activeProvider.value) diff --git a/packages/stage-ui/src/stores/modules/consciousness.ts b/packages/stage-ui/src/stores/modules/consciousness.ts index 124c0a4ba..e5b43972d 100644 --- a/packages/stage-ui/src/stores/modules/consciousness.ts +++ b/packages/stage-ui/src/stores/modules/consciousness.ts @@ -61,7 +61,7 @@ export const useConsciousnessStore = defineStore('consciousness', () => { // provider's model and chat requests failed upstream with model_not_found. // // The watcher is synchronous on purpose: call sites assign the provider - // first and a new model right after (e.g. use-auth-provider-sync), so a + // first and a new model right after, so a // deferred reset would wipe the model they just chose. Synchronous flush // makes "set provider, then set model" a safe, ordered operation. // diff --git a/packages/stage-ui/src/stores/providers/config.ts b/packages/stage-ui/src/stores/providers/config.ts index f13d95a47..9a5df983f 100644 --- a/packages/stage-ui/src/stores/providers/config.ts +++ b/packages/stage-ui/src/stores/providers/config.ts @@ -102,7 +102,7 @@ export const useProviderConfigStore = defineStore('provider-config', () => { } function getProviderConfig(providerId: string) { - return getProvider(providerId)?.config + return providers.value[providerId]?.config } function ensureProvider(providerId: string, definitionId: string, config: Record = {}) { @@ -133,7 +133,7 @@ export const useProviderConfigStore = defineStore('provider-config', () => { } function setProviderStatus(providerId: string, status: ProviderValidationStatus) { - const provider = getProvider(providerId) + const provider = providers.value[providerId] if (provider) provider.status = status } diff --git a/packages/stage-ui/src/stores/providers/provider.test.ts b/packages/stage-ui/src/stores/providers/provider.test.ts index 69967cb3b..0f5de33f2 100644 --- a/packages/stage-ui/src/stores/providers/provider.test.ts +++ b/packages/stage-ui/src/stores/providers/provider.test.ts @@ -1,7 +1,9 @@ import { createPinia, setActivePinia } from 'pinia' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { nextTick } from 'vue' import { OFFICIAL_SPEECH_PROVIDER_ID } from '../../libs/providers/providers/official' +import { useProviderConfigStore } from './config' import { useProviderStore } from './provider' vi.mock('vue-i18n', () => ({ @@ -39,6 +41,52 @@ describe('provider store synchronization boundary', () => { expect(store.providerRuntimeState.openai).toEqual(runtimeState) }) + // ROOT CAUSE: + // + // The provider store installed immediate watchers that called synchronized + // background actions. Every renderer created the same watchers, so one + // shared state transition produced one routed action per renderer. + // + // We fixed this by keeping background work behind explicit action calls. + it('does not start background provider actions when shared configuration changes', async () => { + const store = useProviderStore() + const configStore = useProviderConfigStore() + + await nextTick() + await new Promise(resolve => queueMicrotask(resolve)) + + const refreshValidation = vi.spyOn(store, 'refreshListedProviderValidation').mockResolvedValue() + const refreshModels = vi.spyOn(store, 'refreshModelsForChangedCredentials').mockResolvedValue() + + configStore.ensureProvider('openai', 'openai', { apiKey: 'test-key' }) + await nextTick() + await new Promise(resolve => queueMicrotask(resolve)) + + expect(refreshValidation).not.toHaveBeenCalled() + expect(refreshModels).not.toHaveBeenCalled() + }) + + // ROOT CAUSE: + // + // Provider metadata projection called the config store's `getProvider` + // action once for every registered provider. Pinia tracing and plugins then + // processed hundreds of action lifecycle events during renderer startup, + // even though each call was only a read. + // Internal provider projections now read the reactive provider map directly. + it('does not dispatch config actions while projecting provider metadata', async () => { + const configStore = useProviderConfigStore() + let getProviderCalls = 0 + configStore.$onAction(({ name }) => { + if (name === 'getProvider') + getProviderCalls += 1 + }) + + useProviderStore() + await nextTick() + + expect(getProviderCalls).toBe(0) + }) + // ROOT CAUSE: // // A model request kept a reference to its runtime entry across an await. diff --git a/packages/stage-ui/src/stores/providers/provider.ts b/packages/stage-ui/src/stores/providers/provider.ts index 8119ad3f4..915d600e5 100644 --- a/packages/stage-ui/src/stores/providers/provider.ts +++ b/packages/stage-ui/src/stores/providers/provider.ts @@ -19,7 +19,7 @@ import { computedAsync, useIntervalFn } from '@vueuse/core' import { listModels } from '@xsai/model' import { uniqBy } from 'es-toolkit' import { defineStore } from 'pinia' -import { computed, ref, watch } from 'vue' +import { computed, ref } from 'vue' import { useI18n } from 'vue-i18n' import { @@ -31,7 +31,6 @@ import { validateProvider as runProviderValidation, } from '../../libs/providers' import { selectProviderMetadata, selectProvidersMetadata } from '../../libs/providers/metadata' -import { useAuthStore } from '../auth' import { useProviderConfigStore } from './config' export type { ModelInfo, VoiceInfo } from '../../libs/providers/types' @@ -72,20 +71,15 @@ export const useProviderStore = defineStore('provider', () => { const providerStateStore = useProviderStateStore() const providerCredentials = computed(() => providerConfigStore.configs) const addedProviders = computed(() => providerConfigStore.addedProviders) - // Synced state applies fresh object snapshots. Compare serialized values so - // an equivalent snapshot does not restart validation and publish more state. - const providerCredentialsSignature = computed(() => JSON.stringify(providerCredentials.value)) - const addedProvidersSignature = computed(() => JSON.stringify(addedProviders.value)) // Provider instances contain functions and transport handles. Keep this map // private so it never enters Pinia state. const providerInstanceCache = new Map() const { t } = useI18n() - const authState = useAuthStore() const VISION_PROVIDER_ID_PREFIX = 'vision-' function getProviderDefinitionId(providerId: string) { - const configuredProvider = providerConfigStore.getProvider(providerId) + const configuredProvider = providerConfigStore.providers[providerId] if (configuredProvider) return configuredProvider.definitionId @@ -262,7 +256,7 @@ export const useProviderStore = defineStore('provider', () => { initializeProviderRuntimeState(providerId) const configString = JSON.stringify(config || {}) const runtimeState = providerRuntimeState.value[providerId] - const configuredProvider = providerConfigStore.getProvider(providerId) + const configuredProvider = providerConfigStore.providers[providerId] const cacheKey = `${providerId}:${configString}` const forceValidation = options.force === true @@ -414,17 +408,6 @@ export const useProviderStore = defineStore('provider', () => { startPeriodicRuntimeValidation() } - function requestListedProviderValidation() { - // Store setup runs before pinia-plugin-synced installs its action wrappers. - // Defer the public-store lookup so background validation is routed to the - // leader instead of running independently in every renderer. - queueMicrotask(() => void useProviderStore().refreshListedProviderValidation()) - } - - watch(providerCredentialsSignature, requestListedProviderValidation, { immediate: true }) - watch(addedProvidersSignature, requestListedProviderValidation) - watch(() => authState.isAuthenticated, requestListedProviderValidation) - // Available providers (only those that are properly configured) const availableProviders = computed(() => Object.values(providerConfigStore.providers) .filter(provider => provider.status === 'configured') @@ -679,18 +662,14 @@ export const useProviderStore = defineStore('provider', () => { await disposeProviderInstance(providerId) // If the provider is configured and has the capability, refetch its models - if (providerConfigStore.getProvider(providerId)?.status === 'configured' && supportsModelListing(providerId)) { + if (providerConfigStore.providers[providerId]?.status === 'configured' && supportsModelListing(providerId)) { await fetchModelsForProvider(providerId) } } } - watch(providerCredentialsSignature, () => { - queueMicrotask(() => void useProviderStore().refreshModelsForChangedCredentials()) - }, { immediate: true }) - function projectProvider(providerId: string): ProviderMetadata | undefined { - const configuredProvider = providerConfigStore.getProvider(providerId) + const configuredProvider = providerConfigStore.providers[providerId] const metadata = providerMetadata[providerId] ?? providerMetadata[configuredProvider?.definitionId ?? ''] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5c9c93347..f8f24a960 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -877,8 +877,8 @@ catalogs: specifier: ^3.0.4 version: 3.0.4 pinia-plugin-synced: - specifier: ^0.1.0 - version: 0.1.0 + specifier: ^0.1.3 + version: 0.1.3 pixi-filters: specifier: ^4.2.0 version: 4.2.0 @@ -2127,7 +2127,7 @@ importers: version: 3.0.2(electron@41.2.1) '@electron-toolkit/tsconfig': specifier: 'catalog:' - version: 2.0.0(@types/node@24.12.2) + version: 2.0.0(@types/node@25.6.0) '@electron-toolkit/utils': specifier: 'catalog:' version: 4.0.0(electron@41.2.1) @@ -2166,7 +2166,7 @@ importers: version: 3.1.0 '@intlify/unplugin-vue-i18n': specifier: 'catalog:' - version: 11.0.7(@vue/compiler-dom@3.5.32)(eslint@10.2.1(jiti@2.7.0))(rollup@4.60.1)(typescript@5.9.3)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-i18n@11.3.2(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)) + version: 11.0.7(@vue/compiler-dom@3.5.32)(eslint@10.2.1(jiti@2.7.0))(rollup@4.60.1)(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-i18n@11.3.2(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)) '@modelcontextprotocol/sdk': specifier: 'catalog:' version: 1.30.0(@cfworker/json-schema@4.1.1)(zod@4.4.3) @@ -2202,10 +2202,10 @@ importers: version: link:../../packages/ui-transitions '@proj-airi/unplugin-fetch': specifier: 'catalog:' - version: 0.2.3(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 0.2.3(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) '@proj-airi/unplugin-live2d-sdk': specifier: 'catalog:' - version: 0.1.7(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + version: 0.1.7(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) '@types/audioworklet': specifier: 'catalog:' version: 0.0.97 @@ -2232,7 +2232,7 @@ importers: version: 2.10.3 '@vitejs/plugin-vue': specifier: 'catalog:' - version: 6.0.6(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3)) + version: 6.0.6(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3)) '@vue-macros/volar': specifier: 'catalog:' version: 3.1.2(typescript@5.9.3)(vue-tsc@3.2.6(typescript@5.9.3))(vue@3.5.32(typescript@5.9.3)) @@ -2265,7 +2265,7 @@ importers: version: 6.8.3 electron-vite: specifier: 'catalog:' - version: 5.0.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 5.0.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) get-port-please: specifier: 'catalog:' version: 3.2.0 @@ -2286,31 +2286,31 @@ importers: version: 2.2.6 unocss-preset-scrollbar: specifier: 'catalog:' - version: 4.0.0(unocss@66.6.8(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))) + version: 4.0.0(unocss@66.6.8(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))) unplugin-info: specifier: 'catalog:' - version: 1.3.2(esbuild@0.27.2)(rollup@4.60.1)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 1.3.2(esbuild@0.27.2)(rollup@4.60.1)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) unplugin-yaml: specifier: 'catalog:' - version: 4.1.0(esbuild@0.27.2)(rolldown@1.0.0-rc.16)(rollup@4.60.1)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.0(esbuild@0.27.2)(rolldown@1.0.0-rc.16)(rollup@4.60.1)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) vite: specifier: 'catalog:' - version: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + version: 8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) vite-bundle-visualizer: specifier: 'catalog:' version: 1.2.1(rolldown@1.0.0-rc.16)(rollup@4.60.1) vite-plugin-mkcert: specifier: 'catalog:' - version: 2.0.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 2.0.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) vite-plugin-vue-devtools: specifier: 'catalog:' - version: 8.1.1(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3)) + version: 8.1.1(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3)) vite-plugin-vue-layouts: specifier: 'catalog:' - version: 0.11.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-router@5.0.4(@pinia/colada@1.2.1(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(@vue/compiler-sfc@3.5.32)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)) + version: 0.11.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-router@5.0.4(@pinia/colada@1.2.1(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(@vue/compiler-sfc@3.5.32)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)) vue-macros: specifier: 'catalog:' - version: 3.1.2(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@vueuse/core@14.2.1(vue@3.5.32(typescript@5.9.3)))(esbuild@0.27.2)(rolldown@1.0.0-rc.16)(rollup@4.60.1)(typescript@5.9.3)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-tsc@3.2.6(typescript@5.9.3))(vue@3.5.32(typescript@5.9.3)) + version: 3.1.2(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@vueuse/core@14.2.1(vue@3.5.32(typescript@5.9.3)))(esbuild@0.27.2)(rolldown@1.0.0-rc.16)(rollup@4.60.1)(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-tsc@3.2.6(typescript@5.9.3))(vue@3.5.32(typescript@5.9.3)) vue-tsc: specifier: 'catalog:' version: 3.2.6(typescript@5.9.3) @@ -4437,7 +4437,7 @@ importers: version: 3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)) pinia-plugin-synced: specifier: 'catalog:' - version: 0.1.0(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)) + version: 0.1.3(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)) posthog-js: specifier: 'catalog:' version: 1.306.1 @@ -17170,8 +17170,8 @@ packages: resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} engines: {node: '>=6'} - pinia-plugin-synced@0.1.0: - resolution: {integrity: sha512-53LEjp6A5ub16fwPdM01ZwYtLF+uG5i3JOfwbA5/m1j7cmiIAdlb4dSd5NcY+fZDw51f/DXWBiLAqV/C1qyKTg==} + pinia-plugin-synced@0.1.3: + resolution: {integrity: sha512-qisOMMeMClWqHMdJSBZzIGxLQKRTWA2dqF48l++Ys1ay91fOUuT0UcNZWYsrs4eP1MvAR3DMXskknen8iYyq6Q==} peerDependencies: pinia: '>=3.0.4 <5' vue: ^3.5.0 @@ -21699,9 +21699,9 @@ snapshots: dependencies: electron: 41.2.1 - '@electron-toolkit/tsconfig@2.0.0(@types/node@24.12.2)': + '@electron-toolkit/tsconfig@2.0.0(@types/node@25.6.0)': dependencies: - '@types/node': 24.12.2 + '@types/node': 25.6.0 '@electron-toolkit/utils@4.0.0(electron@41.2.1)': dependencies: @@ -22638,31 +22638,6 @@ snapshots: - supports-color - typescript - '@intlify/unplugin-vue-i18n@11.0.7(@vue/compiler-dom@3.5.32)(eslint@10.2.1(jiti@2.7.0))(rollup@4.60.1)(typescript@5.9.3)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-i18n@11.3.2(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))': - dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1(jiti@2.7.0)) - '@intlify/bundle-utils': 11.0.7(vue-i18n@11.3.2(vue@3.5.32(typescript@5.9.3))) - '@intlify/shared': 11.3.2 - '@intlify/vue-i18n-extensions': 8.0.0(@intlify/shared@11.3.2)(@vue/compiler-dom@3.5.32)(vue-i18n@11.3.2(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)) - '@rollup/pluginutils': 5.3.0(rollup@4.60.1) - '@typescript-eslint/scope-manager': 8.63.0 - '@typescript-eslint/typescript-estree': 8.63.0(typescript@5.9.3) - debug: 4.4.3(supports-color@10.2.2) - fast-glob: 3.3.3 - pathe: 2.0.3 - picocolors: 1.1.1 - unplugin: 2.3.11 - vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) - vue: 3.5.32(typescript@5.9.3) - optionalDependencies: - vue-i18n: 11.3.2(vue@3.5.32(typescript@5.9.3)) - transitivePeerDependencies: - - '@vue/compiler-dom' - - eslint - - rollup - - supports-color - - typescript - '@intlify/unplugin-vue-i18n@11.0.7(@vue/compiler-dom@3.5.32)(eslint@10.2.1(jiti@2.7.0))(rollup@4.60.1)(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-i18n@11.3.2(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1(jiti@2.7.0)) @@ -24793,35 +24768,11 @@ snapshots: '@proj-airi/unocss-preset-chromatic@1.1.1': {} - '@proj-airi/unplugin-fetch@0.2.3(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': - dependencies: - ofetch: 1.5.1 - vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) - '@proj-airi/unplugin-fetch@0.2.3(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': dependencies: ofetch: 1.5.1 vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) - '@proj-airi/unplugin-live2d-sdk@0.1.7(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)': - dependencies: - ofetch: 1.5.1 - vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) - yauzl: 3.3.0 - transitivePeerDependencies: - - '@types/node' - - '@vitejs/devtools' - - esbuild - - jiti - - less - - sass - - sass-embedded - - stylus - - sugarss - - terser - - tsx - - yaml - '@proj-airi/unplugin-live2d-sdk@0.1.7(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)': dependencies: ofetch: 1.5.1 @@ -26556,12 +26507,6 @@ snapshots: transitivePeerDependencies: - typescript - '@vitejs/plugin-vue@6.0.6(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3))': - dependencies: - '@rolldown/pluginutils': 1.0.0-rc.13 - vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) - vue: 3.5.32(typescript@5.9.3) - '@vitejs/plugin-vue@6.0.6(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.13 @@ -26837,15 +26782,6 @@ snapshots: transitivePeerDependencies: - vue - '@vue-macros/devtools@3.1.2(typescript@5.9.3)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': - dependencies: - sirv: 3.0.2 - vue: 3.5.32(typescript@5.9.3) - optionalDependencies: - vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) - transitivePeerDependencies: - - typescript - '@vue-macros/devtools@3.1.2(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': dependencies: sirv: 3.0.2 @@ -29000,7 +28936,7 @@ snapshots: transitivePeerDependencies: - supports-color - electron-vite@5.0.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): + electron-vite@5.0.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.0) @@ -29008,7 +28944,7 @@ snapshots: esbuild: 0.25.12 magic-string: 0.30.21 picocolors: 1.1.1 - vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) transitivePeerDependencies: - supports-color @@ -32941,7 +32877,7 @@ snapshots: pify@4.0.1: optional: true - pinia-plugin-synced@0.1.0(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)): + pinia-plugin-synced@0.1.3(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)): dependencies: '@moeru/std': 0.1.0-beta.20 es-toolkit: 1.50.0 @@ -35168,6 +35104,11 @@ snapshots: '@unocss/preset-mini': 66.6.8 unocss: 66.6.8(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + unocss-preset-scrollbar@4.0.0(unocss@66.6.8(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))): + dependencies: + '@unocss/preset-mini': 66.6.8 + unocss: 66.6.8(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + unocss@66.6.8(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): dependencies: '@unocss/cli': 66.6.8 @@ -35234,14 +35175,6 @@ snapshots: unplugin: 2.3.11 vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) - unplugin-combine@2.3.0(esbuild@0.27.2)(rolldown@1.0.0-rc.16)(rollup@4.60.1)(unplugin@2.3.11)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): - optionalDependencies: - esbuild: 0.27.2 - rolldown: 1.0.0-rc.16 - rollup: 4.60.1 - unplugin: 2.3.11 - vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) - unplugin-combine@2.3.0(esbuild@0.27.2)(rolldown@1.0.0-rc.16)(rollup@4.60.1)(unplugin@2.3.11)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): optionalDependencies: esbuild: 0.27.2 @@ -35263,19 +35196,6 @@ snapshots: transitivePeerDependencies: - supports-color - unplugin-info@1.3.2(esbuild@0.27.2)(rollup@4.60.1)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): - dependencies: - ci-info: 4.4.0 - git-url-parse: 16.1.0 - simple-git: 3.36.0 - unplugin: 2.3.11 - optionalDependencies: - esbuild: 0.27.2 - rollup: 4.60.1 - vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) - transitivePeerDependencies: - - supports-color - unplugin-info@1.3.2(esbuild@0.27.2)(rollup@4.60.1)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): dependencies: ci-info: 4.4.0 @@ -35369,17 +35289,6 @@ snapshots: rollup: 2.80.0 vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) - unplugin-yaml@4.1.0(esbuild@0.27.2)(rolldown@1.0.0-rc.16)(rollup@4.60.1)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): - dependencies: - '@rollup/pluginutils': 5.3.0(rollup@4.60.1) - unplugin: 3.0.0 - yaml: 2.8.3 - optionalDependencies: - esbuild: 0.27.2 - rolldown: 1.0.0-rc.16 - rollup: 4.60.1 - vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) - unplugin-yaml@4.1.0(esbuild@0.27.2)(rolldown@1.0.0-rc.16)(rollup@4.60.1)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): dependencies: '@rollup/pluginutils': 5.3.0(rollup@4.60.1) @@ -35618,22 +35527,12 @@ snapshots: - rollup - supports-color - vite-dev-rpc@1.1.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): - dependencies: - birpc: 2.9.0 - vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) - vite-hot-client: 2.1.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) - vite-dev-rpc@1.1.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): dependencies: birpc: 2.9.0 vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) vite-hot-client: 2.1.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) - vite-hot-client@2.1.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): - dependencies: - vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) - vite-hot-client@2.1.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): dependencies: vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) @@ -35679,21 +35578,6 @@ snapshots: - tsx - yaml - vite-plugin-inspect@11.3.3(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): - dependencies: - ansis: 4.2.0 - debug: 4.4.3(supports-color@10.2.2) - error-stack-parser-es: 1.0.5 - ohash: 2.0.11 - open: 10.2.0 - perfect-debounce: 2.1.0 - sirv: 3.0.2 - unplugin-utils: 0.3.1 - vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) - vite-dev-rpc: 1.1.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) - transitivePeerDependencies: - - supports-color - vite-plugin-inspect@11.3.3(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): dependencies: ansis: 4.2.0 @@ -35725,13 +35609,6 @@ snapshots: - typescript - ws - vite-plugin-mkcert@2.0.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): - dependencies: - debug: 4.4.3(supports-color@10.2.2) - supports-color: 10.2.2 - undici: 8.1.0 - vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) - vite-plugin-mkcert@2.0.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): dependencies: debug: 4.4.3(supports-color@10.2.2) @@ -35750,20 +35627,6 @@ snapshots: transitivePeerDependencies: - supports-color - vite-plugin-vue-devtools@8.1.1(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3)): - dependencies: - '@vue/devtools-core': 8.1.1(vue@3.5.32(typescript@5.9.3)) - '@vue/devtools-kit': 8.1.1 - '@vue/devtools-shared': 8.1.1 - sirv: 3.0.2 - vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) - vite-plugin-inspect: 11.3.3(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) - vite-plugin-vue-inspector: 5.3.2(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) - transitivePeerDependencies: - - '@nuxt/kit' - - supports-color - - vue - vite-plugin-vue-devtools@8.1.1(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3)): dependencies: '@vue/devtools-core': 8.1.1(vue@3.5.32(typescript@5.9.3)) @@ -35778,21 +35641,6 @@ snapshots: - supports-color - vue - vite-plugin-vue-inspector@5.3.2(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): - dependencies: - '@babel/core': 7.29.0 - '@babel/plugin-proposal-decorators': 7.28.0(@babel/core@7.29.0) - '@babel/plugin-syntax-import-attributes': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.0) - '@babel/plugin-transform-typescript': 7.28.5(@babel/core@7.29.0) - '@vue/babel-plugin-jsx': 1.5.0(@babel/core@7.29.0) - '@vue/compiler-dom': 3.5.32 - kolorist: 1.8.0 - magic-string: 0.30.21 - vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) - transitivePeerDependencies: - - supports-color - vite-plugin-vue-inspector@5.3.2(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): dependencies: '@babel/core': 7.29.0 @@ -35808,16 +35656,6 @@ snapshots: transitivePeerDependencies: - supports-color - vite-plugin-vue-layouts@0.11.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-router@5.0.4(@pinia/colada@1.2.1(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(@vue/compiler-sfc@3.5.32)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)): - dependencies: - debug: 4.4.3(supports-color@10.2.2) - fast-glob: 3.3.3 - vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) - vue: 3.5.32(typescript@5.9.3) - vue-router: 5.0.4(@pinia/colada@1.2.1(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(@vue/compiler-sfc@3.5.32)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)) - transitivePeerDependencies: - - supports-color - vite-plugin-vue-layouts@0.11.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-router@5.0.4(@pinia/colada@1.2.1(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(@vue/compiler-sfc@3.5.32)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)): dependencies: debug: 4.4.3(supports-color@10.2.2) @@ -36083,54 +35921,6 @@ snapshots: - vue-tsc - webpack - vue-macros@3.1.2(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@vueuse/core@14.2.1(vue@3.5.32(typescript@5.9.3)))(esbuild@0.27.2)(rolldown@1.0.0-rc.16)(rollup@4.60.1)(typescript@5.9.3)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-tsc@3.2.6(typescript@5.9.3))(vue@3.5.32(typescript@5.9.3)): - dependencies: - '@vue-macros/better-define': 3.1.2(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(vue@3.5.32(typescript@5.9.3)) - '@vue-macros/boolean-prop': 3.1.2(vue@3.5.32(typescript@5.9.3)) - '@vue-macros/chain-call': 3.1.2(vue@3.5.32(typescript@5.9.3)) - '@vue-macros/common': 3.1.2(vue@3.5.32(typescript@5.9.3)) - '@vue-macros/config': 3.1.2(vue@3.5.32(typescript@5.9.3)) - '@vue-macros/define-emit': 3.1.2(vue@3.5.32(typescript@5.9.3)) - '@vue-macros/define-models': 3.1.2(@vueuse/core@14.2.1(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)) - '@vue-macros/define-prop': 3.1.2(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(vue@3.5.32(typescript@5.9.3)) - '@vue-macros/define-props': 3.1.2(@vue-macros/reactivity-transform@3.1.2(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)) - '@vue-macros/define-props-refs': 3.1.2(vue@3.5.32(typescript@5.9.3)) - '@vue-macros/define-render': 3.1.2(vue@3.5.32(typescript@5.9.3)) - '@vue-macros/define-slots': 3.1.2(vue@3.5.32(typescript@5.9.3)) - '@vue-macros/define-stylex': 3.1.2(vue@3.5.32(typescript@5.9.3)) - '@vue-macros/devtools': 3.1.2(typescript@5.9.3)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) - '@vue-macros/export-expose': 3.1.2(vue@3.5.32(typescript@5.9.3)) - '@vue-macros/export-props': 3.1.2(vue@3.5.32(typescript@5.9.3)) - '@vue-macros/export-render': 3.1.2(vue@3.5.32(typescript@5.9.3)) - '@vue-macros/hoist-static': 3.1.2(vue@3.5.32(typescript@5.9.3)) - '@vue-macros/jsx-directive': 3.1.2(typescript@5.9.3) - '@vue-macros/named-template': 3.1.2(vue@3.5.32(typescript@5.9.3)) - '@vue-macros/reactivity-transform': 3.1.2(vue@3.5.32(typescript@5.9.3)) - '@vue-macros/script-lang': 3.1.2(vue@3.5.32(typescript@5.9.3)) - '@vue-macros/setup-block': 3.1.2(vue@3.5.32(typescript@5.9.3)) - '@vue-macros/setup-component': 3.1.2(vue@3.5.32(typescript@5.9.3)) - '@vue-macros/setup-sfc': 3.1.2(vue@3.5.32(typescript@5.9.3)) - '@vue-macros/short-bind': 3.1.2(vue@3.5.32(typescript@5.9.3)) - '@vue-macros/short-emits': 3.1.2(vue@3.5.32(typescript@5.9.3)) - '@vue-macros/short-vmodel': 3.1.2(vue@3.5.32(typescript@5.9.3)) - '@vue-macros/volar': 3.1.2(typescript@5.9.3)(vue-tsc@3.2.6(typescript@5.9.3))(vue@3.5.32(typescript@5.9.3)) - unplugin: 2.3.11 - unplugin-combine: 2.3.0(esbuild@0.27.2)(rolldown@1.0.0-rc.16)(rollup@4.60.1)(unplugin@2.3.11)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) - unplugin-vue-define-options: 3.1.2(vue@3.5.32(typescript@5.9.3)) - vue: 3.5.32(typescript@5.9.3) - transitivePeerDependencies: - - '@emnapi/core' - - '@emnapi/runtime' - - '@rspack/core' - - '@vueuse/core' - - esbuild - - rolldown - - rollup - - typescript - - vite - - vue-tsc - - webpack - vue-macros@3.1.2(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@vueuse/core@14.2.1(vue@3.5.32(typescript@5.9.3)))(esbuild@0.27.2)(rolldown@1.0.0-rc.16)(rollup@4.60.1)(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-tsc@3.2.6(typescript@5.9.3))(vue@3.5.32(typescript@5.9.3)): dependencies: '@vue-macros/better-define': 3.1.2(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(vue@3.5.32(typescript@5.9.3)) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a66242b68..ffa3d6597 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -328,7 +328,7 @@ catalog: pathe: ^2.0.3 pg: ^8.20.0 pinia: ^3.0.4 - pinia-plugin-synced: ^0.1.0 + pinia-plugin-synced: ^0.1.3 pixi-filters: ^4.2.0 pixi-live2d-display: ^0.4.0 playwright: ^1.60.0