From 352a9e389b047c9bef5e31469cf34ad66349c320 Mon Sep 17 00:00:00 2001 From: Lovehsigure_520 <62863834+Neko-233@users.noreply.github.com> Date: Thu, 10 Sep 2026 00:08:35 +0800 Subject: [PATCH] fix(stage-ui): restore TTS after first login (#2490) --- .../src/providers/cloud/elevenlabs/index.ts | 1 + .../cloud/google-gemini-audio-speech/index.ts | 1 + .../src/providers/cloud/mimo-audio/index.ts | 1 + .../providers/cloud/minimax-speech/index.ts | 1 + .../src/providers/cloud/openai-audio/index.ts | 2 + .../cloud/openrouter-audio-speech/index.ts | 1 + .../src/providers/cloud/unspeech/index.ts | 4 + .../providers/local/index-tts-vllm/index.ts | 1 + .../providers/local/player2-speech/index.ts | 1 + .../src/providers/local/speech-noop/index.ts | 1 + .../src/providers/local/voicevox/define.ts | 1 + packages/provider-inference/src/types.ts | 13 +- .../components/CardCreationDialog.vue | 41 +- .../src/pages/settings/modules/speech.vue | 144 ++-- .../speech/google-gemini-audio-speech.vue | 2 +- .../providers/speech/mimo-audio-speech.vue | 2 +- .../src/composables/live2d/animation.ts | 2 +- packages/stage-ui/package.json | 1 + .../providers/speech-provider-settings.vue | 2 +- .../use-data-maintenance.browser.test.ts | 102 +++ .../src/composables/use-data-maintenance.ts | 29 +- .../stage-ui/src/libs/pinia/setup-synced.ts | 21 +- .../stage-ui/src/libs/pinia/synced-context.ts | 15 + .../providers/providers/kokoro-local/index.ts | 1 + .../providers/providers/official/index.ts | 154 ++-- .../modules/airi-card-inheritance.test.ts | 128 ++++ .../src/stores/modules/airi-card.test.ts | 19 +- .../stage-ui/src/stores/modules/airi-card.ts | 61 +- .../speech-card-preview.browser.test.ts | 97 +++ .../modules/speech-settings.browser.test.ts | 173 +++++ .../src/stores/modules/speech.browser.test.ts | 668 ++++++++++++++++++ .../src/stores/modules/speech.test.ts | 332 ++++++++- .../stage-ui/src/stores/modules/speech.ts | 337 +++++++-- .../src/stores/providers/provider.test.ts | 203 +++++- .../stage-ui/src/stores/providers/provider.ts | 140 +++- packages/stage-ui/vitest.config.ts | 8 + 36 files changed, 2385 insertions(+), 325 deletions(-) create mode 100644 packages/stage-ui/src/composables/use-data-maintenance.browser.test.ts create mode 100644 packages/stage-ui/src/libs/pinia/synced-context.ts create mode 100644 packages/stage-ui/src/stores/modules/speech-card-preview.browser.test.ts create mode 100644 packages/stage-ui/src/stores/modules/speech-settings.browser.test.ts create mode 100644 packages/stage-ui/src/stores/modules/speech.browser.test.ts diff --git a/packages/provider-inference/src/providers/cloud/elevenlabs/index.ts b/packages/provider-inference/src/providers/cloud/elevenlabs/index.ts index 214562e3b..570204143 100644 --- a/packages/provider-inference/src/providers/cloud/elevenlabs/index.ts +++ b/packages/provider-inference/src/providers/cloud/elevenlabs/index.ts @@ -103,6 +103,7 @@ export const providerElevenLabs = defineProvider contextLength: 0, deprecated: false, })), + voiceCatalogConfig: ({ apiKey, baseUrl }) => ({ apiKey, baseUrl }), listVoices: async (config) => { const provider = createUnElevenLabs(config.apiKey.trim(), config.baseUrl?.trim() ?? 'https://unspeech.hyp3r.link/v1/') as VoiceProviderWithExtraOptions const voices = await listVoices(toListVoicesOptions(provider)) diff --git a/packages/provider-inference/src/providers/cloud/google-gemini-audio-speech/index.ts b/packages/provider-inference/src/providers/cloud/google-gemini-audio-speech/index.ts index 1848d6ef6..458a8dd20 100644 --- a/packages/provider-inference/src/providers/cloud/google-gemini-audio-speech/index.ts +++ b/packages/provider-inference/src/providers/cloud/google-gemini-audio-speech/index.ts @@ -160,6 +160,7 @@ export const providerGoogleGeminiAudioSpeech = defineProvider ({}), listVoices: async () => googleGeminiTtsVoices.map(([id, style]) => ({ id, name: id, diff --git a/packages/provider-inference/src/providers/cloud/mimo-audio/index.ts b/packages/provider-inference/src/providers/cloud/mimo-audio/index.ts index 0510029ba..f626f2717 100644 --- a/packages/provider-inference/src/providers/cloud/mimo-audio/index.ts +++ b/packages/provider-inference/src/providers/cloud/mimo-audio/index.ts @@ -227,6 +227,7 @@ export const providerMimoAudioSpeech = defineProvider ({}), listVoices: async () => [ { id: 'mimo_default', name: 'MiMo-默认', provider: 'mimo-audio-speech', gender: 'female', languages: [{ code: 'en', title: 'English' }, { code: 'zh', title: 'Chinese' }] }, { id: '冰糖', name: '冰糖', provider: 'mimo-audio-speech', gender: 'female', languages: [{ code: 'zh', title: 'Chinese' }] }, diff --git a/packages/provider-inference/src/providers/cloud/minimax-speech/index.ts b/packages/provider-inference/src/providers/cloud/minimax-speech/index.ts index 9f827e1db..138888155 100644 --- a/packages/provider-inference/src/providers/cloud/minimax-speech/index.ts +++ b/packages/provider-inference/src/providers/cloud/minimax-speech/index.ts @@ -136,6 +136,7 @@ export const providerMinimaxSpeech = defineProvider ({}), listVoices: async () => [ { id: 'English_Graceful_Lady', name: 'Graceful Lady', provider: 'minimax-speech', gender: 'female', languages: [{ code: 'en', title: 'English' }] }, { id: 'English_Insightful_Speaker', name: 'Insightful Speaker', provider: 'minimax-speech', gender: 'male', languages: [{ code: 'en', title: 'English' }] }, diff --git a/packages/provider-inference/src/providers/cloud/openai-audio/index.ts b/packages/provider-inference/src/providers/cloud/openai-audio/index.ts index cac2d6a24..154f447b5 100644 --- a/packages/provider-inference/src/providers/cloud/openai-audio/index.ts +++ b/packages/provider-inference/src/providers/cloud/openai-audio/index.ts @@ -187,6 +187,7 @@ export const providerOpenAIAudioSpeech = defineProvider(), extraMethods: { listModels: async () => openAISpeechModels, + voiceCatalogConfig: () => ({}), listVoices: async () => openAISpeechVoices, }, }) @@ -204,6 +205,7 @@ export const providerOpenAICompatibleAudioSpeech = defineProvider Boolean(config.apiKey?.trim() && config.baseUrl?.trim()), validators: createAudioValidators(), extraMethods: { + voiceCatalogConfig: () => ({}), listVoices: async () => [], listModels: async (config) => { const apiKey = config.apiKey?.trim() ?? '' diff --git a/packages/provider-inference/src/providers/cloud/openrouter-audio-speech/index.ts b/packages/provider-inference/src/providers/cloud/openrouter-audio-speech/index.ts index 73cc98795..8f5aa3e79 100644 --- a/packages/provider-inference/src/providers/cloud/openrouter-audio-speech/index.ts +++ b/packages/provider-inference/src/providers/cloud/openrouter-audio-speech/index.ts @@ -190,6 +190,7 @@ export const providerOpenRouterAudioSpeech = defineProvider ({}), listVoices: async () => openAIVoices.map(id => ({ id, name: `${id[0].toUpperCase()}${id.slice(1)}`, diff --git a/packages/provider-inference/src/providers/cloud/unspeech/index.ts b/packages/provider-inference/src/providers/cloud/unspeech/index.ts index c0dbe3ad7..a90019e22 100644 --- a/packages/provider-inference/src/providers/cloud/unspeech/index.ts +++ b/packages/provider-inference/src/providers/cloud/unspeech/index.ts @@ -133,6 +133,7 @@ export const providerDeepgramTts = defineProvider ({ apiKey, baseUrl }), listVoices: async (config) => { const provider = createUnDeepgram(config.apiKey.trim(), config.baseUrl?.trim() ?? '') as VoiceProviderWithExtraOptions const voices = await listVoices(toListVoicesOptions(provider)) @@ -162,6 +163,7 @@ export const providerMicrosoftSpeech = defineProvider [{ id: 'v1', name: 'v1', provider: 'microsoft-speech', description: '', deprecated: false }], + voiceCatalogConfig: ({ apiKey, baseUrl, region }) => ({ apiKey, baseUrl, region }), listVoices: async (config) => { const provider = createUnMicrosoft(config.apiKey.trim(), config.baseUrl?.trim() ?? '') as VoiceProviderWithExtraOptions const voices = await listVoices(toListVoicesOptions(provider, { region: config.region ?? '' })) @@ -194,6 +196,7 @@ export const providerAlibabaCloudModelStudio = defineProvider ({ apiKey, baseUrl }), listVoices: async (config) => { const provider = createUnAlibabaCloud(config.apiKey.trim(), config.baseUrl?.trim() ?? '') as VoiceProviderWithExtraOptions const voices = await listVoices(toListVoicesOptions(provider)) @@ -224,6 +227,7 @@ export const providerVolcengineSpeech = defineProvider('volcengine', true), extraMethods: { listModels: async () => [{ id: 'v1', name: 'v1', provider: 'volcano-engine', description: '', deprecated: false }], + voiceCatalogConfig: ({ apiKey, baseUrl }) => ({ apiKey, baseUrl }), listVoices: async (config) => { const provider = createUnVolcengine(config.apiKey.trim(), config.baseUrl?.trim() ?? '') as VoiceProviderWithExtraOptions const voices = await listVoices(toListVoicesOptions(provider)) diff --git a/packages/provider-inference/src/providers/local/index-tts-vllm/index.ts b/packages/provider-inference/src/providers/local/index-tts-vllm/index.ts index eca2ac447..0ecfd7c19 100644 --- a/packages/provider-inference/src/providers/local/index-tts-vllm/index.ts +++ b/packages/provider-inference/src/providers/local/index-tts-vllm/index.ts @@ -75,6 +75,7 @@ export const providerIndexTtsVllm = defineProvider ({ baseUrl }), listVoices: async (config) => { const response = await fetch(voicesUrl(config)) if (!response.ok) diff --git a/packages/provider-inference/src/providers/local/player2-speech/index.ts b/packages/provider-inference/src/providers/local/player2-speech/index.ts index 322e6849a..ba5654eda 100644 --- a/packages/provider-inference/src/providers/local/player2-speech/index.ts +++ b/packages/provider-inference/src/providers/local/player2-speech/index.ts @@ -90,6 +90,7 @@ export const providerPlayer2Speech = defineProvider ({ baseUrl }), listVoices: async (config) => { const response = await fetch(new URL('tts/voices', normalizeBaseUrl(config.baseUrl))) const data = await response.json() as { diff --git a/packages/provider-inference/src/providers/local/speech-noop/index.ts b/packages/provider-inference/src/providers/local/speech-noop/index.ts index edf6e251c..8f87b05eb 100644 --- a/packages/provider-inference/src/providers/local/speech-noop/index.ts +++ b/packages/provider-inference/src/providers/local/speech-noop/index.ts @@ -27,6 +27,7 @@ export const providerSpeechNoop = defineProvider({ validationRequiredWhen: () => false, extraMethods: { listModels: async () => [], + voiceCatalogConfig: () => ({}), listVoices: async () => [], }, }) diff --git a/packages/provider-inference/src/providers/local/voicevox/define.ts b/packages/provider-inference/src/providers/local/voicevox/define.ts index 31a339484..f7cc31482 100644 --- a/packages/provider-inference/src/providers/local/voicevox/define.ts +++ b/packages/provider-inference/src/providers/local/voicevox/define.ts @@ -108,6 +108,7 @@ export function defineVoicevoxFamilyProvider( provider: options.id, }], + voiceCatalogConfig: ({ baseUrl }) => ({ baseUrl }), listVoices: async (config) => { const speakers = await fetchSpeakers(config.baseUrl?.trim() ?? '') return speakers.flatMap(speaker => (speaker.styles ?? []).map(style => toVoiceInfo(options.id, speaker.name, style))) diff --git a/packages/provider-inference/src/types.ts b/packages/provider-inference/src/types.ts index d0ef292fb..b2d64bc38 100644 --- a/packages/provider-inference/src/types.ts +++ b/packages/provider-inference/src/types.ts @@ -94,12 +94,21 @@ export interface ProviderModelCatalog { export interface ProviderExtraMethods { listModelCatalog?: (config: TConfig, provider: ProviderInstance, contextOptions?: ProviderContext) => Promise listModels?: (config: TConfig, provider: ProviderInstance, contextOptions?: ProviderContext) => Promise + /** + * Selects serializable configuration fields used by voice discovery. The cache + * fingerprints these fields separately from model and authentication ownership. + * Omit synthesis-only controls. Without a selector, all config fields invalidate the cache. + */ + voiceCatalogConfig?: (config: TConfig) => Record /** * Returns the voice catalogue. `model` lets providers whose voices vary by * model variant (Volcengine streaming TTS 1.0 vs 2.0 differ in catalogue) * narrow the result. Providers with a single catalogue ignore it. + * The request owner aborts the signal when its session ends. Adapters must + * discard aborted response side effects, including recommendation caches. */ - listVoices?: (config: TConfig, provider: ProviderInstance, model?: string) => Promise + + listVoices?: (config: TConfig, provider: ProviderInstance, model?: string, signal?: AbortSignal) => Promise loadModel?: (config: TConfig, provider: ProviderInstance, hooks?: { onProgress?: (progress: ProgressInfo) => Promise | void }) => Promise } @@ -166,6 +175,8 @@ export interface VoiceInfo { id: string name: string provider: string + /** Locales for which the server recommends this voice in this catalog response. */ + recommendedFor?: string[] compatibleModels?: string[] description?: string gender?: string diff --git a/packages/stage-pages/src/pages/settings/airi-card/components/CardCreationDialog.vue b/packages/stage-pages/src/pages/settings/airi-card/components/CardCreationDialog.vue index 308d6314f..33933ed4c 100644 --- a/packages/stage-pages/src/pages/settings/airi-card/components/CardCreationDialog.vue +++ b/packages/stage-pages/src/pages/settings/airi-card/components/CardCreationDialog.vue @@ -1,8 +1,10 @@ diff --git a/packages/stage-ui-live2d/src/composables/live2d/animation.ts b/packages/stage-ui-live2d/src/composables/live2d/animation.ts index 8adb00bc3..533edc3f7 100644 --- a/packages/stage-ui-live2d/src/composables/live2d/animation.ts +++ b/packages/stage-ui-live2d/src/composables/live2d/animation.ts @@ -2,7 +2,7 @@ import type { InternalModel } from 'pixi-live2d-display/cubism4' import { MathUtils } from 'three' -import { randomSaccadeInterval } from '../../utils' +import { randomSaccadeInterval } from '../../utils/eye-motions' /** * This is to simulate idle eye saccades and focus (head) movements in a *pretty* naive way. diff --git a/packages/stage-ui/package.json b/packages/stage-ui/package.json index cd36dc54a..2e4360454 100644 --- a/packages/stage-ui/package.json +++ b/packages/stage-ui/package.json @@ -36,6 +36,7 @@ "./libs/pinia": "./src/libs/pinia/index.ts", "./libs/providers/stream-transcription": "./src/libs/providers/stream-transcription/index.ts", "./libs/providers/providers/aliyun-nls": "./src/libs/providers/providers/aliyun-nls/index.ts", + "./libs/providers/providers/official": "./src/libs/providers/providers/official/index.ts", "./libs/*": "./src/libs/*.ts", "./libs": "./src/libs/index.ts", "./models/*": "./src/models/*.ts", diff --git a/packages/stage-ui/src/components/scenarios/providers/speech-provider-settings.vue b/packages/stage-ui/src/components/scenarios/providers/speech-provider-settings.vue index c269face1..612e2cbb9 100644 --- a/packages/stage-ui/src/components/scenarios/providers/speech-provider-settings.vue +++ b/packages/stage-ui/src/components/scenarios/providers/speech-provider-settings.vue @@ -122,7 +122,7 @@ onMounted(async () => { // Load voices if provider is configured if (providerStore.configuredProviders[props.providerId]) { - speechStore.loadVoicesForProvider(props.providerId) + await speechStore.loadVoicesForProvider(props.providerId) } }) diff --git a/packages/stage-ui/src/composables/use-data-maintenance.browser.test.ts b/packages/stage-ui/src/composables/use-data-maintenance.browser.test.ts new file mode 100644 index 000000000..c91286692 --- /dev/null +++ b/packages/stage-ui/src/composables/use-data-maintenance.browser.test.ts @@ -0,0 +1,102 @@ +import type { LeadershipMode } from 'pinia-plugin-synced' + +import en from '@proj-airi/i18n/locales/en' + +import { PiniaColada } from '@pinia/colada' +import { createPinia, disposePinia } from 'pinia' +import { createSyncedPiniaPlugin } from 'pinia-plugin-synced' +import { afterEach, expect, it, vi } from 'vitest' +import { createApp } from 'vue' +import { createI18n } from 'vue-i18n' + +import { injectKeyPiniaSynced } from '../libs/pinia/synced-context' +import { useConsciousnessStore } from '../stores/modules/consciousness' +import { useConsciousnessSettingsStore } from '../stores/modules/consciousness-settings' +import { useDiscordStore } from '../stores/modules/discord' +import { useFactorioStore } from '../stores/modules/gaming-factorio' +import { useMinecraftStore } from '../stores/modules/gaming-minecraft' +import { useHearingStore } from '../stores/modules/hearing' +import { useSpeechStore } from '../stores/modules/speech' +import { useTwitterStore } from '../stores/modules/twitter' +import { useWebSearchStore } from '../stores/modules/web-search' +import { useDataMaintenance } from './use-data-maintenance' + +const cleanups: Array<() => void> = [] + +/** Mounts the maintenance composable with real stores and a separate synchronization runtime. */ +function mountMaintenance(namespace: string, leadership: LeadershipMode) { + const pinia = createPinia() + const runtime = createSyncedPiniaPlugin({ namespace, leadership }) + pinia.use(runtime.plugin) + let maintenance: ReturnType | undefined + const app = createApp({ + setup() { + maintenance = useDataMaintenance() + return () => null + }, + }) + app.provide(injectKeyPiniaSynced, runtime) + .use(pinia) + .use(PiniaColada) + .use(createI18n({ legacy: false, locale: 'en', messages: { en } })) + .mount(document.createElement('div')) + cleanups.push(() => { + app.unmount() + disposePinia(pinia) + runtime.dispose() + }) + if (!maintenance) + throw new Error('Maintenance composable did not initialize') + return { pinia, runtime, maintenance } +} + +afterEach(() => { + for (const cleanup of cleanups.splice(0)) + cleanup() + vi.restoreAllMocks() + vi.unstubAllGlobals() + localStorage.clear() +}) + +// https://github.com/moeru-ai/airi/pull/2490#discussion_r3968502055 +// ROOT CAUSE: Sequential awaits stopped independent module cleanup after one +// leader RPC failed. Every reset must settle before the caller receives the error. +it.each(['resetSettings', 'resetState'])('continues independent module resets when the %s RPC fails', async (action) => { + localStorage.clear() + vi.stubGlobal('fetch', vi.fn(async () => Response.json({ voices: [], models: [] }))) + const namespace = `maintenance:${crypto.randomUUID()}` + const leader = mountMaintenance(namespace, 'leader-only') + await vi.waitFor(() => expect(leader.runtime.isLeader()).toBe(true)) + const follower = mountMaintenance(namespace, 'follower-only') + await vi.waitFor(() => expect(follower.runtime.getLeaderId()).toBe(leader.runtime.participantId)) + const { pinia } = follower + await useConsciousnessSettingsStore(pinia).setReasoning(true) + useMinecraftStore(pinia).latestRuntimeContextText = 'previous context' + const modules = [ + useHearingStore(pinia), + useSpeechStore(pinia), + useConsciousnessStore(pinia), + useTwitterStore(pinia), + useWebSearchStore(pinia), + useDiscordStore(pinia), + useFactorioStore(pinia), + useMinecraftStore(pinia), + ] + const resetModules: string[] = [] + for (const module of modules) { + module.$onAction(({ name }) => { + if (name === 'resetState') + resetModules.push(module.$id) + }) + } + const postMessage = BroadcastChannel.prototype.postMessage + vi.spyOn(BroadcastChannel.prototype, 'postMessage').mockImplementation(function (this: BroadcastChannel, message) { + if (JSON.stringify(message).includes(`"${action}"`)) + throw new Error('Reset transport unavailable') + postMessage.call(this, message) + }) + await expect(follower.maintenance.resetModulesSettings()).rejects.toThrow('Reset transport unavailable') + expect(resetModules).toEqual(modules.map(module => module.$id)) + expect(useMinecraftStore(pinia).latestRuntimeContextText).toBe('') + expect(useConsciousnessSettingsStore(leader.pinia).reasoning).toBe(action === 'resetState') +}) diff --git a/packages/stage-ui/src/composables/use-data-maintenance.ts b/packages/stage-ui/src/composables/use-data-maintenance.ts index dbc861ca6..424470cd7 100644 --- a/packages/stage-ui/src/composables/use-data-maintenance.ts +++ b/packages/stage-ui/src/composables/use-data-maintenance.ts @@ -1,7 +1,8 @@ import type { ChatSessionsExport } from '../types/chat-session' import { isStageTamagotchi } from '@proj-airi/stage-shared' -import { useLive2dParams, useSettingsLive2d } from '@proj-airi/stage-ui-live2d' +import { useSettingsLive2d } from '@proj-airi/stage-ui-live2d/composables/live2d' +import { useLive2dParams } from '@proj-airi/stage-ui-live2d/stores/model-parameters' import { useModelStore } from '@proj-airi/stage-ui-three' import { useLive2DMotionMagicSettings } from '../features/motions/live2d' @@ -57,16 +58,24 @@ export function useDataMaintenance() { await providersStore.resetProviderSettings() } + /** Attempts every independent reset and reports the first failure only after all operations settle. */ async function resetModulesSettings() { - hearingStore.resetState() - speechStore.resetState() - consciousnessStore.resetState() - await consciousnessSettingsStore.resetState() - twitterStore.resetState() - webSearchStore.resetState() - discordStore.resetState() - factorioStore.resetState() - minecraftStore.resetState() + // Schedule each reset separately so both synchronous errors and rejected + // leader RPCs leave the other modules free to finish their cleanup. + const results = await Promise.allSettled([ + () => hearingStore.resetState(), + () => speechStore.resetState(), + () => consciousnessStore.resetState(), + () => consciousnessSettingsStore.resetState(), + () => twitterStore.resetState(), + () => webSearchStore.resetState(), + () => discordStore.resetState(), + () => factorioStore.resetState(), + () => minecraftStore.resetState(), + ].map(reset => Promise.resolve().then(reset))) + const failure = results.find(result => result.status === 'rejected') + if (failure) + throw failure.reason } function deleteAllChatSessions() { diff --git a/packages/stage-ui/src/libs/pinia/setup-synced.ts b/packages/stage-ui/src/libs/pinia/setup-synced.ts index dc0d58d24..2cce49d7d 100644 --- a/packages/stage-ui/src/libs/pinia/setup-synced.ts +++ b/packages/stage-ui/src/libs/pinia/setup-synced.ts @@ -1,15 +1,15 @@ import type { PiniaPlugin } from 'pinia' -import type { SyncedOptions, SyncedPiniaRuntime } from 'pinia-plugin-synced' -import type { InjectionKey, Plugin } from 'vue' +import type { SyncedOptions } from 'pinia-plugin-synced' +import type { Plugin } from 'vue' import { createSyncedPiniaPlugin } from 'pinia-plugin-synced' -import { inject } from 'vue' + +import { injectKeyPiniaSynced } from './synced-context' + +export { injectKeyPiniaSynced, usePiniaSynced } from './synced-context' export type { LeadershipMode } from 'pinia-plugin-synced' -/** Provides the synchronization runtime installed by {@link setupSynced}. */ -export const injectKeyPiniaSynced: InjectionKey = Symbol('stage-synced-pinia-runtime') - /** * Creates the Vue and Pinia plugins for one Stage synchronization runtime. * @@ -55,12 +55,3 @@ export function setupSynced(options: Pick = {}): { vue, } } - -/** Returns the synchronization runtime provided by {@link setupSynced}. */ -export function usePiniaSynced(): SyncedPiniaRuntime { - const runtime = inject(injectKeyPiniaSynced) - if (!runtime) - throw new Error('Pinia synchronization is not installed. Call app.use(synced.vue) first.') - - return runtime -} diff --git a/packages/stage-ui/src/libs/pinia/synced-context.ts b/packages/stage-ui/src/libs/pinia/synced-context.ts new file mode 100644 index 000000000..78ba43860 --- /dev/null +++ b/packages/stage-ui/src/libs/pinia/synced-context.ts @@ -0,0 +1,15 @@ +import type { SyncedPiniaRuntime } from 'pinia-plugin-synced' +import type { InjectionKey } from 'vue' + +import { inject } from 'vue' + +/** Shares the installed runtime without importing its transport and election implementation. */ +export const injectKeyPiniaSynced: InjectionKey = Symbol('stage-synced-pinia-runtime') + +/** Returns the synchronization runtime installed by the application's setupSynced plugin. */ +export function usePiniaSynced(): SyncedPiniaRuntime { + const runtime = inject(injectKeyPiniaSynced) + if (!runtime) + throw new Error('Pinia synchronization is not installed. Call app.use(synced.vue) first.') + return runtime +} diff --git a/packages/stage-ui/src/libs/providers/providers/kokoro-local/index.ts b/packages/stage-ui/src/libs/providers/providers/kokoro-local/index.ts index 951e85e4f..1da8cf36c 100644 --- a/packages/stage-ui/src/libs/providers/providers/kokoro-local/index.ts +++ b/packages/stage-ui/src/libs/providers/providers/kokoro-local/index.ts @@ -153,6 +153,7 @@ export const providerKokoroLocal = defineProvider({ throw error } }, + voiceCatalogConfig: ({ model }) => ({ model }), listVoices: async (config) => { try { const adapter = await getKokoroAdapter() diff --git a/packages/stage-ui/src/libs/providers/providers/official/index.ts b/packages/stage-ui/src/libs/providers/providers/official/index.ts index 4e55173b6..4b0490530 100644 --- a/packages/stage-ui/src/libs/providers/providers/official/index.ts +++ b/packages/stage-ui/src/libs/providers/providers/official/index.ts @@ -1,8 +1,5 @@ -import type { Ref, WatchSource } from 'vue' - import type { ModelInfo, ProviderModelCatalog, VoiceInfo } from '../../types' -import { watch } from 'vue' import { z } from 'zod' import { getAuthToken } from '../../../../libs/auth' @@ -13,23 +10,6 @@ import { createOfficialAudioProvider, createOfficialOpenAIProvider, OFFICIAL_ICO export { OFFICIAL_CHAT_PROVIDER_ID, OFFICIAL_SPEECH_PROVIDER_ID, OFFICIAL_SPEECH_STREAMING_PROVIDER_ID, OFFICIAL_TRANSCRIPTION_PROVIDER_ID, OFFICIAL_VISION_PROVIDER_ID } from './constants' -// Locale → voice id map recommended by the server, keyed by provider id. -// Populated by each speech provider's listVoices() from the response's -// `recommended` field so the auto-pick can prefer a curated default per -// locale. Keyed per provider because the HTTP and streaming providers have -// independent catalogs and recommendation buckets. Falls back to language + -// first-voice matching when the server returns no recommendations. -const recommendedVoicesByProvider: Record> = {} - -// Server-curated default HTTP speech model id, populated by the HTTP speech -// provider's listModels(). The speech store uses this when it needs to seed an -// empty/stale model selection, so the UI mirrors `/audio/speech` `model: auto`. -let defaultSpeechModelId: string | null = null - -export function getDefaultSpeechModel(): string | null { - return defaultSpeechModelId -} - const officialConfigSchema = z.object({}) function authHeaders(): Record { @@ -132,8 +112,7 @@ export const providerOfficialSpeech = defineProvider({ }, validationRequiredWhen: () => false, extraMethods: { - listModels: async (): Promise => { - defaultSpeechModelId = null + listModelCatalog: async (): Promise => { const res = await globalThis.fetch(`${SERVER_URL}/api/v1/audio/models`, { headers: authHeaders() }) if (!res.ok) throw new Error(`audio models upstream ${res.status}: ${await res.text().catch(() => '')}`.slice(0, 256)) @@ -142,16 +121,19 @@ export const providerOfficialSpeech = defineProvider({ if (!Array.isArray(data.models)) throw new Error('audio models upstream returned malformed body') - defaultSpeechModelId = typeof data.default === 'string' && data.default.length > 0 ? data.default : null - - return data.models.map(m => ({ - id: m.id, - name: m.name, - description: m.description, - provider: OFFICIAL_SPEECH_PROVIDER_ID, - })) + // Replicate the server default with its models so a new leader can select it. + return { + defaultModel: typeof data.default === 'string' && data.default.length > 0 ? data.default : null, + models: data.models.map(m => ({ + id: m.id, + name: m.name, + description: m.description, + provider: OFFICIAL_SPEECH_PROVIDER_ID, + })), + } }, - listVoices: async (_config, _provider, model): Promise => { + voiceCatalogConfig: () => ({}), + listVoices: async (_config, _provider, model, signal): Promise => { // Voice catalogs are model-scoped on the server side. Pass the active // model through so Azure / cosyvoice / future provider voices route to // the right adapter. If model discovery has not completed yet, keep the @@ -159,7 +141,7 @@ export const providerOfficialSpeech = defineProvider({ const target = model && model.length > 0 ? model : 'auto' const url = new URL(`${SERVER_URL}/api/v1/audio/voices`) url.searchParams.set('model', target) - const res = await globalThis.fetch(url.toString(), { headers: authHeaders() }) + const res = await globalThis.fetch(url.toString(), { headers: authHeaders(), signal }) if (!res.ok) throw new Error(`audio voices upstream ${res.status}: ${await res.text().catch(() => '')}`.slice(0, 256)) @@ -180,11 +162,8 @@ export const providerOfficialSpeech = defineProvider({ recommended?: Record } - // Refresh the server-side recommendation map. Done here rather than - // threading it through the return value because the auto-pick watcher - // lives in this module and reads the same singleton. - recommendedVoicesByProvider[OFFICIAL_SPEECH_PROVIDER_ID] = (data.recommended && typeof data.recommended === 'object') ? data.recommended : {} - + // An aborted response must not replace the current session's recommendations. + signal?.throwIfAborted() if (!Array.isArray(data.voices)) throw new Error('audio voices upstream returned malformed body') @@ -192,6 +171,9 @@ export const providerOfficialSpeech = defineProvider({ // unspeech surfaces gender inside labels rather than as a top-level field. const rawGender = typeof v.labels?.gender === 'string' ? (v.labels.gender as string) : undefined return { + // Keep recommendations in the response so stale catalogs cannot mutate + // a separate cache, and synchronized windows retain the same metadata. + recommendedFor: Object.entries(data.recommended ?? {}).filter(([, id]) => id === v.id).map(([locale]) => locale), id: v.id, name: v.name, provider: OFFICIAL_SPEECH_PROVIDER_ID, @@ -262,7 +244,8 @@ export const providerOfficialSpeechStreaming = defineProvider({ extraMethods: { listModelCatalog: listStreamingModelCatalog, listModels: async () => (await listStreamingModelCatalog()).models, - listVoices: async (_config, _provider, model): Promise => { + voiceCatalogConfig: () => ({}), + listVoices: async (_config, _provider, model, signal): Promise => { // Streaming voices live behind a dedicated endpoint // (`/audio/voices/streaming`) because they come from the // `UNSPEECH_UPSTREAM.streaming` configKV subtree rather than the HTTP TTS @@ -279,7 +262,7 @@ export const providerOfficialSpeechStreaming = defineProvider({ voicesURL.searchParams.set('model', apiResourceId) const res = await globalThis.fetch( voicesURL.toString(), - { headers: authHeaders() }, + { headers: authHeaders(), signal }, ) if (!res.ok) throw new Error(`streaming voices upstream ${res.status}: ${await res.text().catch(() => '')}`.slice(0, 256)) @@ -296,17 +279,17 @@ export const providerOfficialSpeechStreaming = defineProvider({ recommended?: Record } - // Mirror the HTTP provider: stash the server's per-locale recommendations - // so setupOfficialSpeechAutoPick can seed a curated default voice when - // the streaming provider becomes active. - recommendedVoicesByProvider[OFFICIAL_SPEECH_STREAMING_PROVIDER_ID] = (data.recommended && typeof data.recommended === 'object') ? data.recommended : {} - + // An aborted response must not replace the current session's recommendations. + signal?.throwIfAborted() if (!Array.isArray(data.voices)) throw new Error('streaming voices upstream returned malformed body') return data.voices.map((v) => { const rawGender = typeof v.labels?.gender === 'string' ? (v.labels.gender as string) : undefined return { + // Keep recommendations in the response so stale catalogs cannot mutate + // a separate cache, and synchronized windows retain the same metadata. + recommendedFor: Object.entries(data.recommended ?? {}).filter(([, id]) => id === v.id).map(([locale]) => locale), id: v.id, name: v.name, provider: OFFICIAL_SPEECH_STREAMING_PROVIDER_ID, @@ -416,51 +399,50 @@ const AUTO_PICK_PROVIDER_IDS = new Set([OFFICIAL_SPEECH_PROVIDER_ID, OFFICIAL_SP // the user. The target locale is derived from the UI locale on each run — we // don't persist it, since that was the root of the cross-provider filter // drift bug. -export function setupOfficialSpeechAutoPick(ctx: { - activeSpeechProvider: Ref - activeSpeechVoiceId: Ref - availableVoices: Ref> - uiLocale: WatchSource | Ref +/** Selects from the catalog and recommendations loaded in this renderer; valid selections stay unchanged. */ +export function pickOfficialSpeechVoice(ctx: { + activeSpeechProvider: string + activeSpeechVoiceId: string + availableVoices: Record + uiLocale: string }) { - watch([ctx.availableVoices, ctx.activeSpeechProvider], ([voices, provider]) => { - if (!AUTO_PICK_PROVIDER_IDS.has(provider)) - return + const voices = ctx.availableVoices + const provider = ctx.activeSpeechProvider + if (!AUTO_PICK_PROVIDER_IDS.has(provider)) + return - const providerVoices = voices[provider] - if (!providerVoices?.length) - return - if (ctx.activeSpeechVoiceId.value && providerVoices.some(v => v.id === ctx.activeSpeechVoiceId.value)) - return + const providerVoices = voices[provider] + if (!providerVoices?.length) + return + if (ctx.activeSpeechVoiceId && providerVoices.some(v => v.id === ctx.activeSpeechVoiceId)) + return - const localeCodes = Array.from(new Set( - providerVoices.flatMap(v => (v.languages || []).map(l => l.code).filter(Boolean)), - )).sort() + const localeCodes = Array.from(new Set( + providerVoices.flatMap(v => (v.languages || []).map(l => l.code).filter(Boolean)), + )).sort() - const uiLocaleValue = typeof ctx.uiLocale === 'function' - ? (ctx.uiLocale as () => string)() - : (ctx.uiLocale as Ref).value - const targetLocale = pickLocaleForUi(uiLocaleValue, localeCodes) + const targetLocale = pickLocaleForUi(ctx.uiLocale, localeCodes) - // Pick a default voice with a layered fallback so auto-pick never dumps - // the user into an unrelated voice (e.g. the alphabetically-first af-ZA - // voice when nothing matches): - // 1) server-recommended voice for the exact locale, then the same - // language prefix - // 2) any other server-recommended voice for the same model - // 3) first voice speaking the exact target locale - // 4) any English voice (en-US, then en-*) — broadest comprehensible - // fallback when the user's locale has no coverage at all - // 5) alphabetical first voice, as a last resort - const recommendedMap = recommendedVoicesByProvider[provider] ?? {} - const recommendedId = lookupRecommendedVoiceId(targetLocale, recommendedMap) - const speaksLocale = (v: VoiceInfo, code: string) => (v.languages || []).some(l => l.code === code) - const match = (recommendedId && providerVoices.find(v => v.id === recommendedId)) - || findRecommendedVoice(providerVoices, recommendedMap) - || providerVoices.find(v => speaksLocale(v, targetLocale)) - || providerVoices.find(v => speaksLocale(v, 'en-US')) - || providerVoices.find(v => (v.languages || []).some(l => l.code.toLowerCase().startsWith('en'))) - || providerVoices[0] - if (match) - ctx.activeSpeechVoiceId.value = match.id - }, { deep: true, immediate: true }) + // Pick a default voice with a layered fallback so auto-pick never dumps + // the user into an unrelated voice (e.g. the alphabetically-first af-ZA + // voice when nothing matches): + // 1) server-recommended voice for the exact locale, then the same + // language prefix + // 2) any other server-recommended voice for the same model + // 3) first voice speaking the exact target locale + // 4) any English voice (en-US, then en-*) — broadest comprehensible + // fallback when the user's locale has no coverage at all + // 5) alphabetical first voice, as a last resort + const recommendedMap = Object.fromEntries(providerVoices.flatMap(voice => + voice.recommendedFor?.map(locale => [locale, voice.id]) ?? [], + )) + const recommendedId = lookupRecommendedVoiceId(targetLocale, recommendedMap) + const speaksLocale = (v: VoiceInfo, code: string) => (v.languages || []).some(l => l.code === code) + const match = (recommendedId && providerVoices.find(v => v.id === recommendedId)) + || findRecommendedVoice(providerVoices, recommendedMap) + || providerVoices.find(v => speaksLocale(v, targetLocale)) + || providerVoices.find(v => speaksLocale(v, 'en-US')) + || providerVoices.find(v => (v.languages || []).some(l => l.code.toLowerCase().startsWith('en'))) + || providerVoices[0] + return match?.id } diff --git a/packages/stage-ui/src/stores/modules/airi-card-inheritance.test.ts b/packages/stage-ui/src/stores/modules/airi-card-inheritance.test.ts index efd6f141c..22c05941a 100644 --- a/packages/stage-ui/src/stores/modules/airi-card-inheritance.test.ts +++ b/packages/stage-ui/src/stores/modules/airi-card-inheritance.test.ts @@ -1,3 +1,5 @@ +import type { Session, User } from 'better-auth' + import type { AiriCard } from '../../types/airiCard' import { PiniaColada } from '@pinia/colada' @@ -6,6 +8,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createApp, nextTick, toRaw } from 'vue' import { DEFAULT_ARTISTRY_WIDGET_SPAWNING_PROMPT } from '../../constants/prompts/character-defaults' +import { OFFICIAL_SPEECH_PROVIDER_ID } from '../../libs/providers/providers/official' +import { useAuthStore } from '../auth' import { useAiriCardStore } from './airi-card' import { useArtistryStore } from './artistry' import { useConsciousnessStore } from './consciousness' @@ -266,6 +270,130 @@ describe('card inheritance with real module stores', () => { expect(useConsciousnessStore().activeModel).toBe('auto') }) + // https://github.com/moeru-ai/airi/pull/2490#discussion_r3959813216 + // ROOT CAUSE: + // + // The speech store observed authentication directly. This leaked official + // provider policy into a generic module and ran in every renderer. + // + // Before: an auth snapshot started voice loading before the leader-owned + // card setup completed. + // + // We fixed this by loading auth-owned voices after card setup applies the + // effective provider, model, and voice in the synchronization leader. + it('loads official voices through authenticated card setup', async () => { + const speechStore = useSpeechStore() + speechStore.activeSpeechProvider = OFFICIAL_SPEECH_PROVIDER_ID + speechStore.activeSpeechModel = 'auto' + await nextTick() + + const voiceRequests: string[] = [] + vi.stubGlobal('fetch', vi.fn(async (input) => { + const url = String(input) + if (url.includes('/api/v1/audio/voices')) { + voiceRequests.push(url) + return Response.json({ + recommended: { 'en-US': 'voice-a' }, + voices: [{ id: 'voice-a', name: 'Voice A', languages: ['en-US'] }], + }) + } + return Response.json({ flux: 0 }) + })) + + const user: User = { + id: 'user-1', + name: 'AIRI User', + email: 'user@example.com', + emailVerified: true, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + } + const session: Session = { + id: 'session-1', + token: 'server-session-token', + userId: user.id, + expiresAt: new Date('2026-12-01T00:00:00.000Z'), + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + } + useAuthStore().$patch({ session, token: 'restored-access-token', user }) + await nextTick() + expect(voiceRequests).toHaveLength(0) + + const cards = useAiriCardStore() + await cards.initialize() + await cards.configureForAuthentication(true) + + await vi.waitFor(() => { + expect(voiceRequests).toHaveLength(1) + expect(speechStore.activeSpeechVoiceId).toBe('voice-a') + }) + }) + + // https://github.com/moeru-ai/airi/pull/2490#discussion_r3960117808 + // ROOT CAUSE: + // Authentication setup awaited the optional voice catalog. A pending response + // blocked login completion, card edits, and queued logout configuration. + // Refresh voices outside the authentication transition's pending operation. + it('completes authentication and logout while voice discovery is pending', async () => { + const speechStore = useSpeechStore() + speechStore.activeSpeechProvider = OFFICIAL_SPEECH_PROVIDER_ID + speechStore.activeSpeechModel = 'auto' + await nextTick() + + let finishVoices!: (response: Response) => void + const pendingVoices = new Promise((resolve) => { + finishVoices = resolve + }) + const voiceRequests: string[] = [] + vi.stubGlobal('fetch', vi.fn(async (input) => { + const url = String(input) + if (url.includes('/api/v1/audio/voices')) { + voiceRequests.push(url) + return pendingVoices + } + return Response.json({ flux: 0 }) + })) + + const user: User = { + id: 'user-1', + name: 'AIRI User', + email: 'user@example.com', + emailVerified: true, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + } + const session: Session = { + id: 'session-1', + token: 'server-session-token', + userId: user.id, + expiresAt: new Date('2026-12-01T00:00:00.000Z'), + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + } + useAuthStore().$patch({ session, token: 'restored-access-token', user }) + await nextTick() + expect(voiceRequests).toHaveLength(0) + + const cards = useAiriCardStore() + await cards.initialize() + let configured = false + const setup = cards.configureForAuthentication(true).then(() => { + configured = true + }) + try { + await vi.waitFor(() => expect(voiceRequests).toHaveLength(1)) + await vi.waitFor(() => expect(configured).toBe(true)) + await cards.addCard(card(), 'import') + await cards.configureForAuthentication(false) + expect(speechStore.activeSpeechProvider).toBe('speech-noop') + } + finally { + finishVoices(Response.json({ recommended: {}, voices: [] })) + await setup + } + }) + it('does not write login defaults into empty uploaded-card fields', async () => { const cards = useAiriCardStore() await cards.initialize() 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 7930ff620..d53c43f22 100644 --- a/packages/stage-ui/src/stores/modules/airi-card.test.ts +++ b/packages/stage-ui/src/stores/modules/airi-card.test.ts @@ -63,20 +63,6 @@ vi.mock('./consciousness', async () => { } }) -vi.mock('./speech', async () => { - const { defineStore } = await import('pinia') - - return { - useSpeechStore: defineStore('speech', { - state: () => ({ - activeSpeechProvider: 'mock-speech-provider', - activeSpeechModel: 'mock-speech-model', - activeSpeechVoiceId: 'mock-speech-voice', - }), - }), - } -}) - vi.mock('./vision', async () => { const { defineStore } = await import('pinia') @@ -104,6 +90,11 @@ describe('airi-card store', () => { beforeEach(() => { setActivePinia(createPinia()) resetArtistryToGlobal.mockClear() + useSpeechStore().$patch({ + activeSpeechProvider: 'mock-speech-provider', + activeSpeechModel: 'mock-speech-model', + activeSpeechVoiceId: 'mock-speech-voice', + }) }) // ROOT CAUSE: diff --git a/packages/stage-ui/src/stores/modules/airi-card.ts b/packages/stage-ui/src/stores/modules/airi-card.ts index 5626870f8..489c8ce8c 100644 --- a/packages/stage-ui/src/stores/modules/airi-card.ts +++ b/packages/stage-ui/src/stores/modules/airi-card.ts @@ -3,6 +3,7 @@ import type { Card, ccv3 } from '@proj-airi/ccc' import type { CardModuleDefaults } from '../../services/airi-card-modules' import type { AiriCard, AiriExtension } from '../../types/airiCard' +import { errorMessageFrom } from '@moeru/std' import { useLocalStorageManualReset } from '@proj-airi/stage-shared/composables' import { StorageSerializers } from '@vueuse/core' import { nanoid } from 'nanoid' @@ -111,7 +112,8 @@ export const useAiriCardStore = defineStore('airi-card', () => { moduleDefaults.value = next } - function writeRuntimeModules(modules: CardModuleDefaults) { + /** Applies card speech through the leader command so catalog invalidation precedes its saved voice. */ + async function writeRuntimeModules(modules: CardModuleDefaults) { const { consciousness, vision, speech, stageModel } = useRuntimeModuleStores() // Provider changes synchronously clear dependent selections. Assign the // resolved model and voice afterwards, including empty values. @@ -119,9 +121,7 @@ export const useAiriCardStore = defineStore('airi-card', () => { consciousness.activeModel = modules.consciousness.model vision.activeProvider = modules.vision.provider vision.activeModel = modules.vision.model - speech.activeSpeechProvider = modules.speech.provider - speech.activeSpeechModel = modules.speech.model - speech.activeSpeechVoiceId = modules.speech.voice_id + await speech.selectProviderModel(modules.speech.provider, modules.speech.model, modules.speech.voice_id) if (modules.displayModelId !== undefined) stageModel.stageModelSelected = modules.displayModelId } @@ -138,6 +138,13 @@ export const useAiriCardStore = defineStore('airi-card', () => { if (previous) await previous.catch(() => {}) await applyAuthenticationDefaults(authenticated) + if (authenticated) { + // Voice discovery is owned by the speech action. It must not hold the + // authentication queue, card edits, or logout cleanup open on network IO. + void loadAuthenticatedSpeechVoices().catch((error) => { + console.error('Failed to refresh authenticated speech voices:', errorMessageFrom(error)) + }) + } })() pendingAuthenticationSetup = operation try { @@ -153,7 +160,7 @@ export const useAiriCardStore = defineStore('airi-card', () => { rememberInheritedSettings() if (!moduleDefaults.value) return - writeRuntimeModules(moduleDefaults.value) + await writeRuntimeModules(moduleDefaults.value) try { if (authenticated) await configureAsDefaultsIfEmpty() @@ -163,10 +170,24 @@ export const useAiriCardStore = defineStore('airi-card', () => { } finally { appliedModules = undefined - applyActiveCardSettings() + await applyActiveCardSettings() } } + /** Loads the effective auth-owned voice catalog after card setup finishes. */ + async function loadAuthenticatedSpeechVoices(): Promise { + const { speech } = useRuntimeModuleStores() + const provider = useProviderConfigStore().providers[speech.activeSpeechProvider] + if (provider?.configuredBy !== 'authentication') + return + + speech.ensureActiveSpeechModel() + await speech.loadVoicesForProvider( + speech.activeSpeechProvider, + speech.activeSpeechModel || undefined, + ) + } + /** * `source` feeds the `card_created` analytics event: `scratch` = built in * the creation dialog, `import` = ccv3 JSON upload, `duplicate` = cloned @@ -194,7 +215,7 @@ export const useAiriCardStore = defineStore('airi-card', () => { // before consumers observe a dangling runtime profile after deletion. if (activeCardId.value === id) { activeCardId.value = 'default' - applyActiveCardSettings() + await applyActiveCardSettings() } captureAnalyticsEvent('character_deleted', { character_id: id }) @@ -215,7 +236,7 @@ export const useAiriCardStore = defineStore('airi-card', () => { const card = newAiriCard(updatedCard) cards.value.set(id, card) if (id === activeCardId.value) - applyActiveCardSettings(card) + await applyActiveCardSettings(card) return true } @@ -252,7 +273,7 @@ export const useAiriCardStore = defineStore('airi-card', () => { await pendingAuthenticationSetup const updated = updateActiveCardModules(() => ({ displayModelId })) if (updated) - applyActiveCardSettings() + await applyActiveCardSettings() return updated } @@ -260,7 +281,7 @@ export const useAiriCardStore = defineStore('airi-card', () => { await pendingAuthenticationSetup const updated = updateActiveCardModules(() => ({ consciousness })) if (updated) - applyActiveCardSettings() + await applyActiveCardSettings() return updated } @@ -268,7 +289,7 @@ export const useAiriCardStore = defineStore('airi-card', () => { await pendingAuthenticationSetup const updated = updateActiveCardModules(() => ({ vision })) if (updated) - applyActiveCardSettings() + await applyActiveCardSettings() return updated } @@ -281,7 +302,7 @@ export const useAiriCardStore = defineStore('airi-card', () => { }, })) if (updated) - applyActiveCardSettings() + await applyActiveCardSettings() return updated } @@ -308,7 +329,7 @@ export const useAiriCardStore = defineStore('airi-card', () => { speech: modules.speech.provider === providerId ? { ...modules.speech, provider: '', model: '', voice_id: '' } : modules.speech, })) appliedModules = undefined - applyActiveCardSettings() + await applyActiveCardSettings() } function resolveAiriExtension(card: Card | ccv3.CharacterCardV3): AiriExtension { @@ -441,8 +462,12 @@ export const useAiriCardStore = defineStore('airi-card', () => { } } + /** Applies the initial card while preserving setup context when no auth work is pending. */ async function initialize() { - await pendingAuthenticationSetup + // Awaiting undefined would leave component setup before the first runtime + // stores bind i18n. An existing auth operation already owns those stores. + if (pendingAuthenticationSetup) + await pendingAuthenticationSetup // This synchronized action executes in the leader. Each window calls it, // but only the first call can apply persisted card settings to the runtime. if (initialized) @@ -475,7 +500,7 @@ export const useAiriCardStore = defineStore('airi-card', () => { if (!cards.value.has(activeCardId.value)) activeCardId.value = 'default' - applyActiveCardSettings() + await applyActiveCardSettings() } /** @@ -488,11 +513,11 @@ export const useAiriCardStore = defineStore('airi-card', () => { return false activeCardId.value = id - applyActiveCardSettings() + await applyActiveCardSettings() return true } - function applyActiveCardSettings(newCard = activeCard.value) { + async function applyActiveCardSettings(newCard = activeCard.value) { rememberInheritedSettings() const artistry = useArtistryStore() @@ -535,7 +560,7 @@ export const useAiriCardStore = defineStore('airi-card', () => { resolved.speech.voice_id = '' } } - writeRuntimeModules(resolved) + await writeRuntimeModules(resolved) appliedModules = modules if (extension.modules?.artistry) { diff --git a/packages/stage-ui/src/stores/modules/speech-card-preview.browser.test.ts b/packages/stage-ui/src/stores/modules/speech-card-preview.browser.test.ts new file mode 100644 index 000000000..ba2e57bef --- /dev/null +++ b/packages/stage-ui/src/stores/modules/speech-card-preview.browser.test.ts @@ -0,0 +1,97 @@ +import en from '@proj-airi/i18n/locales/en' + +import { PiniaColada } from '@pinia/colada' +import { MotionPlugin } from '@vueuse/motion' +import { createPinia, disposePinia } from 'pinia' +import { expect, it, vi } from 'vitest' +import { createApp, h, ref } from 'vue' +import { createI18n } from 'vue-i18n' +import { createMemoryHistory, createRouter } from 'vue-router' + +import CardCreationDialog from '../../../../stage-pages/src/pages/settings/airi-card/components/CardCreationDialog.vue' + +import { useProviderConfigStore } from '../providers/config' +import { useAiriCardStore } from './airi-card' +import { useSpeechStore } from './speech' + +// https://github.com/moeru-ai/airi/pull/2490#discussion_r3967236115 +// ROOT CAUSE: Unsaved dialog selections loaded into the runtime catalog and +// cleared its selected voice. Preview responses must remain local to the dialog. +it.each(['completed', 'closed', 'replaced'])('isolates card preview responses when %s', async (scenario) => { + localStorage.clear() + let voice = 'runtime' + const fetchVoices = vi.fn(async () => Response.json({ voices: [{ id: voice, name: voice, languages: [] }], data: [] })) + vi.stubGlobal('fetch', fetchVoices) + const deferred = Promise.withResolvers() + const pinia = createPinia() + const i18n = createI18n({ legacy: false, locale: 'en', messages: { en } }) + const open = ref(false) + const cardId = ref('') + const container = document.createElement('div') + document.body.append(container) + const app = createApp({ + setup() { + useSpeechStore() + return () => h(CardCreationDialog, { modelValue: open.value, cardId: cardId.value, initialTab: 'modules' }) + }, + }) + app.use(pinia).use(PiniaColada).use(MotionPlugin).use(i18n).use(createRouter({ history: createMemoryHistory(), routes: [] })).mount(container) + try { + const speech = useSpeechStore(pinia) + await useProviderConfigStore(pinia).ensureProvider('microsoft-speech', 'microsoft-speech', { + apiKey: 'key', + baseUrl: 'https://voices.invalid/v1/', + region: 'eastasia', + }) + await speech.selectProviderModel('microsoft-speech', 'runtime-model') + await vi.waitFor(() => expect(speech.availableVoices['microsoft-speech']?.[0]?.id).toBe('runtime')) + speech.activeSpeechVoiceId = 'runtime' + await speech.ensureActiveSpeechVoice() + const cards = useAiriCardStore(pinia) + /** Creates an inactive draft so changing the dialog never activates it. */ + async function draft(model: string) { + return cards.addCard({ + name: 'Preview', + version: '1.0', + description: '', + extensions: { airi: { modules: { speech: { provider: 'microsoft-speech', model, voice_id: '' } } } }, + }, 'scratch') + } + cardId.value = await draft('preview-model') + voice = 'preview' + fetchVoices.mockClear() + if (scenario !== 'completed') + fetchVoices.mockImplementationOnce(() => deferred.promise) + open.value = true + await vi.waitFor(() => expect(fetchVoices).toHaveBeenCalled()) + if (scenario === 'closed') { + open.value = false + } + else if (scenario === 'replaced') { + cardId.value = await draft('newer-model') + await vi.waitFor(() => expect(fetchVoices.mock.calls.length).toBeGreaterThan(1)) + } + deferred.resolve(Response.json({ voices: [{ id: 'obsolete', name: 'Obsolete', languages: [] }] })) + await new Promise(resolve => setTimeout(resolve, 100)) + if (scenario !== 'closed') { + const label = Array.from(document.querySelectorAll('label')).find(element => element.textContent?.trim() === i18n.global.t('settings.pages.card.speech.voice')) + const trigger = label?.parentElement?.querySelector('button') + expect(trigger).toBeTruthy() + trigger!.click() + await vi.waitFor(() => expect(Array.from(document.querySelectorAll('[role="option"]')).some(element => element.textContent?.includes('preview'))).toBe(true)) + expect(Array.from(document.querySelectorAll('[role="option"]')).some(element => element.textContent?.includes('Obsolete'))).toBe(false) + open.value = false + } + expect(speech.activeSpeechModel).toBe('runtime-model') + expect(speech.activeSpeechVoiceId).toBe('runtime') + expect(speech.availableVoices['microsoft-speech']?.[0]?.id).toBe('runtime') + } + finally { + deferred.resolve(Response.json({ voices: [] })) + app.unmount() + disposePinia(pinia) + container.remove() + vi.unstubAllGlobals() + localStorage.clear() + } +}) diff --git a/packages/stage-ui/src/stores/modules/speech-settings.browser.test.ts b/packages/stage-ui/src/stores/modules/speech-settings.browser.test.ts new file mode 100644 index 000000000..9db58d236 --- /dev/null +++ b/packages/stage-ui/src/stores/modules/speech-settings.browser.test.ts @@ -0,0 +1,173 @@ +import type { Component } from 'vue' + +import en from '@proj-airi/i18n/locales/en' + +import { MotionPlugin } from '@vueuse/motion' +import { createPinia, disposePinia } from 'pinia' +import { createSyncedPiniaPlugin } from 'pinia-plugin-synced' +import { afterEach, expect, it, vi } from 'vitest' +import { createApp, h } from 'vue' +import { createI18n } from 'vue-i18n' +import { createMemoryHistory, createRouter } from 'vue-router' + +import SpeechSettings from '../../../../stage-pages/src/pages/settings/modules/speech.vue' + +import { injectKeyPiniaSynced } from '../../libs/pinia/synced-context' +import { captureAnalyticsEvent, enableAnalyticsCapture, isAnalyticsAvailableInBuild } from '../../libs/product-signals/client' +import { useProviderConfigStore } from '../providers/config' +import { useProviderStore } from '../providers/provider' +import { useSpeechStore } from './speech' + +// Analytics delivery is external IO. Exercise the real page and stores while +// recording its outgoing payload instead of sending product events. +vi.mock('../../libs/product-signals/client', { spy: true }) + +const cleanups: Array<() => void> = [] + +/** Mounts a real renderer with a separate Pinia and BroadcastChannel runtime. */ +function mountRenderer(namespace: string, page?: Component) { + const pinia = createPinia() + const runtime = createSyncedPiniaPlugin({ namespace, leadership: page ? 'follower-only' : 'leader-only' }) + pinia.use(runtime.plugin) + const container = document.createElement('div') + document.body.append(container) + const app = createApp({ + setup() { + useSpeechStore() + return () => page ? h(page) : null + }, + }) + const router = createRouter({ history: createMemoryHistory(), routes: [] }) + app.provide(injectKeyPiniaSynced, runtime) + .use(pinia) + .use(router) + .use(MotionPlugin) + .use(createI18n({ legacy: false, locale: 'en', messages: { en } })) + .mount(container) + cleanups.push(() => { + app.unmount() + disposePinia(pinia) + runtime.dispose() + container.remove() + }) + return { app, pinia, runtime, container, speech: useSpeechStore(pinia) } +} + +afterEach(() => { + for (const cleanup of cleanups.splice(0)) + cleanup() + vi.restoreAllMocks() + vi.unstubAllGlobals() + localStorage.clear() +}) + +// https://github.com/moeru-ai/airi/actions/runs/34348745853/job/102456521103 +// ROOT CAUSE: Page initialization and provider watchers awaited model RPCs +// without handling transport disposal. Passing assertions hid a teardown rejection. +it.each(['mount', 'provider change'])('handles interrupted model discovery after %s', async (trigger) => { + localStorage.clear() + vi.stubGlobal('fetch', vi.fn(async () => Response.json({ voices: [], models: [], flux: 0 }))) + const namespace = `speech-settings:${crypto.randomUUID()}` + const leader = mountRenderer(namespace) + await vi.waitFor(() => expect(leader.runtime.isLeader()).toBe(true)) + await useProviderConfigStore(leader.pinia).ensureProvider('microsoft-speech', 'microsoft-speech', { + apiKey: 'key', + baseUrl: 'https://voices.invalid/v1/', + region: 'eastasia', + }) + await leader.speech.selectProviderModel('speech-noop', '') + let completed = 0 + useProviderStore(leader.pinia).$onAction(({ name, after }) => { + if (name === 'loadModelsForConfiguredProviders') + after(() => completed++) + }) + let blocked = 0 + let interrupt = trigger === 'mount' + const postMessage = BroadcastChannel.prototype.postMessage + vi.spyOn(BroadcastChannel.prototype, 'postMessage').mockImplementation(function (this: BroadcastChannel, message) { + if (interrupt && JSON.stringify(message).includes('loadModelsForConfiguredProviders')) { + blocked++ + return + } + postMessage.call(this, message) + }) + const follower = mountRenderer(namespace, SpeechSettings) + const globalErrors = vi.fn() + follower.app.config.errorHandler = globalErrors + if (trigger === 'provider change') { + await vi.waitFor(() => expect(completed).toBeGreaterThan(0)) + interrupt = true + await leader.speech.selectProviderModel('microsoft-speech', 'v1') + } + await vi.waitFor(() => expect(blocked).toBeGreaterThan(0)) + follower.runtime.dispose() + await vi.waitFor(() => expect(globalErrors.mock.calls.length > 0 || follower.container.textContent?.includes('Pinia sync runtime was disposed before the RPC completed.')).toBe(true)) + expect(globalErrors).not.toHaveBeenCalled() + await vi.waitFor(() => expect(follower.container.textContent).toContain('Pinia sync runtime was disposed before the RPC completed.')) +}) + +// https://github.com/moeru-ai/airi/pull/2490#discussion_r3964866483 +// ROOT CAUSE: The click handler read the old model before the leader RPC +// committed the provider. Analytics must use the completed selection receipt. +it('reports the committed provider and model after a settings-page click', async () => { + localStorage.clear() + vi.mocked(captureAnalyticsEvent).mockReset().mockReturnValue(true) + vi.mocked(enableAnalyticsCapture).mockReturnValue(true) + vi.mocked(isAnalyticsAvailableInBuild).mockReturnValue(true) + vi.stubGlobal('fetch', vi.fn(async () => Response.json({ voices: [], models: [], flux: 0 }))) + const namespace = `speech-settings:${crypto.randomUUID()}` + const leader = mountRenderer(namespace) + await vi.waitFor(() => expect(leader.runtime.isLeader()).toBe(true)) + await useProviderConfigStore(leader.pinia).ensureProvider('microsoft-speech', 'microsoft-speech', { + apiKey: 'key', + baseUrl: 'https://voices.invalid/v1/', + region: 'eastasia', + }) + await useProviderStore(leader.pinia).forceProviderConfigured('microsoft-speech') + await leader.speech.selectProviderModel('speech-noop', 'previous-model') + const follower = mountRenderer(namespace, SpeechSettings) + await vi.waitFor(() => expect(follower.speech.activeSpeechModel).toBe('previous-model')) + await vi.waitFor(() => expect(follower.container.querySelector('input[value="microsoft-speech"]')).not.toBeNull()) + const input = follower.container.querySelector('input[value="microsoft-speech"]')! + input.click() + await vi.waitFor(() => expect(vi.mocked(captureAnalyticsEvent).mock.calls.filter(([name]) => name === 'tts_provider_selected')).toHaveLength(1)) + expect(captureAnalyticsEvent).toHaveBeenCalledWith('tts_provider_selected', expect.objectContaining({ + tts_provider_id: 'microsoft-speech', + tts_model_id: 'v1', + source: 'settings', + })) + expect(follower.speech.activeSpeechProvider).toBe('microsoft-speech') +}) + +// https://github.com/moeru-ai/airi/pull/2490#discussion_r3967708960 +// ROOT CAUSE: Manual input bypassed the guarded computed setter. A rejected +// leader RPC reached Vue's global handler instead of the page error display. +it('shows a manual model transport failure in the settings page', async () => { + localStorage.clear() + vi.stubGlobal('fetch', vi.fn(async () => Response.json({ voices: [], data: [] }))) + const namespace = `speech-settings:${crypto.randomUUID()}` + const leader = mountRenderer(namespace) + await vi.waitFor(() => expect(leader.runtime.isLeader()).toBe(true)) + const provider = 'openai-compatible-audio-speech' + await useProviderConfigStore(leader.pinia).ensureProvider(provider, provider, { apiKey: 'key', baseUrl: 'https://voices.invalid/v1/' }) + await useProviderStore(leader.pinia).forceProviderConfigured(provider) + await leader.speech.selectProviderModel(provider, 'tts-1') + const follower = mountRenderer(namespace, SpeechSettings) + await vi.waitFor(() => expect(follower.container.querySelector('input[placeholder="tts-1"]')).not.toBeNull()) + await new Promise(resolve => setTimeout(resolve, 100)) + const globalErrors = vi.fn() + follower.app.config.errorHandler = globalErrors + const postMessage = BroadcastChannel.prototype.postMessage + vi.spyOn(BroadcastChannel.prototype, 'postMessage').mockImplementation(function (this: BroadcastChannel, message) { + if (JSON.stringify(message).includes('selectProviderModel')) + throw new Error('Model selection transport unavailable') + postMessage.call(this, message) + }) + const input = follower.container.querySelector('input[placeholder="tts-1"]')! + input.value = 'custom-model' + input.dispatchEvent(new Event('input', { bubbles: true })) + await new Promise(resolve => setTimeout(resolve, 100)) + expect(globalErrors).not.toHaveBeenCalled() + expect(follower.container.textContent).toContain('Model selection transport unavailable') + expect(leader.speech.activeSpeechModel).toBe('tts-1') +}) diff --git a/packages/stage-ui/src/stores/modules/speech.browser.test.ts b/packages/stage-ui/src/stores/modules/speech.browser.test.ts new file mode 100644 index 000000000..d7c2f10a3 --- /dev/null +++ b/packages/stage-ui/src/stores/modules/speech.browser.test.ts @@ -0,0 +1,668 @@ +import type { LeadershipMode, SyncedPiniaRuntime } from 'pinia-plugin-synced' +import type { App } from 'vue' + +import en from '@proj-airi/i18n/locales/en' + +import { createPinia, disposePinia } from 'pinia' +import { createSyncedPiniaPlugin } from 'pinia-plugin-synced' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createApp } from 'vue' +import { createI18n } from 'vue-i18n' + +import { injectKeyPiniaSynced } from '../../libs/pinia/synced-context' +import { useAuthStore } from '../auth' +import { useProviderConfigStore } from '../providers/config' +import { useProviderStore } from '../providers/provider' +import { useAiriCardStore } from './airi-card' +import { useSpeechStore } from './speech' + +const syncedContexts: Array<{ + app: App + pinia: ReturnType + runtime: SyncedPiniaRuntime +}> = [] + +/** Creates one mounted speech-store renderer with explicit leadership. */ +function createSyncedContext(namespace: string, leadership: LeadershipMode, withCards = false) { + const pinia = createPinia() + const runtime = createSyncedPiniaPlugin({ + callTimeout: 1000, + leadership, + namespace, + }) + pinia.use(runtime.plugin) + + let speechStore: ReturnType | undefined + const app = createApp({ + setup() { + speechStore = useSpeechStore() + if (withCards) + useAiriCardStore() + return () => null + }, + }) + app + .provide(injectKeyPiniaSynced, runtime) + .use(createI18n({ legacy: false, locale: 'en', messages: { en } })) + .use(pinia) + .mount(document.createElement('div')) + + if (!speechStore) + throw new Error('Speech store did not initialize') + + syncedContexts.push({ app, pinia, runtime }) + return { pinia, runtime, speechStore } +} + +/** Creates two real renderers and waits until their leader routing agrees. */ +async function createSyncedPair() { + const namespace = `speech:${crypto.randomUUID()}` + const leader = createSyncedContext(namespace, 'leader-only') + await vi.waitFor(() => expect(leader.runtime.isLeader()).toBe(true)) + const follower = createSyncedContext(namespace, 'follower-only') + await vi.waitFor(() => expect(follower.runtime.getLeaderId()).toBe(leader.runtime.participantId)) + return { leader, follower } +} + +describe('speech synchronization', () => { + // https://github.com/moeru-ai/airi/pull/2490#discussion_r3967949219 + // ROOT CAUSE: Catalog invalidation erased the voice just applied by a card. + // The selection command must discard the old catalog before setting the override. + it.each(['microsoft-speech', 'official-provider-speech'])('preserves a card voice while its new %s model catalog loads', async (provider) => { + const deferred = Promise.withResolvers() + let pause = false + vi.stubGlobal('fetch', vi.fn(async () => pause + ? deferred.promise.then(response => response.clone()) + : Response.json({ voices: [{ id: 'old', name: 'Old', languages: [] }], data: [] }))) + const namespace = `speech:${crypto.randomUUID()}` + const leader = createSyncedContext(namespace, 'leader-only', true) + await vi.waitFor(() => expect(leader.runtime.isLeader()).toBe(true)) + const follower = createSyncedContext(namespace, 'follower-only', true) + await vi.waitFor(() => expect(follower.runtime.getLeaderId()).toBe(leader.runtime.participantId)) + if (provider === 'official-provider-speech') { + const now = new Date() + useAuthStore(leader.pinia).$patch({ + token: 'access-token', + user: { id: 'owner', name: 'Owner', email: 'owner@example.com', emailVerified: true, createdAt: now, updatedAt: now }, + session: { id: 'session', userId: 'owner', token: 'session-token', createdAt: now, updatedAt: now, expiresAt: new Date(now.getTime() + 60000) }, + }) + } + await useProviderConfigStore(leader.pinia).ensureProvider(provider, provider, { apiKey: 'key', baseUrl: 'https://voices.invalid/v1/', region: 'eastasia' }) + await useProviderStore(leader.pinia).forceProviderConfigured(provider) + await leader.speechStore.selectProviderModel(provider, 'model-a', 'old') + await vi.waitFor(() => expect(leader.speechStore.availableVoices[provider]?.[0]?.id).toBe('old')) + await vi.waitFor(() => expect(follower.speechStore.activeSpeechVoice?.id).toBe('old')) + const cards = useAiriCardStore(leader.pinia) + await cards.initialize() + const id = await cards.addCard({ + name: 'Saved voice', + version: '1.0', + description: '', + extensions: { airi: { modules: { speech: { provider, model: 'model-b', voice_id: 'saved' } } } }, + }, 'scratch') + pause = true + try { + await cards.activateCard(id) + await vi.waitFor(() => expect(leader.speechStore.availableVoices[provider]).toEqual([])) + expect(leader.speechStore.activeSpeechVoiceId).toBe('saved') + deferred.resolve(Response.json({ + voices: [{ id: 'recommended', name: 'Recommended', languages: [] }, { id: 'saved', name: 'Saved', languages: [] }], + recommended: { en: 'recommended' }, + })) + await vi.waitFor(() => expect(follower.speechStore.activeSpeechVoice?.id).toBe('saved')) + expect(follower.speechStore.activeSpeechModel).toBe('model-b') + // Consecutive commands must not capture the first card's override as + // an inherited default while its leader action yields. + await cards.activateCard('default') + await Promise.all([cards.activateCard(id), cards.activateCard('default')]) + expect(leader.speechStore.activeSpeechModel).toBe('model-a') + expect(cards.moduleDefaults?.speech.model).toBe('model-a') + } + finally { + deferred.resolve(Response.json({ voices: [] })) + } + }) + + // https://github.com/moeru-ai/airi/pull/2490#discussion_r3967949224 + // ROOT CAUSE: HTTP discovery returned only models, leaving the server default + // in the outgoing renderer instead of the replicated provider catalog. + it('replicates the HTTP speech default to the next leader', async () => { + vi.stubGlobal('fetch', vi.fn(async () => Response.json({ + models: [{ id: 'first', name: 'First' }, { id: 'preferred', name: 'Preferred' }], + default: 'preferred', + voices: [], + }))) + const namespace = `speech:${crypto.randomUUID()}` + const leader = createSyncedContext(namespace, 'leader-only') + await vi.waitFor(() => expect(leader.runtime.isLeader()).toBe(true)) + const survivor = createSyncedContext(namespace, 'follower-preferred') + const provider = 'official-provider-speech' + await useProviderStore(leader.pinia).fetchModelsForProvider(provider) + await vi.waitFor(() => expect(useProviderStore(survivor.pinia).getDefaultModelForProvider(provider)).toBe('preferred')) + const outgoing = syncedContexts.find(context => context.runtime === leader.runtime)! + outgoing.app.unmount() + disposePinia(outgoing.pinia) + outgoing.runtime.dispose() + syncedContexts.splice(syncedContexts.indexOf(outgoing), 1) + await vi.waitFor(() => expect(survivor.runtime.isLeader()).toBe(true), { timeout: 5000 }) + await survivor.speechStore.selectProviderModel(provider, '') + expect(survivor.speechStore.activeSpeechModel).toBe('preferred') + }) + beforeEach(() => { + localStorage.clear() + }) + + afterEach(() => { + for (const context of syncedContexts.splice(0)) { + context.app.unmount() + context.runtime.dispose() + disposePinia(context.pinia) + } + vi.restoreAllMocks() + vi.unstubAllGlobals() + localStorage.clear() + }) + + // https://github.com/moeru-ai/airi/pull/2490#discussion_r3965793949 + // ROOT CAUSE: A delayed settings proposal carried an old catalog and replaced + // a completed leader load. Catalog state must have a separate snapshot owner. + it('preserves a fresh catalog after a delayed follower settings proposal', async () => { + const { leader, follower } = await createSyncedPair() + await new Promise(resolve => setTimeout(resolve, 100)) + const postMessage = BroadcastChannel.prototype.postMessage + const delayed: Array<() => void> = [] + const traffic = vi.spyOn(BroadcastChannel.prototype, 'postMessage').mockImplementation(function (this: BroadcastChannel, message) { + if (JSON.stringify(message).includes('replaceState')) { + const snapshot = structuredClone(message) + delayed.push(() => postMessage.call(this, snapshot)) + return + } + postMessage.call(this, message) + }) + follower.speechStore.pitch = 15 + follower.speechStore.ssmlEnabled = true + await vi.waitFor(() => expect(delayed.length).toBeGreaterThan(0)) + vi.stubGlobal('fetch', vi.fn(async () => Response.json({ voices: [{ id: 'fresh', name: 'Fresh', languages: [] }] }))) + await leader.speechStore.loadVoiceCatalog('microsoft-speech', 'model', { + definitionId: 'microsoft-speech', + config: { apiKey: 'key', baseUrl: 'https://voices.invalid/v1/', region: 'eastasia' }, + }) + await vi.waitFor(() => expect(follower.speechStore.availableVoices['microsoft-speech']?.[0]?.id).toBe('fresh')) + traffic.mockRestore() + for (const deliver of delayed) + deliver() + await vi.waitFor(() => expect(leader.speechStore.pitch).toBe(15)) + expect(leader.speechStore.availableVoices['microsoft-speech']?.[0]?.id).toBe('fresh') + expect(leader.speechStore.voiceCatalogIdentities['microsoft-speech']?.model).toBe('model') + expect(follower.speechStore.$state).not.toHaveProperty('availableVoices') + }) + + // https://github.com/moeru-ai/airi/pull/2490#discussion_r3965793956 + // ROOT CAUSE: Reset canceled the caller and leader, but left a third + // renderer waiting. Every renderer must observe the reset generation. + it('cancels waits in a third renderer when another follower resets', async () => { + const namespace = `speech:${crypto.randomUUID()}` + const leader = createSyncedContext(namespace, 'leader-only') + await vi.waitFor(() => expect(leader.runtime.isLeader()).toBe(true)) + await useProviderConfigStore(leader.pinia).ensureProvider('microsoft-speech', 'microsoft-speech', { + apiKey: 'key', + baseUrl: 'https://voices.invalid/v1/', + region: 'eastasia', + }) + const caller = createSyncedContext(namespace, 'follower-only') + const other = createSyncedContext(namespace, 'follower-only') + await vi.waitFor(() => expect(useProviderConfigStore(other.pinia).configs['microsoft-speech']?.apiKey).toBe('key')) + const { promise: response, resolve: finish } = Promise.withResolvers() + const fetchCatalog = vi.fn(() => response) + vi.stubGlobal('fetch', fetchCatalog) + const pending = other.speechStore.loadVoicesForProvider('microsoft-speech') + try { + await vi.waitFor(() => expect(fetchCatalog).toHaveBeenCalledOnce()) + await caller.speechStore.resetState() + await vi.waitFor(() => expect(other.speechStore.voiceCatalogStatus['microsoft-speech']).toBeUndefined(), { timeout: 400 }) + await expect(pending).resolves.toEqual([]) + } + finally { + finish(Response.json({ voices: [] })) + await pending + } + }) + + // ROOT CAUSE: A request can still be in the transport queue during reset. + // The captured generation rejects it before the provider starts new IO. + it('rejects a pre-reset catalog RPC delivered after reset', async () => { + const namespace = `speech:${crypto.randomUUID()}` + const leader = createSyncedContext(namespace, 'leader-only') + await vi.waitFor(() => expect(leader.runtime.isLeader()).toBe(true)) + await useProviderConfigStore(leader.pinia).ensureProvider('microsoft-speech', 'microsoft-speech', { + apiKey: 'key', + baseUrl: 'https://voices.invalid/v1/', + region: 'eastasia', + }) + const follower = createSyncedContext(namespace, 'follower-only') + await vi.waitFor(() => expect(follower.runtime.getLeaderId()).toBe(leader.runtime.participantId)) + await vi.waitFor(() => expect(useProviderConfigStore(follower.pinia).configs['microsoft-speech']?.apiKey).toBe('key')) + await new Promise(resolve => setTimeout(resolve, 100)) + const fetchCatalog = vi.fn(async () => Response.json({ voices: [] })) + vi.stubGlobal('fetch', fetchCatalog) + const postMessage = BroadcastChannel.prototype.postMessage + const delayed: Array<() => void> = [] + const traffic = vi.spyOn(BroadcastChannel.prototype, 'postMessage').mockImplementation(function (this: BroadcastChannel, message) { + if (JSON.stringify(message).includes('loadVoiceCatalog')) { + const snapshot = structuredClone(message) + delayed.push(() => postMessage.call(this, snapshot)) + return + } + postMessage.call(this, message) + }) + const pending = follower.speechStore.loadVoicesForProvider('microsoft-speech') + await vi.waitFor(() => expect(delayed.length).toBeGreaterThan(0)) + await follower.speechStore.resetState() + traffic.mockRestore() + for (const deliver of delayed) + deliver() + await expect(pending).resolves.toEqual([]) + // Await the same channel's next action so the delayed request has run. + await follower.speechStore.ensureActiveSpeechVoice() + expect(fetchCatalog).not.toHaveBeenCalled() + expect(leader.speechStore.availableVoices['microsoft-speech']).toBeUndefined() + }) + + // https://github.com/moeru-ai/airi/pull/2490#discussion_r3965793959 + // ROOT CAUSE: A new configuration cleared the catalog but kept its selected + // voice. Failed replacement IO then left speech configured with a stale voice. + it('clears the selected voice before loading a different configuration', async () => { + const leader = createSyncedContext(`speech:${crypto.randomUUID()}`, 'leader-only') + await vi.waitFor(() => expect(leader.runtime.isLeader()).toBe(true)) + await useProviderConfigStore(leader.pinia).ensureProvider('microsoft-speech', 'microsoft-speech', { + apiKey: 'key', + baseUrl: 'https://old.invalid/v1/', + region: 'eastasia', + }) + vi.stubGlobal('fetch', vi.fn(async () => Response.json({ voices: [{ id: 'old', name: 'Old', languages: [] }] }))) + await leader.speechStore.selectProviderModel('microsoft-speech', 'model') + await vi.waitFor(() => expect(leader.speechStore.availableVoices['microsoft-speech']?.[0]?.id).toBe('old')) + leader.speechStore.activeSpeechVoiceId = 'old' + await leader.speechStore.ensureActiveSpeechVoice() + expect(leader.speechStore.configured).toBe(true) + const { promise: response, reject: fail } = Promise.withResolvers() + const fetchCatalog = vi.fn(() => response) + vi.stubGlobal('fetch', fetchCatalog) + const pending = leader.speechStore.loadVoiceCatalog('microsoft-speech', 'model', { + definitionId: 'microsoft-speech', + config: { apiKey: 'new-key', baseUrl: 'https://new.invalid/v1/', region: 'westus' }, + }) + const rejection = expect(pending).rejects.toThrow('unavailable') + try { + await vi.waitFor(() => expect(fetchCatalog).toHaveBeenCalledOnce()) + expect(leader.speechStore.activeSpeechVoiceId).toBe('') + expect(leader.speechStore.activeSpeechVoice).toBeUndefined() + expect(leader.speechStore.configured).toBe(false) + } + finally { + fail(new Error('unavailable')) + await rejection + } + }) + + // https://github.com/moeru-ai/airi/pull/2490#discussion_r3959813206 + // ROOT CAUSE: + // + // Each renderer ran the speech watcher and changed a state-synchronized + // store after its local voice request completed. A follower then proposed + // its full snapshot and could overwrite newer leader state. + // + // Before: a follower executed loadVoicesForProvider locally and published a + // replaceState proposal. + // + // We fixed this by routing the action to the synchronization leader. The + // leader publishes the result, and the follower only applies that snapshot. + it('routes voice catalog loading through the leader', async () => { + const { leader: leaderContext, follower: followerContext } = await createSyncedPair() + await new Promise(resolve => setTimeout(resolve, 50)) + + let leaderLoads = 0 + leaderContext.speechStore.$onAction(({ name }) => { + if (name === 'loadVoiceCatalog') + leaderLoads++ + }) + const traffic = vi.spyOn(BroadcastChannel.prototype, 'postMessage') + + await followerContext.speechStore.loadVoicesForProvider('speech-noop') + + expect(leaderLoads).toBe(1) + const proposals = traffic.mock.calls.filter(([message]) => JSON.stringify(message).includes('replaceState')) + expect(proposals).toHaveLength(0) + }) + + // https://github.com/moeru-ai/airi/pull/2490#discussion_r3960117797 + // ROOT CAUSE: + // The provider watcher called its setup-scope function, bypassing the public + // action wrapper. A replicated provider change then published follower state. + // Route watcher requests through the exposed action after store setup. + it('routes replicated provider watcher loading through the leader', async () => { + const { leader: leaderContext, follower: followerContext } = await createSyncedPair() + await new Promise(resolve => setTimeout(resolve, 50)) + + leaderContext.speechStore.activeSpeechProvider = '' + await vi.waitFor(() => expect(followerContext.speechStore.activeSpeechProvider).toBe('')) + await new Promise(resolve => setTimeout(resolve, 100)) + let leaderLoads = 0 + leaderContext.speechStore.$onAction(({ name }) => { + if (name === 'loadVoiceCatalog') + leaderLoads++ + }) + const traffic = vi.spyOn(BroadcastChannel.prototype, 'postMessage') + + leaderContext.speechStore.activeSpeechProvider = 'speech-noop' + await vi.waitFor(() => expect(followerContext.speechStore.activeSpeechProvider).toBe('speech-noop')) + // Both renderers observe the provider, but both requests execute in the leader. + await vi.waitFor(() => expect(leaderLoads).toBe(2)) + await new Promise(resolve => setTimeout(resolve, 100)) + + const proposals = traffic.mock.calls.filter(([message]) => JSON.stringify(message).includes('replaceState')) + expect(proposals).toHaveLength(0) + }) + + // https://github.com/moeru-ai/airi/pull/2490#discussion_r3964660976 + // ROOT CAUSE: A follower selection proposed a full snapshot containing the + // previous catalog while an independent RPC loaded its replacement. + it('commits follower provider and model selection without stale snapshot proposals', async () => { + const namespace = `speech:${crypto.randomUUID()}` + const leader = createSyncedContext(namespace, 'leader-only') + await vi.waitFor(() => expect(leader.runtime.isLeader()).toBe(true)) + await useProviderConfigStore(leader.pinia).ensureProvider('microsoft-speech', 'microsoft-speech', { + apiKey: 'key', + baseUrl: 'https://voices.invalid/v1/', + region: 'eastasia', + }) + const follower = createSyncedContext(namespace, 'follower-only') + await vi.waitFor(() => expect(useProviderConfigStore(follower.pinia).configs['microsoft-speech']?.apiKey).toBe('key')) + vi.stubGlobal('fetch', vi.fn(async () => Response.json({ voices: [{ id: 'fresh', name: 'Fresh', languages: [] }] }))) + const traffic = vi.spyOn(BroadcastChannel.prototype, 'postMessage') + await follower.speechStore.selectProviderModel('microsoft-speech', 'model-a') + expect(follower.speechStore.activeSpeechModel).toBe('model-a') + await vi.waitFor(() => expect(follower.speechStore.availableVoices['microsoft-speech']?.[0]?.id).toBe('fresh')) + expect(leader.speechStore.activeSpeechModel).toBe('model-a') + await follower.speechStore.selectProviderModel('microsoft-speech', 'model-b') + expect(follower.speechStore.activeSpeechModel).toBe('model-b') + await vi.waitFor(() => expect(follower.speechStore.voiceCatalogIdentities['microsoft-speech']?.model).toBe('model-b')) + expect(follower.speechStore.availableVoices['microsoft-speech']?.[0]?.id).toBe('fresh') + expect(traffic.mock.calls.filter(([message]) => JSON.stringify(message).includes('replaceState'))).toHaveLength(0) + }) + + // https://github.com/moeru-ai/airi/pull/2490#discussion_r3960349403 + // ROOT CAUSE: + // Configuration proposals and voice RPCs use independent queues. Capture + // request configuration in the caller instead of reading a stale leader copy. + it('loads voices with the follower configuration before its snapshot arrives', async () => { + const namespace = `speech:${crypto.randomUUID()}` + const leader = createSyncedContext(namespace, 'leader-only') + await vi.waitFor(() => expect(leader.runtime.isLeader()).toBe(true)) + const leaderConfig = useProviderConfigStore(leader.pinia) + await leaderConfig.ensureProvider('microsoft-speech', 'microsoft-speech', { + apiKey: 'old-key', + baseUrl: 'https://old.invalid/v1/', + region: 'eastasia', + }) + const follower = createSyncedContext(namespace, 'follower-only') + const followerConfig = useProviderConfigStore(follower.pinia) + await vi.waitFor(() => expect(followerConfig.configs['microsoft-speech']?.apiKey).toBe('old-key')) + const requests: string[] = [] + vi.stubGlobal('fetch', vi.fn(async (input) => { + requests.push(String(input)) + return Response.json({ voices: [] }) + })) + followerConfig.configs['microsoft-speech'].baseUrl = 'https://new.invalid/v1/' + await follower.speechStore.loadVoicesForProvider('microsoft-speech') + expect(requests).toHaveLength(1) + expect(requests[0]).toContain('https://new.invalid/') + }) + + // https://github.com/moeru-ai/airi/pull/2490#discussion_r3960349408 + // ROOT CAUSE: + // Leader RPC failures bypassed the loader's provider catch block. Public + // loading must contain transport failures without mutating follower state. + it('contains voice RPC failure when the synchronization runtime closes', async () => { + const context = createSyncedContext(`speech:${crypto.randomUUID()}`, 'follower-only') + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}) + const loading = context.speechStore.loadVoicesForProvider('speech-noop') + context.runtime.dispose() + await expect(loading).resolves.toEqual([]) + expect(errors).toHaveBeenCalled() + expect(context.speechStore.speechProviderError).toBeNull() + }) + + // https://github.com/moeru-ai/airi/pull/2490#discussion_r3960349403 + // ROOT CAUSE: + // A slow response for earlier configuration must not overwrite the catalog + // returned for the newer configuration carried by a subsequent command. + it('keeps the newer configuration catalog when an older response arrives last', async () => { + const context = createSyncedContext(`speech:${crypto.randomUUID()}`, 'leader-only') + await vi.waitFor(() => expect(context.runtime.isLeader()).toBe(true)) + const config = useProviderConfigStore(context.pinia) + await config.ensureProvider('microsoft-speech', 'microsoft-speech', { + apiKey: 'key', + baseUrl: 'https://old.invalid/v1/', + region: 'eastasia', + }) + const { promise: oldResponse, resolve: finishOld } = Promise.withResolvers() + let requests = 0 + vi.stubGlobal('fetch', vi.fn(async () => { + requests++ + if (requests === 1) + return oldResponse + return Response.json({ voices: [{ id: 'new', name: 'New', languages: [] }] }) + })) + const oldLoad = context.speechStore.loadVoicesForProvider('microsoft-speech') + try { + await vi.waitFor(() => expect(requests).toBe(1)) + config.configs['microsoft-speech'].baseUrl = 'https://new.invalid/v1/' + await context.speechStore.loadVoicesForProvider('microsoft-speech') + finishOld(Response.json({ voices: [{ id: 'old', name: 'Old', languages: [] }] })) + await oldLoad + expect(context.speechStore.availableVoices['microsoft-speech'][0]?.id).toBe('new') + } + finally { + finishOld(Response.json({ voices: [] })) + await oldLoad + } + }) + // https://github.com/moeru-ai/airi/pull/2490#discussion_r3960674493 + // ROOT CAUSE: A remote catalog triggered local auto-pick state proposals. + it('routes automatic voice selection to the leader without follower proposals', async () => { + const namespace = `speech:${crypto.randomUUID()}` + const leader = createSyncedContext(namespace, 'leader-only') + await vi.waitFor(() => expect(leader.runtime.isLeader()).toBe(true)) + const follower = createSyncedContext(namespace, 'follower-only') + await new Promise(resolve => setTimeout(resolve, 100)) + let selections = 0 + leader.speechStore.$onAction(({ name }) => { + if (name === 'ensureActiveSpeechVoice') + selections++ + }) + // Complete provider initialization before delivering a replacement catalog. + leader.speechStore.activeSpeechProvider = 'official-provider-speech' + await new Promise(resolve => setTimeout(resolve, 100)) + const traffic = vi.spyOn(BroadcastChannel.prototype, 'postMessage') + vi.stubGlobal('fetch', vi.fn(async () => Response.json({ + flux: 0, + voices: [ + { id: 'fallback', name: 'Fallback', languages: [{ code: 'en-US', title: 'English' }] }, + { id: 'voice', name: 'Voice', languages: [{ code: 'en-US', title: 'English' }] }, + ], + recommended: { 'en-US': 'voice' }, + }))) + const now = new Date() + useAuthStore(leader.pinia).$patch({ + token: 'access-token', + user: { id: 'owner', name: 'Owner', email: 'owner@example.com', emailVerified: true, createdAt: now, updatedAt: now }, + session: { id: 'session', userId: 'owner', token: 'session-token', createdAt: now, updatedAt: now, expiresAt: new Date(now.getTime() + 60000) }, + }) + await vi.waitFor(() => expect(useAuthStore(follower.pinia).isAuthenticated).toBe(true)) + await leader.speechStore.loadVoicesForProvider('official-provider-speech') + await vi.waitFor(() => expect(follower.speechStore.activeSpeechVoiceId).toBe('voice')) + await new Promise(resolve => setTimeout(resolve, 100)) + expect(selections).toBeGreaterThan(0) + expect(traffic.mock.calls.filter(([message]) => JSON.stringify(message).includes('replaceState'))).toHaveLength(0) + }) + // https://github.com/moeru-ai/airi/pull/2490#discussion_r3964170541 + // ROOT CAUSE: A replicated loading flag outlived the leader's request after tab closure. + it('recovers an interrupted catalog when the surviving renderer becomes leader', async () => { + const namespace = `speech:${crypto.randomUUID()}` + const leader = createSyncedContext(namespace, 'leader-only') + await vi.waitFor(() => expect(leader.runtime.isLeader()).toBe(true)) + const config = useProviderConfigStore(leader.pinia) + await config.ensureProvider('microsoft-speech', 'microsoft-speech', { apiKey: 'key', baseUrl: 'https://voices.invalid/v1/', region: 'eastasia' }) + const { promise: oldResponse, resolve: finishOld } = Promise.withResolvers() + let pause = false + let catalogVersion = 'cached' + let requests = 0 + vi.stubGlobal('fetch', vi.fn(async () => { + requests++ + if (pause) + return oldResponse + return Response.json({ voices: [{ id: catalogVersion, name: catalogVersion, languages: [] }] }) + })) + const survivor = createSyncedContext(namespace, 'follower-preferred') + await vi.waitFor(() => expect(survivor.runtime.getLeaderId()).toBe(leader.runtime.participantId)) + leader.speechStore.activeSpeechProvider = 'microsoft-speech' + await vi.waitFor(() => expect(survivor.speechStore.availableVoices['microsoft-speech']?.[0]?.id).toBe('cached')) + await vi.waitFor(() => expect(survivor.speechStore.isLoadingSpeechProviderVoices).toBe(false)) + pause = true + const beforeRefresh = requests + const refresh = leader.speechStore.loadVoicesForProvider('microsoft-speech') + try { + await vi.waitFor(() => expect(requests).toBeGreaterThan(beforeRefresh)) + // Same-identity refreshes preserve the last successful catalog during IO. + expect(survivor.speechStore.availableVoices['microsoft-speech']?.[0]?.id).toBe('cached') + pause = false + catalogVersion = 'recovered' + // Dispose the outgoing renderer's store scopes as closing a tab would. + const outgoing = syncedContexts.find(context => context.runtime === leader.runtime)! + outgoing.app.unmount() + disposePinia(outgoing.pinia) + outgoing.runtime.dispose() + syncedContexts.splice(syncedContexts.indexOf(outgoing), 1) + await vi.waitFor(() => expect(survivor.runtime.isLeader()).toBe(true), { timeout: 5000 }) + await vi.waitFor(() => expect(survivor.speechStore.availableVoices['microsoft-speech']?.[0]?.id).toBe('recovered'), { timeout: 5000 }) + await vi.waitFor(() => expect(survivor.speechStore.isLoadingSpeechProviderVoices).toBe(false)) + expect(survivor.pinia.state.value.speech).not.toHaveProperty('voiceCatalogStatus') + } + finally { + finishOld(Response.json({ voices: [] })) + await refresh + } + }) + // https://github.com/moeru-ai/airi/pull/2490#discussion_r3964310221 + // ROOT CAUSE: + // A follower reset cleared only its local request map. The leader could then + // accept a pending response and restore the catalog after the reset. + // The reset must invalidate requests and clear settings in the same leader. + it('rejects a pending leader catalog after a follower resets speech settings', async () => { + const { leader, follower } = await createSyncedPair() + await new Promise(resolve => setTimeout(resolve, 100)) + + const { promise: response, resolve: finish } = Promise.withResolvers() + const fetchCatalog = vi.fn(() => response) + vi.stubGlobal('fetch', fetchCatalog) + const pending = leader.speechStore.loadVoiceCatalog('microsoft-speech', undefined, { + definitionId: 'microsoft-speech', + config: { apiKey: 'key', baseUrl: 'https://voices.invalid/v1/', region: 'eastasia' }, + }) + try { + await vi.waitFor(() => expect(fetchCatalog).toHaveBeenCalledOnce()) + await vi.waitFor(() => expect(follower.speechStore.availableVoices['microsoft-speech']).toEqual([])) + const traffic = vi.spyOn(BroadcastChannel.prototype, 'postMessage') + await follower.speechStore.resetState() + finish(Response.json({ voices: [{ id: 'stale', name: 'Stale', languages: [] }] })) + await expect(pending).resolves.toEqual([]) + expect(leader.speechStore.availableVoices['microsoft-speech']).toBeUndefined() + await vi.waitFor(() => expect(follower.speechStore.availableVoices['microsoft-speech']).toBeUndefined()) + expect(traffic.mock.calls.filter(([message]) => JSON.stringify(message).includes('replaceState'))).toHaveLength(0) + } + finally { + finish(Response.json({ voices: [] })) + await pending + } + }) + + // https://github.com/moeru-ai/airi/pull/2490#discussion_r3964550171 + // ROOT CAUSE: + // A synchronized reset ran only in the leader and left the caller's local + // waiters loading. Cancel local waits before awaiting the shared reset. + it('settles follower catalog waits on reset before the network responds', async () => { + const namespace = `speech:${crypto.randomUUID()}` + const leader = createSyncedContext(namespace, 'leader-only') + await vi.waitFor(() => expect(leader.runtime.isLeader()).toBe(true)) + await useProviderConfigStore(leader.pinia).ensureProvider('microsoft-speech', 'microsoft-speech', { + apiKey: 'key', + baseUrl: 'https://voices.invalid/v1/', + region: 'eastasia', + }) + const follower = createSyncedContext(namespace, 'follower-only') + await vi.waitFor(() => expect(useProviderConfigStore(follower.pinia).configs['microsoft-speech']?.apiKey).toBe('key')) + const { promise: response, resolve: finish } = Promise.withResolvers() + const fetchCatalog = vi.fn(() => response) + vi.stubGlobal('fetch', fetchCatalog) + const first = follower.speechStore.loadVoicesForProvider('microsoft-speech') + const second = follower.speechStore.loadVoicesForProvider('microsoft-speech') + try { + await vi.waitFor(() => expect(fetchCatalog).toHaveBeenCalledOnce()) + expect(follower.speechStore.voiceCatalogStatus['microsoft-speech']?.loading).toBe(true) + await follower.speechStore.resetState() + expect(follower.speechStore.voiceCatalogStatus['microsoft-speech']).toBeUndefined() + await expect(first).resolves.toEqual([]) + await expect(second).resolves.toEqual([]) + await follower.speechStore.resetState() + expect(follower.speechStore.voiceCatalogStatus['microsoft-speech']).toBeUndefined() + } + finally { + finish(Response.json({ voices: [] })) + await Promise.all([first, second]) + } + }) + + it('invalidates completed owned catalogs across renderers without follower proposals', async () => { + vi.stubGlobal('fetch', vi.fn(async () => Response.json({ + flux: 0, + voices: [{ id: 'previous-owner', name: 'Previous owner', languages: [] }], + }))) + const namespace = `speech:${crypto.randomUUID()}` + const leader = createSyncedContext(namespace, 'leader-only') + await vi.waitFor(() => expect(leader.runtime.isLeader()).toBe(true)) + const follower = createSyncedContext(namespace, 'follower-only') + const auth = useAuthStore(leader.pinia) + const now = new Date() + auth.$patch({ + token: 'access-token', + user: { id: 'owner', name: 'Owner', email: 'owner@example.com', emailVerified: true, createdAt: now, updatedAt: now }, + session: { id: 'session', userId: 'owner', token: 'session-token', createdAt: now, updatedAt: now, expiresAt: new Date(now.getTime() + 60000) }, + }) + await vi.waitFor(() => expect(useAuthStore(follower.pinia).user?.id).toBe('owner')) + await leader.speechStore.loadVoicesForProvider('official-provider-speech', 'model-a') + await vi.waitFor(() => expect(follower.speechStore.availableVoices['official-provider-speech']?.[0]?.id).toBe('previous-owner')) + const traffic = vi.spyOn(BroadcastChannel.prototype, 'postMessage') + auth.$patch({ token: null, session: null, user: null }) + await vi.waitFor(() => expect(follower.speechStore.availableVoices['official-provider-speech']).toEqual([])) + expect(leader.speechStore.availableVoices['official-provider-speech']).toEqual([]) + expect(traffic.mock.calls.filter(([message]) => JSON.stringify(message).includes('replaceState'))).toHaveLength(0) + }) + + it('reports a leader provider failure only in the requesting renderer', async () => { + const namespace = `speech:${crypto.randomUUID()}` + const leader = createSyncedContext(namespace, 'leader-only') + await vi.waitFor(() => expect(leader.runtime.isLeader()).toBe(true)) + const config = useProviderConfigStore(leader.pinia) + await config.ensureProvider('microsoft-speech', 'microsoft-speech', { apiKey: 'key', baseUrl: 'https://voices.invalid/v1/', region: 'eastasia' }) + const follower = createSyncedContext(namespace, 'follower-only') + await vi.waitFor(() => expect(useProviderConfigStore(follower.pinia).configs['microsoft-speech']?.apiKey).toBe('key')) + vi.stubGlobal('fetch', vi.fn(async () => { + throw new Error('catalog unavailable') + })) + await expect(follower.speechStore.loadVoicesForProvider('microsoft-speech')).resolves.toEqual([]) + expect(follower.speechStore.voiceCatalogStatus['microsoft-speech']?.error).toContain('catalog unavailable') + expect(follower.speechStore.voiceCatalogStatus['microsoft-speech']?.loading).toBe(false) + expect(leader.speechStore.voiceCatalogStatus['microsoft-speech']).toBeUndefined() + }) +}) diff --git a/packages/stage-ui/src/stores/modules/speech.test.ts b/packages/stage-ui/src/stores/modules/speech.test.ts index 49f1e3329..437f75666 100644 --- a/packages/stage-ui/src/stores/modules/speech.test.ts +++ b/packages/stage-ui/src/stores/modules/speech.test.ts @@ -1,8 +1,11 @@ +import type { Session, User } from 'better-auth' + import { createPinia, setActivePinia } from 'pinia' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { nextTick } from 'vue' -import { OFFICIAL_SPEECH_PROVIDER_ID, OFFICIAL_SPEECH_STREAMING_PROVIDER_ID, providerOfficialSpeech } from '../../libs/providers/providers/official' +import { OFFICIAL_SPEECH_PROVIDER_ID, OFFICIAL_SPEECH_STREAMING_PROVIDER_ID, pickOfficialSpeechVoice } from '../../libs/providers/providers/official' +import { useAuthStore } from '../auth' import { useProviderConfigStore } from '../providers/config' import { useProviderStore } from '../providers/provider' import { toSignedPercent, useSpeechStore } from './speech' @@ -18,12 +21,37 @@ vi.mock('vue-i18n', () => ({ }), })) +/** Configures the authenticated state required by official provider requests. */ +function authenticateOfficialProvider(): void { + const user: User = { + id: 'user-1', + name: 'AIRI User', + email: 'user@example.com', + emailVerified: true, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + } + const session: Session = { + id: 'session-1', + token: 'server-session-token', + userId: user.id, + expiresAt: new Date('2026-12-01T00:00:00.000Z'), + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + } + useAuthStore().$patch({ session, token: 'restored-access-token', user }) +} + describe('speech store helpers', () => { beforeEach(() => { i18nState.locale.value = 'en-US' setActivePinia(createPinia()) }) + afterEach(() => { + vi.unstubAllGlobals() + }) + it('formats positive percentages with a plus sign', () => { expect(toSignedPercent(25)).toBe('+25%') }) @@ -70,8 +98,8 @@ describe('speech store helpers', () => { // object. The voice watcher then assigned undefined to an undefined ref. // refManualReset reported that no-op assignment as another Pinia mutation. // - // We fixed this by writing the selected voice only when a matching voice - // exists and its identity differs from the current selection. + // Catalog refreshes now stay outside the speech settings snapshot. They + // must not publish settings when no selected voice needs an update. it('does not publish a second mutation for an unresolved voice', async () => { const providersStore = useProviderStore() vi.spyOn(providersStore, 'listProviderVoices').mockResolvedValue([]) @@ -79,16 +107,18 @@ describe('speech store helpers', () => { speechStore.activeSpeechProvider = OFFICIAL_SPEECH_PROVIDER_ID speechStore.activeSpeechVoiceId = 'missing-voice' speechStore.activeSpeechVoice = undefined - speechStore.availableVoices = {} + await speechStore.loadVoicesForProvider(OFFICIAL_SPEECH_PROVIDER_ID) await nextTick() + // The startup watcher now enters through the deferred public action. + await vi.waitFor(() => expect(speechStore.isLoadingSpeechProviderVoices).toBe(false)) let mutations = 0 speechStore.$subscribe(() => mutations += 1, { flush: 'sync' }) - speechStore.availableVoices = {} + await speechStore.loadVoicesForProvider(OFFICIAL_SPEECH_PROVIDER_ID) await nextTick() - expect(mutations).toBe(1) + expect(mutations).toBe(0) }) // ROOT CAUSE: @@ -207,6 +237,9 @@ describe('speech store helpers', () => { it('does not load streaming voices before server availability is confirmed', async () => { const providersStore = useProviderStore() const speechStore = useSpeechStore() + // Let the initial no-speech request finish before observing streaming calls. + await nextTick() + await vi.waitFor(() => expect(speechStore.isLoadingSpeechProviderVoices).toBe(false)) const listVoices = vi.spyOn(providersStore, 'listProviderVoices') providersStore.setProviderUnconfigured(OFFICIAL_SPEECH_STREAMING_PROVIDER_ID) @@ -250,6 +283,22 @@ describe('speech store helpers', () => { expect(speechStore.activeSpeechModel).toBe('volcengine/seed-tts-2.0') }) + // https://github.com/moeru-ai/airi/pull/2490#discussion_r3967949224 + // ROOT CAUSE: The consumer read a realm-local default instead of the received snapshot. + it('selects the HTTP default from a provider snapshot', async () => { + const providers = useProviderStore() + const speech = useSpeechStore() + await providers.initializeProvider(OFFICIAL_SPEECH_PROVIDER_ID) + providers.providerRuntimeState[OFFICIAL_SPEECH_PROVIDER_ID] = { + models: ['first', 'snapshot-default'].map(id => ({ id, name: id, provider: OFFICIAL_SPEECH_PROVIDER_ID })), + defaultModel: 'snapshot-default', + modelStatus: 'ready', + modelError: null, + } + await speech.selectProviderModel(OFFICIAL_SPEECH_PROVIDER_ID, '') + expect(speech.activeSpeechModel).toBe('snapshot-default') + }) + /** * @example * speechStore.ensureActiveSpeechModel() @@ -312,11 +361,7 @@ describe('speech store helpers', () => { } try { await providersStore.initializeProvider(OFFICIAL_SPEECH_PROVIDER_ID) - const provider = await providerOfficialSpeech.createProvider({}) - providersStore.providerRuntimeState[OFFICIAL_SPEECH_PROVIDER_ID].models = await providerOfficialSpeech.extraMethods!.listModels!( - {}, - provider, - ) + await providersStore.fetchModelsForProvider(OFFICIAL_SPEECH_PROVIDER_ID) speechStore.ensureActiveSpeechModel() @@ -363,6 +408,7 @@ describe('speech store helpers', () => { recommended: { 'en-US': 'en-US-AvaMultilingualNeural' }, }), { status: 200, headers: { 'Content-Type': 'application/json' } }) }) as typeof fetch) + authenticateOfficialProvider() const providersStore = useProviderStore() const speechStore = useSpeechStore() @@ -372,17 +418,13 @@ describe('speech store helpers', () => { try { await providersStore.initializeProvider(OFFICIAL_SPEECH_PROVIDER_ID) - const provider = await providerOfficialSpeech.createProvider({}) - providersStore.providerRuntimeState[OFFICIAL_SPEECH_PROVIDER_ID].models = await providerOfficialSpeech.extraMethods!.listModels!( - {}, - provider, - ) + await providersStore.fetchModelsForProvider(OFFICIAL_SPEECH_PROVIDER_ID) speechStore.ensureActiveSpeechModel() await speechStore.loadVoicesForProvider(OFFICIAL_SPEECH_PROVIDER_ID, speechStore.activeSpeechModel) expect(speechStore.activeSpeechModel).toBe('microsoft/v1') - expect(speechStore.activeSpeechVoiceId).toBe('en-US-AvaMultilingualNeural') + await vi.waitFor(() => expect(speechStore.activeSpeechVoiceId).toBe('en-US-AvaMultilingualNeural')) } finally { vi.unstubAllGlobals() @@ -424,6 +466,7 @@ describe('speech store helpers', () => { recommended: { 'zh-CN': 'zh-CN-XiaochenNeural' }, }), { status: 200, headers: { 'Content-Type': 'application/json' } }) }) as typeof fetch) + authenticateOfficialProvider() const providersStore = useProviderStore() const speechStore = useSpeechStore() @@ -431,17 +474,13 @@ describe('speech store helpers', () => { try { await providersStore.initializeProvider(OFFICIAL_SPEECH_PROVIDER_ID) - const provider = await providerOfficialSpeech.createProvider({}) - providersStore.providerRuntimeState[OFFICIAL_SPEECH_PROVIDER_ID].models = await providerOfficialSpeech.extraMethods!.listModels!( - {}, - provider, - ) + await providersStore.fetchModelsForProvider(OFFICIAL_SPEECH_PROVIDER_ID) speechStore.ensureActiveSpeechModel() await speechStore.loadVoicesForProvider(OFFICIAL_SPEECH_PROVIDER_ID, speechStore.activeSpeechModel) expect(speechStore.activeSpeechModel).toBe('microsoft/v1') - expect(speechStore.activeSpeechVoiceId).toBe('zh-CN-XiaochenNeural') + await vi.waitFor(() => expect(speechStore.activeSpeechVoiceId).toBe('zh-CN-XiaochenNeural')) } finally { vi.unstubAllGlobals() @@ -482,6 +521,20 @@ describe('single model speech providers', () => { expect(speechStore.activeSpeechModel).toBe('default') }) + // https://github.com/moeru-ai/airi/pull/2490#discussion_r3967236129 + // ROOT CAUSE: Single-model defaults overwrote explicit names from the manual field. + it('preserves a manually entered model through selection and catalog loading', async () => { + const providers = useProviderStore() + await providers.initializeProvider('openai-compatible-audio-speech') + providers.providerRuntimeState['openai-compatible-audio-speech'].models = [ + { id: 'discovered', name: 'Discovered', provider: 'openai-compatible-audio-speech' }, + ] + const speech = useSpeechStore() + await speech.selectProviderModel('openai-compatible-audio-speech', 'manual-model') + await speech.loadVoicesForProvider('openai-compatible-audio-speech', 'manual-model') + expect(speech.activeSpeechModel).toBe('manual-model') + }) + it('keeps the voice when it seeds the model, because voices belong to the provider', async () => { const providersStore = useProviderStore() const speechStore = useSpeechStore() @@ -535,4 +588,237 @@ describe('vOICEVOX provider defaults', () => { expect(providerConfigStore.getProviderConfig('voicevox')?.voiceSettings) .toEqual({ speed: 1, pitch: 0, intonation: 1, volume: 1 }) }) + // ROOT CAUSE: Model reloads lived in the settings page, so card changes bypassed them. + it('refreshes voices when only the active model changes outside settings', async () => { + const providers = useProviderStore() + const loads = vi.spyOn(providers, 'listProviderVoices').mockResolvedValue([]) + const speech = useSpeechStore() + speech.activeSpeechProvider = OFFICIAL_SPEECH_PROVIDER_ID + speech.activeSpeechModel = 'model-a' + await new Promise(resolve => setTimeout(resolve, 20)) + loads.mockClear() + speech.activeSpeechModel = 'model-b' + await new Promise(resolve => setTimeout(resolve, 20)) + expect(loads).toHaveBeenCalledWith(OFFICIAL_SPEECH_PROVIDER_ID, 'model-b', expect.anything()) + }) + + // https://github.com/moeru-ai/airi/pull/2490#discussion_r3964660980 + // ROOT CAUSE: Every refresh cleared the catalog, even when its identity did + // not change. A temporary failure then removed valid cached choices. + it('retains the same catalog on refresh failure but clears it for a different model', async () => { + const providers = useProviderStore() + const voices = [{ id: 'cached', name: 'Cached', languages: [], provider: 'microsoft-speech' }] + const loads = vi.spyOn(providers, 'listProviderVoices').mockResolvedValue(voices) + const speech = useSpeechStore() + await speech.loadVoicesForProvider('microsoft-speech', 'model-a') + loads.mockRejectedValue(new Error('temporary outage')) + await speech.loadVoicesForProvider('microsoft-speech', 'model-a') + expect(speech.availableVoices['microsoft-speech']).toEqual(voices) + expect(speech.voiceCatalogStatus['microsoft-speech']?.error).toBe('temporary outage') + await speech.loadVoicesForProvider('microsoft-speech', 'model-b') + expect(speech.availableVoices['microsoft-speech']).toEqual([]) + }) + + // https://github.com/moeru-ai/airi/pull/2490#discussion_r3966034981 + // ROOT CAUSE: Synthesis settings changed the catalog fingerprint and cleared + // a valid selection. Each adapter must identify its discovery inputs. + it.each(['elevenlabs', 'voicevox', 'microsoft-speech'])('retains %s selection after synthesis settings change', async (provider) => { + const voices = [{ id: 'selected', name: 'Selected', languages: [], provider }] + vi.spyOn(useProviderStore(), 'listProviderVoices').mockResolvedValue(voices) + const speech = useSpeechStore() + await speech.selectProviderModel(provider, 'model') + await vi.waitFor(() => expect(speech.isLoadingSpeechProviderVoices).toBe(false)) + const config = { apiKey: 'key', baseUrl: 'https://voices.invalid/', region: 'eastasia' } + await speech.loadVoiceCatalog(provider, 'model', { definitionId: provider, config }) + speech.activeSpeechVoiceId = 'selected' + await speech.ensureActiveSpeechVoice() + await speech.loadVoiceCatalog(provider, 'model', { + definitionId: provider, + config: { ...config, pitch: 1, speed: 1.2, volume: 0.8, style: 'happy', voiceSettings: { stability: 0.7 } }, + }) + expect(speech.activeSpeechVoiceId).toBe('selected') + expect(speech.activeSpeechVoice?.id).toBe('selected') + expect(speech.configured).toBe(true) + }) + + it('invalidates cached voices when configuration changes or the provider session expires', async () => { + const providers = useProviderStore() + const voices = [{ id: 'cached', name: 'Cached', languages: [], provider: 'microsoft-speech' }] + const loads = vi.spyOn(providers, 'listProviderVoices').mockResolvedValue(voices) + const speech = useSpeechStore() + const original = { definitionId: 'microsoft-speech', config: { baseUrl: 'https://old.invalid/' } } + const changed = { definitionId: 'microsoft-speech', config: { baseUrl: 'https://new.invalid/' } } + await speech.loadVoiceCatalog('microsoft-speech', 'model-a', original) + loads.mockRejectedValue(new Error('configuration unavailable')) + await expect(speech.loadVoiceCatalog('microsoft-speech', 'model-a', changed)).rejects.toThrow('configuration unavailable') + expect(speech.availableVoices['microsoft-speech']).toEqual([]) + loads.mockResolvedValue(voices) + await speech.loadVoiceCatalog('microsoft-speech', 'model-a', changed) + loads.mockResolvedValue(undefined) + await speech.loadVoiceCatalog('microsoft-speech', 'model-a', changed) + expect(speech.availableVoices['microsoft-speech']).toEqual([]) + expect(speech.voiceCatalogIdentities['microsoft-speech']).toBeUndefined() + }) + + // https://github.com/moeru-ai/airi/pull/2490#discussion_r3964866479 + // ROOT CAUSE: Completed catalogs outlived their owner because only pending + // requests observed session invalidation. Logout must invalidate cached data. + it('clears completed owned catalogs on logout while preserving user-provider catalogs', async () => { + vi.stubGlobal('fetch', vi.fn(async () => Response.json({ flux: 0 }))) + authenticateOfficialProvider() + const providers = useProviderStore() + const voices = [{ id: 'old-owner', name: 'Old owner', languages: [], provider: OFFICIAL_SPEECH_PROVIDER_ID }] + const loads = vi.spyOn(providers, 'listProviderVoices').mockResolvedValue(voices) + const speech = useSpeechStore() + await speech.loadVoicesForProvider(OFFICIAL_SPEECH_PROVIDER_ID, 'model-a') + await speech.loadVoicesForProvider('microsoft-speech', 'model-a') + useAuthStore().$patch({ user: null, session: null, token: null }) + await vi.waitFor(() => expect(speech.availableVoices[OFFICIAL_SPEECH_PROVIDER_ID]).toEqual([])) + expect(speech.availableVoices['microsoft-speech']).toEqual(voices) + authenticateOfficialProvider() + useAuthStore().user = { ...useAuthStore().user!, id: 'new-owner' } + loads.mockRejectedValue(new Error('new account unavailable')) + await speech.loadVoicesForProvider(OFFICIAL_SPEECH_PROVIDER_ID, 'model-a') + expect(speech.availableVoices[OFFICIAL_SPEECH_PROVIDER_ID]).toEqual([]) + }) + + // https://github.com/moeru-ai/airi/pull/2490#discussion_r3964866488 + it('retains completed catalogs across token renewal and same-ID session objects', async () => { + vi.stubGlobal('fetch', vi.fn(async () => Response.json({ flux: 0 }))) + authenticateOfficialProvider() + const providers = useProviderStore() + const voices = [{ id: 'retained', name: 'Retained', languages: [], provider: OFFICIAL_SPEECH_PROVIDER_ID }] + vi.spyOn(providers, 'listProviderVoices').mockResolvedValue(voices) + const speech = useSpeechStore() + await speech.loadVoicesForProvider(OFFICIAL_SPEECH_PROVIDER_ID, 'model-a') + const identity = speech.voiceCatalogIdentities[OFFICIAL_SPEECH_PROVIDER_ID] + const auth = useAuthStore() + auth.$patch({ token: 'renewed-token', user: { ...auth.user! }, session: { ...auth.session! } }) + await nextTick() + await speech.invalidateVoiceCatalogs() + expect(speech.availableVoices[OFFICIAL_SPEECH_PROVIDER_ID]).toEqual(voices) + expect(speech.voiceCatalogIdentities[OFFICIAL_SPEECH_PROVIDER_ID]).toEqual(identity) + }) + + it('discards a request reset while its configuration fingerprint is pending', async () => { + const providers = useProviderStore() + const original = providers.getVoiceCatalogIdentity.bind(providers) + let finish!: () => void + const barrier = new Promise((resolve) => { + finish = resolve + }) + vi.spyOn(providers, 'getVoiceCatalogIdentity').mockImplementation(async (model, configuration) => { + const identity = await original(model, configuration) + if (model === 'delayed') + await barrier + return identity + }) + const requests = vi.spyOn(providers, 'listProviderVoices').mockResolvedValue([]) + const speech = useSpeechStore() + const pending = speech.loadVoiceCatalog('microsoft-speech', 'delayed', { definitionId: 'microsoft-speech', config: {} }) + await speech.resetState() + finish() + await expect(pending).resolves.toEqual([]) + expect(requests.mock.calls.some(([, model]) => model === 'delayed')).toBe(false) + expect(speech.availableVoices['microsoft-speech']).toBeUndefined() + }) + + // https://github.com/moeru-ai/airi/pull/2490#discussion_r3964866488 + it('keeps large provider samples out of replicated speech state', async () => { + vi.spyOn(useProviderStore(), 'listProviderVoices').mockResolvedValue([]) + const speech = useSpeechStore() + await speech.loadVoiceCatalog('microsoft-speech', 'model-a', { + definitionId: 'microsoft-speech', + config: { voiceSample: 'private-sample'.repeat(100000) }, + }) + const state = JSON.stringify({ settings: speech.$state, identities: speech.voiceCatalogIdentities }) + expect(state.length).toBeLessThan(2000) + expect(state).not.toContain('private-sample') + }) + + // ROOT CAUSE: The adapter wrote recommendations before the store discarded stale responses. + it('rejects old recommendation side effects together with the old catalog', async () => { + authenticateOfficialProvider() + const speech = useSpeechStore() + const { promise: oldResponse, resolve: finishOld } = Promise.withResolvers() + const voices = [ + { id: 'old', name: 'Old', languages: [{ code: 'en-US', title: 'English' }] }, + { id: 'new', name: 'New', languages: [{ code: 'en-US', title: 'English' }] }, + ] + vi.stubGlobal('fetch', vi.fn(async (input) => { + if (String(input).includes('model=model-a')) + return oldResponse + return Response.json({ voices, recommended: { 'en-US': 'new' } }) + })) + const oldLoad = speech.loadVoicesForProvider(OFFICIAL_SPEECH_PROVIDER_ID, 'model-a') + await new Promise(resolve => setTimeout(resolve, 20)) + await speech.loadVoicesForProvider(OFFICIAL_SPEECH_PROVIDER_ID, 'model-b') + finishOld(Response.json({ voices, recommended: { 'en-US': 'old' } })) + await oldLoad + expect(pickOfficialSpeechVoice({ + activeSpeechProvider: OFFICIAL_SPEECH_PROVIDER_ID, + activeSpeechVoiceId: '', + availableVoices: speech.availableVoices, + uiLocale: 'en-US', + })).toBe('new') + }) + // ROOT CAUSE: Clearing a card's voice could auto-pick from the previous model while its replacement loaded. + it('does not auto-pick from the old model while loading the new catalog', async () => { + const providers = useProviderStore() + const loads = vi.spyOn(providers, 'listProviderVoices').mockResolvedValue([]) + const speech = useSpeechStore() + speech.activeSpeechProvider = OFFICIAL_SPEECH_PROVIDER_ID + speech.activeSpeechModel = 'model-a' + await new Promise(resolve => setTimeout(resolve, 20)) + loads.mockResolvedValue([ + { id: 'old', name: 'Old', languages: [], provider: OFFICIAL_SPEECH_PROVIDER_ID, recommendedFor: ['en-US'] }, + ]) + await speech.loadVoicesForProvider(OFFICIAL_SPEECH_PROVIDER_ID, 'model-a') + await speech.ensureActiveSpeechVoice() + let finish!: () => void + loads.mockImplementation(() => new Promise((resolve) => { + finish = () => resolve([]) + })) + speech.activeSpeechModel = 'model-b' + speech.activeSpeechVoiceId = '' + try { + await vi.waitFor(() => expect(finish).toBeDefined()) + expect(speech.activeSpeechVoiceId).toBe('') + } + finally { + finish?.() + } + }) + // https://github.com/moeru-ai/airi/pull/2490#discussion_r3963756330 + // ROOT CAUSE: A background provider's newer request hid the active provider's pending state and error. + it('keeps active provider status when another provider finishes first', async () => { + const providers = useProviderStore() + const loads = vi.spyOn(providers, 'listProviderVoices').mockResolvedValue([]) + const speech = useSpeechStore() + speech.activeSpeechProvider = 'microsoft-speech' + await new Promise(resolve => setTimeout(resolve, 20)) + let rejectActive!: (error: Error) => void + loads.mockImplementation(async (provider) => { + if (provider === 'microsoft-speech') { + return new Promise((_resolve, reject) => { + rejectActive = reject + }) + } + return [] + }) + const active = speech.loadVoicesForProvider('microsoft-speech') + try { + await vi.waitFor(() => expect(rejectActive).toBeDefined()) + await speech.loadVoicesForProvider('speech-noop') + expect(speech.isLoadingSpeechProviderVoices).toBe(true) + rejectActive(new Error('active provider failed')) + await active + expect(speech.speechProviderError).toBe('active provider failed') + expect(speech.isLoadingSpeechProviderVoices).toBe(false) + } + finally { + rejectActive?.(new Error('test cleanup')) + await active + } + }) }) diff --git a/packages/stage-ui/src/stores/modules/speech.ts b/packages/stage-ui/src/stores/modules/speech.ts index 23e1e52fb..1ed22cacb 100644 --- a/packages/stage-ui/src/stores/modules/speech.ts +++ b/packages/stage-ui/src/stores/modules/speech.ts @@ -1,20 +1,21 @@ import type { SpeechProviderWithExtraOptions } from '@xsai-ext/providers/utils' import type {} from 'pinia-plugin-synced' -import type { VoiceInfo } from '../providers/provider' +import type { VoiceCatalogConfiguration, VoiceCatalogIdentity, VoiceInfo } from '../providers/provider' import { errorMessageFrom } from '@moeru/std' import { useLocalStorageManualReset } from '@proj-airi/stage-shared/composables' import { refManualReset } from '@vueuse/core' import { generateSpeech } from '@xsai/generate-speech' import { isEqual } from 'es-toolkit' -import { defineStore, storeToRefs } from 'pinia' -import { computed, watch } from 'vue' +import { defineStore, getActivePinia, storeToRefs } from 'pinia' +import { computed, hasInjectionContext, inject, onScopeDispose, watch } from 'vue' import { useI18n } from 'vue-i18n' import { toXml } from 'xast-util-to-xml' import { x } from 'xastscript' -import { getDefaultSpeechModel, OFFICIAL_SPEECH_PROVIDER_ID, OFFICIAL_SPEECH_STREAMING_PROVIDER_ID, setupOfficialSpeechAutoPick } from '../../libs/providers/providers/official' +import { injectKeyPiniaSynced } from '../../libs/pinia/synced-context' +import { OFFICIAL_SPEECH_PROVIDER_ID, OFFICIAL_SPEECH_STREAMING_PROVIDER_ID, pickOfficialSpeechVoice } from '../../libs/providers/providers/official' import { useProviderConfigStore } from '../providers/config' import { useProviderStore } from '../providers/provider' @@ -45,7 +46,28 @@ interface SpeechAnalytics { voice_type?: 'official_default' | 'official_selected' | 'custom_configured' | 'voice_pack' } +// Request status belongs to this renderer's RPC wait, not to replicated speech settings. +const useSpeechCatalogRequests = defineStore('speech-catalog-requests', () => { + const status = refManualReset>(() => ({})) + return { status } +}) + +// Only speech's leader actions write this store. Settings proposals cannot +// replace catalogs or roll back the reset generation. A new leader inherits both. +const useSpeechCatalog = defineStore('speech-catalog', () => { + const availableVoices = refManualReset>(() => ({})) + const voiceCatalogIdentities = refManualReset>(() => ({})) + const resetGeneration = refManualReset(0) + return { availableVoices, voiceCatalogIdentities, resetGeneration } +}, { synced: { state: true } }) + export const useSpeechStore = defineStore('speech', () => { + const pinia = getActivePinia() + const runtime = hasInjectionContext() ? inject(injectKeyPiniaSynced, undefined) : undefined + const catalog = useSpeechCatalog() + const { availableVoices, voiceCatalogIdentities, resetGeneration } = storeToRefs(catalog) + const catalogRequests = useSpeechCatalogRequests() + const { status: voiceCatalogStatus } = storeToRefs(catalogRequests) const providersStore = useProviderStore() const providerStore = useProviderConfigStore() const { allAudioSpeechProvidersMetadata } = storeToRefs(providersStore) @@ -64,9 +86,10 @@ export const useSpeechStore = defineStore('speech', () => { const pitch = useLocalStorageManualReset('settings/speech/pitch', 0, persistenceOptions) const rate = useLocalStorageManualReset('settings/speech/rate', 1, persistenceOptions) const ssmlEnabled = useLocalStorageManualReset('settings/speech/ssml-enabled', false, persistenceOptions) - const isLoadingSpeechProviderVoices = refManualReset(false) - const speechProviderError = refManualReset(null) - const availableVoices = refManualReset>(() => ({})) + // Each provider owns its latest request status. Settings for the active + // provider and background provider editors must not consume each other's IO. + const isLoadingSpeechProviderVoices = computed(() => voiceCatalogStatus.value[activeSpeechProvider.value]?.loading ?? false) + const speechProviderError = computed(() => voiceCatalogStatus.value[activeSpeechProvider.value]?.error ?? null) const modelSearchQuery = refManualReset('') // Computed properties @@ -111,8 +134,102 @@ export const useSpeechStore = defineStore('speech', () => { return ['elevenlabs', 'microsoft-speech', 'azure-speech'].includes(activeSpeechProvider.value) }) - async function loadVoicesForProvider(provider: string, model?: string) { - if (!provider) { + // Only leader loads own these counters. Older responses for a provider cannot + // replace its newer catalog. Caller request status has separate local ownership. + let voiceLoadSequence = 0 + const latestVoiceLoads = new Map() + + let localRequestSequence = 0 + let disposed = false + const localRequests = new Map() + const cancelPending = new Set<() => void>() + + /** Captures configuration and tracks this renderer's cancelable RPC wait. */ + async function loadVoicesForProvider(provider: string, model?: string): Promise { + if (!provider || disposed) + return [] + const sequence = ++localRequestSequence + localRequests.set(provider, { sequence, model }) + voiceCatalogStatus.value = { ...voiceCatalogStatus.value, [provider]: { loading: true, error: null } } + let cancel!: () => void + const interrupted = new Promise((resolve) => { + cancel = () => resolve([]) + }) + cancelPending.add(cancel) + let errorMessage: string | null = null + try { + const configuration = providersStore.getVoiceCatalogConfiguration(provider) + return await Promise.race([ + useSpeechStore(pinia).loadVoiceCatalog(provider, model, configuration, resetGeneration.value), + interrupted, + ]) + } + catch (error) { + if (localRequests.get(provider)?.sequence === sequence) { + errorMessage = errorMessageFrom(error) ?? 'Unknown error' + console.error('Failed to load speech voice catalog:', errorMessage) + } + return [] + } + finally { + cancelPending.delete(cancel) + if (localRequests.get(provider)?.sequence === sequence) { + localRequests.delete(provider) + voiceCatalogStatus.value = { ...voiceCatalogStatus.value, [provider]: { loading: false, error: errorMessage } } + } + } + } + + /** Releases local waiters and invalidates results owned by the outgoing leader. */ + function cancelCatalogRequests() { + localRequests.clear() + latestVoiceLoads.clear() + for (const cancel of cancelPending) + cancel() + cancelPending.clear() + voiceCatalogStatus.value = {} + } + + // Reset snapshots release local RPC waits in every renderer, including + // windows that did not initiate the reset. This watcher writes no shared state. + watch(resetGeneration, cancelCatalogRequests, { flush: 'sync' }) + + let observedLeader = runtime?.getLeaderId() + const stopCoordination = runtime?.onCoordinationChange(({ leaderId }) => { + // Participant heartbeats do not change request ownership. Wait for an + // elected replacement before restarting; a gap in election is not a leader. + if (!leaderId || leaderId === observedLeader) + return + if (!observedLeader) { + // Initial election routes the startup watchers' pending calls normally. + observedLeader = leaderId + return + } + observedLeader = leaderId + const reloads = new Map(Array.from(localRequests, ([provider, request]) => [provider, request.model])) + // The current selection takes precedence over an interrupted preview model. + if (activeSpeechProvider.value) + reloads.set(activeSpeechProvider.value, activeSpeechModel.value || undefined) + cancelCatalogRequests() + // Let the election callback finish before routing replacement RPCs. + // Each renderer restarts its own active queries. + void Promise.resolve().then(() => { + if (disposed || observedLeader !== leaderId) + return + for (const [provider, model] of reloads) + void loadVoicesForProvider(provider, model) + }) + }) + onScopeDispose(() => { + disposed = true + stopCoordination?.() + cancelCatalogRequests() + }) + + /** Executes a caller's immutable catalog request in the synchronization leader. */ + async function loadVoiceCatalog(provider: string, model: string | undefined, configuration: VoiceCatalogConfiguration, generation = resetGeneration.value): Promise { + // A queued caller request from before reset cannot start new leader work. + if (!provider || disposed || generation !== resetGeneration.value) { return [] } @@ -123,26 +240,42 @@ export const useSpeechStore = defineStore('speech', () => { return [] } - isLoadingSpeechProviderVoices.value = true - speechProviderError.value = null - - try { - const voices = await providersStore.listProviderVoices(provider, model) - // Reassign to trigger reactivity when adding/updating provider entries - availableVoices.value = { - ...availableVoices.value, - [provider]: voices, - } - return voices + if (provider === activeSpeechProvider.value) { + ensureActiveSpeechModel() + model ??= activeSpeechModel.value || undefined } - catch (error) { - console.error(`Error fetching voices for ${provider}:`, error) - speechProviderError.value = errorMessageFrom(error) ?? 'Unknown error' + + const loadSequence = ++voiceLoadSequence + latestVoiceLoads.set(provider, loadSequence) + if (voiceCatalogIdentities.value[provider]?.model !== model) { + discardVoiceCatalog(provider) + } + const identity = await providersStore.getVoiceCatalogIdentity(model, configuration) + // Hashing yields. Reset, a newer request, or provider ownership changes + // during that work must not clear or replace a newer catalog. + if (latestVoiceLoads.get(provider) !== loadSequence || identity.owner !== providersStore.voiceCatalogOwners[identity.definitionId]) + return [] + // Keep valid choices during a refresh. A model or configuration change + // invalidates them before auto-pick can select from the previous catalog. + if (!isEqual(voiceCatalogIdentities.value[provider], identity)) { + discardVoiceCatalog(provider) + } + + const voices = await providersStore.listProviderVoices(provider, model, configuration) + // Undefined is an expired session. A cleared sequence also rejects work + // from an outgoing leader or a reset, even if its network response arrives. + if (latestVoiceLoads.get(provider) !== loadSequence || identity.owner !== providersStore.voiceCatalogOwners[identity.definitionId]) + return [] + if (voices === undefined) { + // Session expiry also rejects persisted choices without a cached identity. + if (!voiceCatalogIdentities.value[provider] && activeSpeechProvider.value === provider) + clearVoiceSelection() + discardVoiceCatalog(provider) return [] } - finally { - isLoadingSpeechProviderVoices.value = false - } + voiceCatalogIdentities.value = { ...voiceCatalogIdentities.value, [provider]: identity } + availableVoices.value = { ...availableVoices.value, [provider]: voices } + return voices } // Get voices for a specific provider @@ -155,6 +288,45 @@ export const useSpeechStore = defineStore('speech', () => { activeSpeechVoice.value = undefined } + /** Drops catalog metadata and its selected voice together; initial discovery preserves unverified persisted choices. */ + function discardVoiceCatalog(provider: string) { + if (voiceCatalogIdentities.value[provider] && activeSpeechProvider.value === provider) + clearVoiceSelection() + delete voiceCatalogIdentities.value[provider] + availableVoices.value = { ...availableVoices.value, [provider]: [] } + } + + /** Rejects expired recommendations synchronously before any leader consumer can select them. */ + function discardExpiredVoiceCatalogs() { + if (disposed) + return + for (const [provider, identity] of Object.entries(voiceCatalogIdentities.value)) { + if (identity.owner === providersStore.voiceCatalogOwners[identity.definitionId]) + continue + latestVoiceLoads.delete(provider) + discardVoiceCatalog(provider) + } + } + + /** Routes provider ownership notifications to the leader's synchronous invalidation. */ + async function invalidateVoiceCatalogs() { + discardExpiredVoiceCatalogs() + } + + watch(() => providersStore.voiceCatalogOwners, async () => { + // Remote provider snapshots can wake every renderer. Only the exposed + // leader action may clear shared catalogs, and repeated calls are harmless. + await Promise.resolve() + if (disposed) + return + try { + await useSpeechStore(pinia).invalidateVoiceCatalogs() + } + catch (error) { + console.error('Failed to invalidate speech catalogs:', errorMessageFrom(error)) + } + }, { immediate: true }) + // Streaming TTS voices are model-scoped: the server only returns recommended // voices for an explicit `?model=`. Ensure the active model is a valid // streaming model id so voice loading gets the right recommendations (parity @@ -191,6 +363,9 @@ export const useSpeechStore = defineStore('speech', () => { // The voice selection stays as it is. Voices belong to the provider, not to // this model, and a provider switch clears both before this runs. function ensureSingleOptionSpeechModel() { + // An explicit model can be a valid custom endpoint name absent from discovery. + if (activeSpeechModel.value) + return const models = providersStore.getModelsForProvider(activeSpeechProvider.value) if (models.length !== 1) return @@ -218,19 +393,50 @@ export const useSpeechStore = defineStore('speech', () => { if (hasValidSelection) return - const defaultModel = getDefaultSpeechModel() + const defaultModel = providersStore.getDefaultModelForProvider(OFFICIAL_SPEECH_PROVIDER_ID) activeSpeechModel.value = defaultModel && models.some(m => m.id === defaultModel) ? defaultModel : models[0]?.id ?? '' clearVoiceSelection() } - // Watch for provider changes and load voices - watch(activeSpeechProvider, async (newProvider) => { + /** Commits an explicit selection in the leader before watchers request its catalog. An omitted voice preserves an unchanged selection. */ + async function selectProviderModel(provider: string, model: string, voiceId?: string) { + if (disposed) + return + const changed = activeSpeechProvider.value !== provider || activeSpeechModel.value !== model + activeSpeechProvider.value = provider + activeSpeechModel.value = model + if (changed) + clearVoiceSelection() + ensureActiveSpeechModel() + // Discard the previous model before applying an explicit card voice. The + // loader must not treat that new choice as a selection from the old catalog. + if (voiceCatalogIdentities.value[provider]?.model !== (activeSpeechModel.value || undefined)) + discardVoiceCatalog(provider) + if (voiceId !== undefined) + activeSpeechVoiceId.value = voiceId + // Watchers run after this synchronous state commit. They route discovery + // through the exposed action without publishing a follower snapshot. + return { provider: activeSpeechProvider.value, model: activeSpeechModel.value } + } + + // Provider and model form the catalog identity, including changes made by cards. + // Watch both here so loading does not depend on an open settings page. Credential policy + // belongs to the provider boundary, so this module stays auth-agnostic. + watch([activeSpeechProvider, activeSpeechModel], async ([newProvider, newModel], _, onCleanup) => { if (!newProvider) return - ensureActiveSpeechModel() - await loadVoicesForProvider(newProvider, activeSpeechModel.value || undefined) + let stale = false + onCleanup(() => { + stale = true + }) + // Immediate watchers run before Pinia installs action wrappers. Wait for + // setup, then use this store's Pinia instance, even if another app is active. + await Promise.resolve() + if (stale) + return + await useSpeechStore(pinia).loadVoicesForProvider(newProvider, newModel || undefined) // Don't reset voice settings when changing providers to allow for persistence }, { // REVIEW: should we always load voices on init? What will happen when network is not available? @@ -241,14 +447,35 @@ export const useSpeechStore = defineStore('speech', () => { activeSpeechProvider.value = 'speech-noop' } - setupOfficialSpeechAutoPick({ - activeSpeechProvider, - activeSpeechVoiceId, - availableVoices, - uiLocale: locale, - }) + // Snapshots may wake every renderer. Only the leader may apply the selection + // and its derived voice object; the action is idempotent for repeated calls. + watch([activeSpeechProvider, activeSpeechVoiceId, availableVoices], async () => { + await Promise.resolve() + try { + await useSpeechStore(pinia).ensureActiveSpeechVoice() + } + catch (error) { + console.error('Failed to route speech voice selection:', errorMessageFrom(error)) + } + }, { immediate: true, deep: true }) - watch([activeSpeechVoiceId, availableVoices], ([voiceId, voices]) => { + /** Applies official recommendations and the matching voice object in the leader. */ + async function ensureActiveSpeechVoice() { + if (disposed) + return + // A selection watcher can run before the ownership watcher reaches its RPC. + // Reject expired recommendations at their consumer as well as on notification. + discardExpiredVoiceCatalogs() + const selected = pickOfficialSpeechVoice({ + activeSpeechProvider: activeSpeechProvider.value, + activeSpeechVoiceId: activeSpeechVoiceId.value, + availableVoices: availableVoices.value, + uiLocale: locale.value, + }) + if (selected) + activeSpeechVoiceId.value = selected + const voiceId = activeSpeechVoiceId.value + const voices = availableVoices.value if (!voiceId) return @@ -272,10 +499,7 @@ export const useSpeechStore = defineStore('speech', () => { return activeSpeechVoice.value = nextVoice - }, { - immediate: true, - deep: true, - }) + } /** * Generate speech using the specified provider and settings @@ -407,7 +631,16 @@ export const useSpeechStore = defineStore('speech', () => { return hasModel && hasVoice }) - function resetState() { + /** Releases this caller's waits, then awaits the leader's shared reset. Transport failures propagate to the caller. */ + async function resetState() { + cancelCatalogRequests() + await useSpeechStore(pinia).resetSettings() + } + + /** Resets shared settings in the leader and rejects catalog results started before this reset. */ + async function resetSettings() { + // Invalidate request ownership before the reset publishes new settings. + cancelCatalogRequests() activeSpeechProvider.reset() activeSpeechModel.reset() activeSpeechVoiceId.reset() @@ -416,9 +649,11 @@ export const useSpeechStore = defineStore('speech', () => { rate.reset() ssmlEnabled.reset() modelSearchQuery.reset() - availableVoices.reset() - speechProviderError.reset() - isLoadingSpeechProviderVoices.reset() + catalog.$patch((state) => { + state.availableVoices = {} + state.voiceCatalogIdentities = {} + state.resetGeneration++ + }) } return { @@ -431,9 +666,11 @@ export const useSpeechStore = defineStore('speech', () => { pitch, rate, ssmlEnabled, + voiceCatalogStatus: computed(() => voiceCatalogStatus.value), isLoadingSpeechProviderVoices, speechProviderError, - availableVoices, + availableVoices: computed(() => availableVoices.value), + voiceCatalogIdentities: computed(() => voiceCatalogIdentities.value), modelSearchQuery, // Computed @@ -448,15 +685,21 @@ export const useSpeechStore = defineStore('speech', () => { // Actions speech, loadVoicesForProvider, + loadVoiceCatalog, + invalidateVoiceCatalogs, + selectProviderModel, + ensureActiveSpeechVoice, getVoicesForProvider, ensureStreamingDefaultModel, ensureActiveSpeechModel, generateSSML, resolveSpeechInput, resetState, + resetSettings, } }, { synced: { + actions: ['loadVoiceCatalog', 'invalidateVoiceCatalogs', 'selectProviderModel', 'ensureActiveSpeechVoice', 'resetSettings'], state: true, }, }) diff --git a/packages/stage-ui/src/stores/providers/provider.test.ts b/packages/stage-ui/src/stores/providers/provider.test.ts index a709d0a47..fc57ebe77 100644 --- a/packages/stage-ui/src/stores/providers/provider.test.ts +++ b/packages/stage-ui/src/stores/providers/provider.test.ts @@ -10,15 +10,51 @@ import { useAuthStore } from '../auth' import { useProviderConfigStore } from './config' import { useProviderStore } from './provider' +const mocks = vi.hoisted(() => ({ + updateCredits: vi.fn(async () => Response.json({ flux: 0 })), +})) + +vi.mock('../../composables/api', () => ({ + client: { + api: { + v1: { + flux: { $get: mocks.updateCredits }, + }, + }, + }, +})) + vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (_key: string, fallback?: string) => fallback ?? _key, }), })) +/** Creates stable authenticated state for provider-store tests. */ +function createAuthenticatedState(): { session: Session, token: string, user: User } { + const user: User = { + id: 'user-1', + name: 'AIRI User', + email: 'user@example.com', + emailVerified: true, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + } + const session: Session = { + id: 'session-1', + token: 'server-session-token', + userId: user.id, + expiresAt: new Date('2026-12-01T00:00:00.000Z'), + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + } + return { session, token: 'restored-access-token', user } +} + describe('provider store synchronization boundary', () => { beforeEach(() => { setActivePinia(createPinia()) + mocks.updateCredits.mockClear() }) // ROOT CAUSE: @@ -132,23 +168,7 @@ describe('provider store synchronization boundary', () => { expect(store.moduleTranscriptionProvidersMetadata.map(provider => provider.id)).not.toContain(OFFICIAL_TRANSCRIPTION_PROVIDER_ID) expect(store.moduleVisionProvidersMetadata.map(provider => provider.id)).not.toContain('vision-official-provider') - const user: User = { - id: 'user-1', - name: 'AIRI User', - email: 'user@example.com', - emailVerified: true, - createdAt: new Date('2026-01-01T00:00:00.000Z'), - updatedAt: new Date('2026-01-01T00:00:00.000Z'), - } - const session: Session = { - id: 'session-1', - token: 'server-session-token', - userId: user.id, - expiresAt: new Date('2026-12-01T00:00:00.000Z'), - createdAt: new Date('2026-01-01T00:00:00.000Z'), - updatedAt: new Date('2026-01-01T00:00:00.000Z'), - } - useAuthStore().$patch({ user, session }) + useAuthStore().$patch(createAuthenticatedState()) expect(store.moduleChatProvidersMetadata.map(provider => provider.id)).toContain('official-provider') expect(store.moduleSpeechProvidersMetadata.map(provider => provider.id)).toContain(OFFICIAL_SPEECH_PROVIDER_ID) @@ -230,6 +250,7 @@ describe('provider store synchronization boundary', () => { // until it settles, so concurrent callers share the same result. it('shares concurrent voice catalog requests', async () => { const store = useProviderStore() + useAuthStore().$patch(createAuthenticatedState()) let resolveRequest: ((response: Response) => void) | undefined const fetchMock = vi.fn(() => new Promise((resolve) => { resolveRequest = resolve @@ -253,4 +274,152 @@ describe('provider store synchronization boundary', () => { vi.unstubAllGlobals() } }) + + // https://github.com/moeru-ai/airi/pull/2490#discussion_r3959813216 + // ROOT CAUSE: + // + // The speech settings page can request an official voice catalog before the + // authenticated session is ready. A tokenless task then occupies the shared + // in-flight slot and can absorb the first authenticated retry. + // + // Before: the provider starts the official request without an authenticated + // access token. + // + // We fixed this at the provider boundary. Authentication-owned providers do + // not create an in-flight task until the session and token are both ready. + it('does not start auth-owned voice requests before the session has a token', async () => { + const store = useProviderStore() + const authStore = useAuthStore() + const voiceRequests: string[] = [] + const fetchMock = vi.fn(async (input) => { + const url = String(input) + if (url.includes('/api/v1/audio/voices')) { + voiceRequests.push(url) + return Response.json({ recommended: {}, voices: [] }) + } + return Response.json({ flux: 0 }) + }) + vi.stubGlobal('fetch', fetchMock) + + try { + await expect(store.listProviderVoices(OFFICIAL_SPEECH_PROVIDER_ID, 'auto')).resolves.toEqual([]) + expect(voiceRequests).toHaveLength(0) + + authStore.token = 'restored-access-token' + await expect(store.listProviderVoices(OFFICIAL_SPEECH_PROVIDER_ID, 'auto')).resolves.toEqual([]) + expect(voiceRequests).toHaveLength(0) + + authStore.$patch(createAuthenticatedState()) + await expect(store.listProviderVoices(OFFICIAL_SPEECH_PROVIDER_ID, 'auto')).resolves.toEqual([]) + expect(voiceRequests).toHaveLength(1) + } + finally { + vi.unstubAllGlobals() + } + }) + + // https://github.com/moeru-ai/airi/pull/2490#discussion_r3960349395 + // ROOT CAUSE: + // A detached catalog task survived logout and occupied the next session's + // in-flight slot. Session changes must isolate requests and stale errors. + it.each([200, 401])('discards the previous session voice response with status %i', async (status) => { + const store = useProviderStore() + const auth = useAuthStore() + auth.$patch(createAuthenticatedState()) + const { promise: oldResponse, resolve: finishOld } = Promise.withResolvers() + let requests = 0 + let oldSignal: AbortSignal | null | undefined + vi.stubGlobal('fetch', vi.fn(async (_input, options) => { + requests++ + if (requests === 1) { + oldSignal = options?.signal + return oldResponse + } + return Response.json({ recommended: {}, voices: [{ id: 'new-voice', name: 'New voice', languages: [] }] }) + })) + const oldLoad = store.listProviderVoices(OFFICIAL_SPEECH_PROVIDER_ID, 'auto') + try { + await vi.waitFor(() => expect(requests).toBe(1)) + auth.$patch({ user: null, session: null, token: null }) + expect(oldSignal?.aborted).toBe(true) + auth.$patch({ ...createAuthenticatedState(), token: 'new-access-token' }) + const newLoad = store.listProviderVoices(OFFICIAL_SPEECH_PROVIDER_ID, 'auto') + await vi.waitFor(() => expect(requests).toBe(2)) + expect((await newLoad)?.[0]?.id).toBe('new-voice') + finishOld(Response.json({ recommended: {}, voices: [{ id: 'old-voice', name: 'Old voice', languages: [] }] }, { status })) + await expect(oldLoad).resolves.toBeUndefined() + } + finally { + finishOld(Response.json({ voices: [], recommended: {} })) + await oldLoad.catch(() => {}) + vi.unstubAllGlobals() + } + }) + // https://github.com/moeru-ai/airi/pull/2490#discussion_r3960674489 + // ROOT CAUSE: Token rotation aborted discovery without a new login hook. + it('restarts an interrupted catalog after token rotation in the same session', async () => { + const store = useProviderStore() + const auth = useAuthStore() + auth.$patch(createAuthenticatedState()) + let requests = 0 + vi.stubGlobal('fetch', vi.fn(async (_input, options) => { + requests++ + if (requests === 1) { + return new Promise((_resolve, reject) => { + options?.signal?.addEventListener('abort', () => reject(options.signal?.reason), { once: true }) + }) + } + return Response.json({ recommended: {}, voices: [{ id: 'rotated', name: 'Rotated', languages: [] }] }) + })) + try { + const loading = store.listProviderVoices(OFFICIAL_SPEECH_PROVIDER_ID, 'auto') + await vi.waitFor(() => expect(requests).toBe(1)) + const duplicate = store.listProviderVoices(OFFICIAL_SPEECH_PROVIDER_ID, 'auto') + auth.token = 'rotated-token' + expect((await loading)?.[0]?.id).toBe('rotated') + expect((await duplicate)?.[0]?.id).toBe('rotated') + expect(requests).toBe(2) + } + finally { + vi.unstubAllGlobals() + } + }) + // https://github.com/moeru-ai/airi/pull/2490#discussion_r3963756328 + // ROOT CAUSE: Deserialized session objects changed identity without changing request ownership. + it('keeps the replacement request alive when refresh replaces same-ID session objects', async () => { + const store = useProviderStore() + const auth = useAuthStore() + auth.$patch(createAuthenticatedState()) + let requests = 0 + let replacementSignal: AbortSignal | null | undefined + let finish!: (response: Response) => void + vi.stubGlobal('fetch', vi.fn(async (_input, options) => { + requests++ + return new Promise((resolve, reject) => { + options?.signal?.addEventListener('abort', () => reject(options.signal?.reason), { once: true }) + if (requests === 2) { + replacementSignal = options?.signal + finish = resolve + } + }) + })) + const loading = store.listProviderVoices(OFFICIAL_SPEECH_PROVIDER_ID, 'auto') + try { + await vi.waitFor(() => expect(requests).toBe(1)) + auth.token = 'renewed-token' + await vi.waitFor(() => expect(requests).toBe(2)) + const refreshed = createAuthenticatedState() + auth.user = refreshed.user + auth.session = refreshed.session + expect(replacementSignal?.aborted).toBe(false) + finish(Response.json({ voices: [{ id: 'renewed', name: 'Renewed', languages: [] }] })) + expect((await loading)?.[0]?.id).toBe('renewed') + expect(requests).toBe(2) + } + finally { + finish?.(Response.json({ voices: [] })) + await loading + vi.unstubAllGlobals() + } + }) }) diff --git a/packages/stage-ui/src/stores/providers/provider.ts b/packages/stage-ui/src/stores/providers/provider.ts index c27b2e245..88c754bff 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, useAsyncState, useIntervalFn } from '@vueuse/core' import { listModels } from '@xsai/model' import { uniqBy } from 'es-toolkit' import { defineStore } from 'pinia' -import { computed, ref } from 'vue' +import { computed, onScopeDispose, ref, toRaw, watch } from 'vue' import { useI18n } from 'vue-i18n' import { @@ -37,6 +37,21 @@ import { normalizeProviderConfigDefaults } from './config-defaults' export type { ModelInfo, VoiceInfo } from '../../libs/providers/types' +/** Request-local provider configuration carried across the leader RPC boundary. */ +export interface VoiceCatalogConfiguration { + definitionId: string + config: Record +} + +/** Compact freshness metadata replicated with a voice catalog, without request credentials or samples. */ +export interface VoiceCatalogIdentity { + definitionId: string + model: string | undefined + configurationFingerprint: string + /** Opaque provider ownership. Token renewal preserves it; an owner change invalidates cached voices. */ + owner: string | undefined +} + /** Serializable request and model-discovery state for one provider instance. */ export interface ProviderRuntimeState { validatedCredentialHash?: string @@ -115,6 +130,18 @@ export const useProviderStore = defineStore('provider', () => { const providerDefinitions = Object.fromEntries( definedProviders.map(definition => [definition.id, definition]), ) as Record + // Scalar identity keeps same-session object replacements and token renewal + // from invalidating completed catalogs. Consumers do not interpret this key. + const catalogOwner = computed(() => JSON.stringify([ + authStore.isAuthenticated, + authStore.session?.id, + authStore.user?.id, + ])) + const voiceCatalogOwners = computed>(() => Object.fromEntries( + definedProviders + .filter(definition => definition.configuredBy === 'authentication') + .map(definition => [definition.id, catalogOwner.value]), + )) const providerValidationIntervalMsById = new Map() const providerMetadataState = useAsyncState(async () => { const metadata = await selectProvidersMetadata(definedProviders, t) @@ -158,7 +185,29 @@ export const useProviderStore = defineStore('provider', () => { set: value => providerStateStore.runtime = value, }) const providerValidationInFlight = new Map>() - const providerVoiceListInFlight = new Map>() + const providerVoiceListInFlight = new Map>() + // Authentication epochs are local request ownership, never replicated state. + // Logout, account changes, and token replacement invalidate old completions. + let voiceSessionEpoch = 0 + let voiceOwnerEpoch = 0 + const authenticatedVoiceControllers = new Set() + /** Ends authentication-owned requests before a new session can create replacements. */ + function invalidateVoiceSession() { + voiceSessionEpoch++ + for (const controller of authenticatedVoiceControllers) + controller.abort() + authenticatedVoiceControllers.clear() + } + // Compare scalar values, not newly deserialized session or user objects. + watch([() => authStore.isAuthenticated, () => authStore.session?.id, () => authStore.user?.id, () => authStore.token], invalidateVoiceSession, { flush: 'sync' }) + // Token renewal retains request ownership; logout and account changes do not. + watch([() => authStore.isAuthenticated, () => authStore.session?.id, () => authStore.user?.id], () => { + voiceOwnerEpoch++ + }, { flush: 'sync' }) + onScopeDispose(() => { + voiceOwnerEpoch++ + invalidateVoiceSession() + }) const providerRevalidationLoops = new Map void, resume: () => void }>() // Server-driven availability overrides for providers whose visibility can @@ -565,32 +614,88 @@ export const useProviderStore = defineStore('provider', () => { } } - async function listProviderVoices(providerId: string, model?: string) { - const definition = getProviderDefinition(providerId) + /** Captures caller configuration so voice RPCs do not depend on snapshot delivery order. */ + function getVoiceCatalogConfiguration(providerId: string): VoiceCatalogConfiguration { + return { + definitionId: getProviderDefinition(providerId).id, + config: structuredClone(toRaw(providerConfigStore.getProviderConfig(providerId) ?? {})), + } + } + + /** Captures ownership before hashing so a concurrent owner change cannot relabel an old request. */ + async function getVoiceCatalogIdentity(model: string | undefined, configuration: VoiceCatalogConfiguration): Promise { + const owner = voiceCatalogOwners.value[configuration.definitionId] + const selectConfig = getProviderDefinition(configuration.definitionId).extraMethods?.voiceCatalogConfig + // Discovery inputs belong to the adapter. Synthesis controls must not clear + // a voice selection; unknown adapters conservatively retain the full config. + const catalogConfig = selectConfig ? selectConfig(configuration.config) : configuration.config + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(JSON.stringify(catalogConfig))) + return { + definitionId: configuration.definitionId, + model, + configurationFingerprint: Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join(''), + owner, + } + } + + /** Returns undefined when an authentication transition invalidates this request. */ + async function listProviderVoices(providerId: string, model?: string, configuration?: VoiceCatalogConfiguration): Promise { + const request = configuration ?? getVoiceCatalogConfiguration(providerId) + const definition = getProviderDefinition(request.definitionId) + if (!hasProviderVoiceCatalogAccess(request.definitionId)) + return [] const listVoices = definition.extraMethods?.listVoices if (!listVoices) return [] - const config = providerConfigStore.getProviderConfig(providerId) ?? {} - const requestKey = JSON.stringify([providerId, model ?? null, config]) + const config = request.config + const ownerEpoch = voiceOwnerEpoch + const sessionEpoch = definition.configuredBy === 'authentication' ? voiceSessionEpoch : undefined + const requestKey = JSON.stringify([providerId, request.definitionId, model ?? null, config, sessionEpoch]) const pending = providerVoiceListInFlight.get(requestKey) if (pending) return pending const task = (async () => { - const provider = await definition.createProvider(config) + const controller = sessionEpoch === undefined ? undefined : new AbortController() + if (controller) + authenticatedVoiceControllers.add(controller) + let provider: ProviderInstance | undefined try { - return await listVoices(config, provider, model) + provider = await definition.createProvider(config) + // Provider creation can yield across logout before the network call starts. + if (sessionEpoch !== undefined && sessionEpoch !== voiceSessionEpoch) + return undefined + const voices = await listVoices(config, provider, model, controller?.signal) + if (sessionEpoch !== undefined && sessionEpoch !== voiceSessionEpoch) + return undefined + return voices + } + catch (error) { + // An expired session's 401 must not replace the new session's catalog error. + if (sessionEpoch !== undefined && sessionEpoch !== voiceSessionEpoch) + return undefined + throw error } finally { - await disposeTemporaryProvider(provider) + if (controller) + authenticatedVoiceControllers.delete(controller) + if (provider) + await disposeTemporaryProvider(provider) } })() - providerVoiceListInFlight.set(requestKey, task) - - return task.finally(() => { + const result = task.finally(() => { providerVoiceListInFlight.delete(requestKey) + }).then((voices) => { + // A token-only transition has no login hook to replace the aborted load. + // Retry under the current token, but never carry work into another session + // or revive requests after this store is disposed. + if (voices === undefined && ownerEpoch === voiceOwnerEpoch && hasProviderVoiceCatalogAccess(request.definitionId)) + return listProviderVoices(providerId, model, request) + return voices }) + providerVoiceListInFlight.set(requestKey, result) + return result } async function loadProviderModel( @@ -940,6 +1045,14 @@ export const useProviderStore = defineStore('provider', () => { return getProviderDefinition(providerId).configuredBy ?? 'user' } + /** Returns whether this session can start a voice-catalog request. */ + function hasProviderVoiceCatalogAccess(providerId: string): boolean { + if (providerConfiguredBy(providerId) !== 'authentication') + return true + + return authStore.isAuthenticated && !!authStore.token + } + function isProviderConfiguredForModule(providerId: string) { return providerConfigStore.configuredProviders[providerId] && (providerConfiguredBy(providerId) !== 'authentication' || authStore.isAuthenticated) @@ -1011,6 +1124,9 @@ export const useProviderStore = defineStore('provider', () => { getModelsForProvider, getDefaultModelForProvider, listProviderVoices, + getVoiceCatalogConfiguration, + getVoiceCatalogIdentity, + voiceCatalogOwners, loadProviderModel, loadModelsForConfiguredProviders, getProviderInstance, diff --git a/packages/stage-ui/vitest.config.ts b/packages/stage-ui/vitest.config.ts index f2e7befd7..01702f6d5 100644 --- a/packages/stage-ui/vitest.config.ts +++ b/packages/stage-ui/vitest.config.ts @@ -3,6 +3,7 @@ import { cwd } from 'node:process' import Vue from '@vitejs/plugin-vue' import UnoCSS from 'unocss/vite' import Info from 'unplugin-info/vite' +import VueRouter from 'vue-router/vite' import { playwright } from '@vitest/browser-playwright' import { loadEnv } from 'vite' @@ -12,8 +13,15 @@ import { sharedUnoConfig } from '../../uno.config' export default defineConfig({ root: import.meta.dirname, + // Shared settings pages import these optional UI dependencies through the + // component barrel. Bundle them before a browser test starts to avoid HMR. + optimizeDeps: { + include: ['embla-carousel-vue', 'html2canvas', 'node-vibrant/browser'], + }, plugins: [ Info(), + // Use the app's route-block transform when browser tests mount shared pages. + VueRouter({ routesFolder: [], dts: false }), Vue(), UnoCSS({ // Browser tests use product styles, not Histoire's hover-preview variants.