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