fix(stage-ui): restore TTS after first login (#2490)

This commit is contained in:
Lovehsigure_520
2026-09-10 00:08:35 +08:00
committed by GitHub
parent 41f9fb616b
commit 352a9e389b
36 changed files with 2385 additions and 325 deletions
@@ -103,6 +103,7 @@ export const providerElevenLabs = defineProvider<ElevenLabsConfig, 'elevenlabs'>
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<UnElevenLabsOptions>
const voices = await listVoices(toListVoicesOptions(provider))
@@ -160,6 +160,7 @@ export const providerGoogleGeminiAudioSpeech = defineProvider<GoogleGeminiSpeech
description: 'Gemini API text-to-speech model',
capabilities: ['text-to-speech'],
})),
voiceCatalogConfig: () => ({}),
listVoices: async () => googleGeminiTtsVoices.map(([id, style]) => ({
id,
name: id,
@@ -227,6 +227,7 @@ export const providerMimoAudioSpeech = defineProvider<MimoSpeechConfig, 'mimo-au
{ id: 'mimo-v2.5-tts-voicedesign', name: 'MiMo v2.5 TTS Voice Design', provider: 'mimo-audio-speech', description: 'Design a new voice from a natural language description', deprecated: false },
{ id: 'mimo-v2.5-tts-voiceclone', name: 'MiMo v2.5 TTS Voice Clone', provider: 'mimo-audio-speech', description: 'Clone a voice from a base64-encoded audio sample', deprecated: false },
],
voiceCatalogConfig: () => ({}),
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' }] },
@@ -136,6 +136,7 @@ export const providerMinimaxSpeech = defineProvider<MinimaxSpeechConfig, 'minima
{ id: 'speech-2.8-hd', name: 'Speech 2.8 HD', provider: 'minimax-speech', description: 'High-definition TTS model with natural prosody', deprecated: false },
{ id: 'speech-2.8-turbo', name: 'Speech 2.8 Turbo', provider: 'minimax-speech', description: 'Fast TTS model for low-latency scenarios', deprecated: false },
],
voiceCatalogConfig: () => ({}),
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' }] },
@@ -187,6 +187,7 @@ export const providerOpenAIAudioSpeech = defineProvider<OpenAIAudioConfig, 'open
validators: createAudioValidators<OpenAIAudioConfig>(),
extraMethods: {
listModels: async () => openAISpeechModels,
voiceCatalogConfig: () => ({}),
listVoices: async () => openAISpeechVoices,
},
})
@@ -204,6 +205,7 @@ export const providerOpenAICompatibleAudioSpeech = defineProvider<OpenAICompatib
validationRequiredWhen: config => Boolean(config.apiKey?.trim() && config.baseUrl?.trim()),
validators: createAudioValidators<OpenAICompatibleAudioConfig>(),
extraMethods: {
voiceCatalogConfig: () => ({}),
listVoices: async () => [],
listModels: async (config) => {
const apiKey = config.apiKey?.trim() ?? ''
@@ -190,6 +190,7 @@ export const providerOpenRouterAudioSpeech = defineProvider<OpenRouterAudioConfi
return []
}
},
voiceCatalogConfig: () => ({}),
listVoices: async () => openAIVoices.map(id => ({
id,
name: `${id[0].toUpperCase()}${id.slice(1)}`,
@@ -133,6 +133,7 @@ export const providerDeepgramTts = defineProvider<UnspeechConfig, 'deepgram-tts'
{ id: 'aura-1', name: 'Aura 1', provider: 'deepgram-tts', description: 'First generation Aura model', deprecated: false },
{ id: 'aura', name: 'Aura (Legacy)', provider: 'deepgram-tts', description: 'Original Aura model', deprecated: true },
],
voiceCatalogConfig: ({ apiKey, baseUrl }) => ({ apiKey, baseUrl }),
listVoices: async (config) => {
const provider = createUnDeepgram(config.apiKey.trim(), config.baseUrl?.trim() ?? '') as VoiceProviderWithExtraOptions<UnDeepgramOptions>
const voices = await listVoices(toListVoicesOptions(provider))
@@ -162,6 +163,7 @@ export const providerMicrosoftSpeech = defineProvider<MicrosoftSpeechConfig, 'mi
validators: createUnspeechValidators('microsoft-speech'),
extraMethods: {
listModels: async () => [{ 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<UnMicrosoftOptions>
const voices = await listVoices(toListVoicesOptions(provider, { region: config.region ?? '' }))
@@ -194,6 +196,7 @@ export const providerAlibabaCloudModelStudio = defineProvider<UnspeechConfig, 'a
{ id: 'cosyvoice-v1', name: 'CosyVoice', provider: 'alibaba-cloud-model-studio', description: '', deprecated: false },
{ id: 'cosyvoice-v2', name: 'CosyVoice (New)', provider: 'alibaba-cloud-model-studio', description: '', deprecated: false },
],
voiceCatalogConfig: ({ apiKey, baseUrl }) => ({ apiKey, baseUrl }),
listVoices: async (config) => {
const provider = createUnAlibabaCloud(config.apiKey.trim(), config.baseUrl?.trim() ?? '') as VoiceProviderWithExtraOptions<UnAlibabaCloudOptions>
const voices = await listVoices(toListVoicesOptions(provider))
@@ -224,6 +227,7 @@ export const providerVolcengineSpeech = defineProvider<VolcengineSpeechConfig, '
validators: createUnspeechValidators<VolcengineSpeechConfig>('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<UnVolcengineOptions>
const voices = await listVoices(toListVoicesOptions(provider))
@@ -75,6 +75,7 @@ export const providerIndexTtsVllm = defineProvider<IndexTtsConfig, 'index-tts-vl
contextLength: 0,
deprecated: false,
}],
voiceCatalogConfig: ({ baseUrl }) => ({ baseUrl }),
listVoices: async (config) => {
const response = await fetch(voicesUrl(config))
if (!response.ok)
@@ -90,6 +90,7 @@ export const providerPlayer2Speech = defineProvider<Player2Config, 'player2-spee
contextLength: 0,
deprecated: false,
}],
voiceCatalogConfig: ({ baseUrl }) => ({ baseUrl }),
listVoices: async (config) => {
const response = await fetch(new URL('tts/voices', normalizeBaseUrl(config.baseUrl)))
const data = await response.json() as {
@@ -27,6 +27,7 @@ export const providerSpeechNoop = defineProvider({
validationRequiredWhen: () => false,
extraMethods: {
listModels: async () => [],
voiceCatalogConfig: () => ({}),
listVoices: async () => [],
},
})
@@ -108,6 +108,7 @@ export function defineVoicevoxFamilyProvider<const TId extends string>(
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)))
+12 -1
View File
@@ -94,12 +94,21 @@ export interface ProviderModelCatalog {
export interface ProviderExtraMethods<TConfig> {
listModelCatalog?: (config: TConfig, provider: ProviderInstance, contextOptions?: ProviderContext) => Promise<ProviderModelCatalog>
listModels?: (config: TConfig, provider: ProviderInstance, contextOptions?: ProviderContext) => Promise<ModelInfo[]>
/**
* 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<string, unknown>
/**
* 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<VoiceInfo[]>
listVoices?: (config: TConfig, provider: ProviderInstance, model?: string, signal?: AbortSignal) => Promise<VoiceInfo[]>
loadModel?: (config: TConfig, provider: ProviderInstance, hooks?: { onProgress?: (progress: ProgressInfo) => Promise<void> | void }) => Promise<void>
}
@@ -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
@@ -1,8 +1,10 @@
<script setup lang="ts">
import type { Card } from '@proj-airi/ccc'
import type { AiriExtension } from '@proj-airi/stage-ui/stores/modules/airi-card'
import type { VoiceInfo } from '@proj-airi/stage-ui/stores/providers/provider'
import type { Ref } from 'vue'
import { errorMessageFrom } from '@moeru/std'
import { isCustomProvidersDisabled } from '@proj-airi/stage-shared'
import { useAnalytics } from '@proj-airi/stage-ui/composables'
import { DEFAULT_ARTISTRY_WIDGET_INSTRUCTION } from '@proj-airi/stage-ui/constants/prompts/artistry-instruction'
@@ -11,7 +13,6 @@ import { resolveModuleSelection } from '@proj-airi/stage-ui/services/airi-card-m
import { useDisplayModelsStore } from '@proj-airi/stage-ui/stores/display-models'
import { useAiriCardStore } from '@proj-airi/stage-ui/stores/modules/airi-card'
import { useConsciousnessStore } from '@proj-airi/stage-ui/stores/modules/consciousness'
import { useSpeechStore } from '@proj-airi/stage-ui/stores/modules/speech'
import { useVisionStore } from '@proj-airi/stage-ui/stores/modules/vision'
import { useProviderStore } from '@proj-airi/stage-ui/stores/providers/provider'
import { Button, FieldInput, FieldValues } from '@proj-airi/ui'
@@ -62,7 +63,6 @@ const { trackCardEdited } = useAnalytics()
const cardStore = useAiriCardStore()
const consciousnessStore = useConsciousnessStore()
const visionStore = useVisionStore()
const speechStore = useSpeechStore()
const providersStore = useProviderStore()
const displayModelsStore = useDisplayModelsStore()
@@ -83,6 +83,7 @@ const selectedVisionModel = ref<string>('')
const selectedSpeechProvider = ref<string>('')
const selectedSpeechModel = ref<string>('')
const selectedSpeechVoiceId = ref<string>('')
const previewVoices = ref<VoiceInfo[]>([])
const selectedDisplayModelId = ref<string>('')
// NOTICE:
@@ -213,8 +214,7 @@ const speechVoiceOptions = computed(() => {
const provider = selectedSpeechProvider.value || speechProvider.value
if (!provider)
return withInheritGlobalSetting([], selectedSpeechVoiceId.value)
const voices = speechStore.getVoicesForProvider(provider)
return withInheritGlobalSetting(voices.map(voice => ({
return withInheritGlobalSetting(previewVoices.value.map(voice => ({
value: voice.id,
label: voice.name || voice.id,
})), selectedSpeechVoiceId.value)
@@ -250,7 +250,6 @@ async function loadSelectedModuleOptions() {
const speechProviderId = selectedSpeechProvider.value || speechProvider.value
if (speechProviderId) {
loads.push(speechStore.loadVoicesForProvider(speechProviderId, selectedSpeechModel.value || undefined))
if (providersStore.supportsModelListing(speechProviderId))
loads.push(providersStore.fetchModelsForProvider(speechProviderId))
}
@@ -291,19 +290,17 @@ watch(selectedSpeechProvider, async (newProvider, oldProvider) => {
selectedSpeechModel.value = ''
selectedSpeechVoiceId.value = ''
const provider = newProvider || speechProvider.value
await speechStore.loadVoicesForProvider(provider)
if (provider && providersStore.supportsModelListing(provider))
await providersStore.fetchModelsForProvider(provider)
}
}, { flush: 'sync' })
// Reset voice when speech model changes (different models may have different voices)
watch(selectedSpeechModel, async (newModel, oldModel) => {
watch(selectedSpeechModel, (newModel, oldModel) => {
// Only reset if model actually changed and we're not initializing
const provider = selectedSpeechProvider.value || speechProvider.value
if (props.modelValue && !isInitializingModuleSelections && oldModel !== undefined && newModel !== oldModel && provider) {
selectedSpeechVoiceId.value = ''
await speechStore.loadVoicesForProvider(provider, newModel || undefined)
}
}, { flush: 'sync' })
@@ -348,6 +345,34 @@ async function selectTab(tabId: string) {
await loadSelectedModuleOptions()
}
// Preview discovery never commits runtime speech state. Closing the dialog or
// changing its selection invalidates the response, including in-flight RPCs.
watch([
() => props.modelValue && activeTab.value === 'modules',
() => selectedSpeechProvider.value || speechProvider.value,
() => selectedSpeechModel.value || ((selectedSpeechProvider.value || speechProvider.value) === speechProvider.value
? cardStore.moduleDefaults?.speech.model
: undefined),
], async ([open, provider, model], _, onCleanup) => {
let current = true
onCleanup(() => {
current = false
})
previewVoices.value = []
if (!open || !provider)
return
try {
const config = providersStore.getVoiceCatalogConfiguration(provider)
const voices = await providersStore.listProviderVoices(provider, model || undefined, config)
if (current)
previewVoices.value = voices ?? []
}
catch (error) {
if (current)
console.error('Failed to load card preview voices:', errorMessageFrom(error))
}
}, { immediate: true })
// Reset active tab when dialog opens
watch(() => props.modelValue, (isOpen) => {
if (isOpen) {
@@ -108,7 +108,9 @@ const displayedSpeechSource = computed({
return activeSpeechProvider.value
},
set: (value: string) => {
selectSpeechSource(value)
void selectSpeechSource(value).catch((error) => {
errorMessage.value = errorMessageFrom(error) ?? 'An unknown error occurred'
})
},
})
@@ -146,7 +148,9 @@ const displayedSpeechModel = computed({
? streamingModelOptionId(activeSpeechModel.value)
: activeSpeechModel.value,
set: (value: string) => {
selectSpeechModel(value)
void selectSpeechModel(value).catch((error) => {
errorMessage.value = errorMessageFrom(error) ?? 'An unknown error occurred'
})
},
})
@@ -263,18 +267,6 @@ function withManualPreviewAnalytics<TProviderConfig extends Record<string, unkno
}
}
/**
* Tracks the active TTS provider while preserving the legacy provider-card event.
*/
function selectSpeechProvider(providerId: string) {
trackProviderClick(providerId, 'speech')
trackTtsProviderSelected({
tts_provider_id: providerId,
tts_model_id: currentTtsModelId(),
source: 'settings',
})
}
/**
* Tracks explicit voice selection from catalog or custom input controls.
*/
@@ -291,14 +283,23 @@ async function selectSpeechVoice(voiceId: string | undefined) {
})
}
/** Persists the selection only after the leader commits its provider and model. */
async function selectSpeechSource(sourceId: string) {
activeSpeechProvider.value = sourceId
activeSpeechModel.value = ''
activeSpeechVoiceId.value = ''
activeSpeechVoice.value = undefined
const selection = await speechStore.selectProviderModel(sourceId, '')
if (!selection)
return
const providerId = providerStore.providers[sourceId]?.definitionId || sourceId
// Use this command's receipt: another selection can reach the store before
// this caller resumes, but must not relabel this analytics event.
trackTtsProviderSelected({
tts_provider_id: providerId,
tts_model_id: selection.model || 'unknown',
source: 'settings',
})
await persistSelection()
}
/** Resolves the displayed model option before committing it in the leader. */
async function selectSpeechModel(modelOptionId: string) {
const streamingModelId = modelIdFromStreamingOptionId(modelOptionId)
const nextProvider = streamingModelId == null
@@ -308,15 +309,7 @@ async function selectSpeechModel(modelOptionId: string) {
: OFFICIAL_SPEECH_STREAMING_PROVIDER_ID
const nextModel = streamingModelId ?? modelOptionId
if (activeSpeechProvider.value !== nextProvider) {
activeSpeechProvider.value = nextProvider
activeSpeechVoiceId.value = ''
activeSpeechVoice.value = undefined
}
activeSpeechModel.value = nextModel
activeSpeechVoiceId.value = ''
activeSpeechVoice.value = undefined
await speechStore.selectProviderModel(nextProvider, nextModel)
await persistSelection()
}
@@ -339,58 +332,59 @@ function trackOfficialTtsExposure(providerId = activeSpeechProvider.value, model
})
}
// Sync OpenAI Compatible model and voice from provider config
function syncOpenAICompatibleSettings() {
/** Applies provider defaults in the leader without a follower state proposal. */
async function syncOpenAICompatibleSettings() {
if (activeSpeechProvider.value !== 'openai-compatible-audio-speech')
return
const providerConfig = providerStore.getProviderConfig(activeSpeechProvider.value)
// Sync model from provider config (override any existing value from previous provider)
if (providerConfig?.model) {
activeSpeechModel.value = providerConfig.model as string
}
else {
// If no model in provider config, use default
activeSpeechModel.value = 'tts-1'
}
// Sync voice from provider config (override any existing value from previous provider)
// Use updateCustomVoiceName to ensure proper reactivity
if (providerConfig?.voice) {
activeSpeechVoiceId.value = providerConfig.voice as string
updateCustomVoiceName(providerConfig.voice as string)
}
else {
// If no voice in provider config, use default
activeSpeechVoiceId.value = 'alloy'
updateCustomVoiceName('alloy')
}
// Empty provider fields select the same OpenAI defaults as the provider form.
await speechStore.selectProviderModel(
activeSpeechProvider.value,
providerConfig?.model as string || 'tts-1',
providerConfig?.voice as string || 'alloy',
)
}
onMounted(async () => {
try {
await providersStore.loadModelsForConfiguredProviders()
speechStore.ensureActiveSpeechModel()
await speechStore.loadVoicesForProvider(activeSpeechProvider.value, activeSpeechModel.value || undefined)
syncOpenAICompatibleSettings()
await syncOpenAICompatibleSettings()
trackOfficialTtsExposure()
}
catch (error) {
// Closing a renderer rejects pending RPCs even after its page unmounts.
errorMessage.value = errorMessageFrom(error) ?? 'An unknown error occurred'
}
})
watch(activeSpeechProvider, async (newProvider) => {
try {
await providersStore.loadModelsForConfiguredProviders()
if (newProvider !== activeSpeechProvider.value)
return
speechStore.ensureActiveSpeechModel()
// Model discovery can finish after the selection commit. The leader loader
// resolves defaults from that catalog before it requests matching voices.
await speechStore.loadVoicesForProvider(newProvider, activeSpeechModel.value || undefined)
if (newProvider !== activeSpeechProvider.value)
return
trackOfficialTtsExposure(newProvider, currentTtsModelId())
syncOpenAICompatibleSettings()
await syncOpenAICompatibleSettings()
}
catch (error) {
// An obsolete provider request must not replace the current form error.
if (newProvider === activeSpeechProvider.value)
errorMessage.value = errorMessageFrom(error) ?? 'An unknown error occurred'
}
})
watch(activeSpeechModel, async (model) => {
watch(activeSpeechModel, () => {
if (!activeSpeechProvider.value)
return
await speechStore.loadVoicesForProvider(activeSpeechProvider.value, model || undefined)
trackOfficialTtsExposure(activeSpeechProvider.value, currentTtsModelId())
})
@@ -581,10 +575,16 @@ function commitCustomVoiceSelection() {
selectSpeechVoice(activeSpeechVoiceId.value)
}
function updateCustomModelName(value: string | undefined) {
activeSpeechModel.value = value || ''
activeSpeechVoiceId.value = ''
void persistSelection()
/** Routes manual model edits through the same leader commit as listed models. */
async function updateCustomModelName(value: string | undefined) {
errorMessage.value = ''
try {
await speechStore.selectProviderModel(activeSpeechProvider.value, value || '')
await persistSelection()
}
catch (error) {
errorMessage.value = errorMessageFrom(error) ?? 'An unknown error occurred'
}
}
async function handleDeleteProvider(providerId: string) {
@@ -598,6 +598,7 @@ async function handleDeleteProvider(providerId: string) {
</script>
<template>
<ErrorContainer v-if="errorMessage" :error="errorMessage" />
<div flex="~ col md:row gap-6">
<div bg="neutral-100 dark:[rgba(0,0,0,0.3)]" rounded-xl p-4 flex="~ col gap-4" class="h-fit w-full md:w-[40%]">
<div flex="~ col gap-4">
@@ -623,7 +624,7 @@ async function handleDeleteProvider(providerId: string) {
:value="source.id"
:title="source.title"
:description="source.description"
@click="selectSpeechProvider(source.providerId || source.id)"
@click="trackProviderClick(source.providerId || source.id, 'speech')"
>
<template #topRight>
<button
@@ -887,9 +888,12 @@ async function handleDeleteProvider(providerId: string) {
Model
</label>
<select
v-model="activeSpeechModel"
class="w-full border border-neutral-300 rounded bg-white px-3 py-2 dark:border-neutral-700 dark:bg-neutral-900"
@change="selectSpeechModel(activeSpeechModel)"
v-model="displayedSpeechModel"
:class="[
'w-full px-3 py-2',
'border border-neutral-300 rounded dark:border-neutral-700',
'bg-white dark:bg-neutral-900',
]"
>
<option value="eleven_monolingual_v1">
Monolingual v1
@@ -133,7 +133,7 @@ const {
:available-voices="availableVoices"
:generate-speech="handleGenerateSpeech"
:api-key-configured="apiKeyConfigured"
:voices-loading="speechStore.isLoadingSpeechProviderVoices"
:voices-loading="speechStore.voiceCatalogStatus[providerId]?.loading ?? false"
default-text="Hello! This is a test of the Google Gemini Speech."
/>
</template>
@@ -198,7 +198,7 @@ const {
:available-voices="availableVoices"
:generate-speech="handleGenerateSpeech"
:api-key-configured="apiKeyConfigured"
:voices-loading="speechStore.isLoadingSpeechProviderVoices"
:voices-loading="speechStore.voiceCatalogStatus[providerId]?.loading ?? false"
default-text="Hello! This is a test of the Xiaomi MiMo Speech."
/>
</template>
@@ -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.
+1
View File
@@ -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",
@@ -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)
}
})
@@ -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<typeof useDataMaintenance> | 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<typeof fetch>(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')
})
@@ -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() {
@@ -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<SyncedPiniaRuntime> = 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<SyncedOptions, 'leadership'> = {}): {
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
}
@@ -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<SyncedPiniaRuntime> = 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
}
@@ -153,6 +153,7 @@ export const providerKokoroLocal = defineProvider({
throw error
}
},
voiceCatalogConfig: ({ model }) => ({ model }),
listVoices: async (config) => {
try {
const adapter = await getKokoroAdapter()
@@ -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<string, Record<string, string>> = {}
// 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<string, string> {
@@ -132,8 +112,7 @@ export const providerOfficialSpeech = defineProvider({
},
validationRequiredWhen: () => false,
extraMethods: {
listModels: async (): Promise<ModelInfo[]> => {
defaultSpeechModelId = null
listModelCatalog: async (): Promise<ProviderModelCatalog> => {
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 => ({
// 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<VoiceInfo[]> => {
voiceCatalogConfig: () => ({}),
listVoices: async (_config, _provider, model, signal): Promise<VoiceInfo[]> => {
// 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<string, string>
}
// 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<VoiceInfo[]> => {
voiceCatalogConfig: () => ({}),
listVoices: async (_config, _provider, model, signal): Promise<VoiceInfo[]> => {
// 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<string, string>
}
// 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,30 +399,29 @@ 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<string>
activeSpeechVoiceId: Ref<string>
availableVoices: Ref<Record<string, VoiceInfo[]>>
uiLocale: WatchSource<string> | Ref<string>
/** Selects from the catalog and recommendations loaded in this renderer; valid selections stay unchanged. */
export function pickOfficialSpeechVoice(ctx: {
activeSpeechProvider: string
activeSpeechVoiceId: string
availableVoices: Record<string, VoiceInfo[]>
uiLocale: string
}) {
watch([ctx.availableVoices, ctx.activeSpeechProvider], ([voices, provider]) => {
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))
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 uiLocaleValue = typeof ctx.uiLocale === 'function'
? (ctx.uiLocale as () => string)()
: (ctx.uiLocale as Ref<string>).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
@@ -451,7 +433,9 @@ export function setupOfficialSpeechAutoPick(ctx: {
// 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 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))
@@ -460,7 +444,5 @@ export function setupOfficialSpeechAutoPick(ctx: {
|| 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 })
return match?.id
}
@@ -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<typeof fetch>(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<Response>((resolve) => {
finishVoices = resolve
})
const voiceRequests: string[] = []
vi.stubGlobal('fetch', vi.fn<typeof fetch>(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()
@@ -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:
@@ -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<void> {
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,7 +462,11 @@ export const useAiriCardStore = defineStore('airi-card', () => {
}
}
/** Applies the initial card while preserving setup context when no auth work is pending. */
async function initialize() {
// 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.
@@ -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) {
@@ -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<typeof fetch>(async () => Response.json({ voices: [{ id: voice, name: voice, languages: [] }], data: [] }))
vi.stubGlobal('fetch', fetchVoices)
const deferred = Promise.withResolvers<Response>()
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()
}
})
@@ -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<typeof fetch>(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<typeof fetch>(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<HTMLInputElement>('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<typeof fetch>(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<HTMLInputElement>('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')
})
@@ -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<typeof createPinia>
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<typeof useSpeechStore> | 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<Response>()
let pause = false
vi.stubGlobal('fetch', vi.fn<typeof fetch>(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<typeof fetch>(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<typeof fetch>(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<Response>()
const fetchCatalog = vi.fn<typeof fetch>(() => 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<typeof fetch>(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<typeof fetch>(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<Response>()
const fetchCatalog = vi.fn<typeof fetch>(() => 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<typeof fetch>(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<typeof fetch>(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<Response>()
let requests = 0
vi.stubGlobal('fetch', vi.fn<typeof fetch>(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<typeof fetch>(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<Response>()
let pause = false
let catalogVersion = 'cached'
let requests = 0
vi.stubGlobal('fetch', vi.fn<typeof fetch>(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<Response>()
const fetchCatalog = vi.fn<typeof fetch>(() => 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<Response>()
const fetchCatalog = vi.fn<typeof fetch>(() => 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<typeof fetch>(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<typeof fetch>(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()
})
})
@@ -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<typeof fetch>(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<typeof fetch>(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<void>((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<Response>()
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<typeof fetch>(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
}
})
})
+288 -45
View File
@@ -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<Record<string, { loading: boolean, error: string | null }>>(() => ({}))
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<Record<string, VoiceInfo[]>>(() => ({}))
const voiceCatalogIdentities = refManualReset<Record<string, VoiceCatalogIdentity>>(() => ({}))
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<number>('settings/speech/pitch', 0, persistenceOptions)
const rate = useLocalStorageManualReset<number>('settings/speech/rate', 1, persistenceOptions)
const ssmlEnabled = useLocalStorageManualReset<boolean>('settings/speech/ssml-enabled', false, persistenceOptions)
const isLoadingSpeechProviderVoices = refManualReset<boolean>(false)
const speechProviderError = refManualReset<string | null>(null)
const availableVoices = refManualReset<Record<string, VoiceInfo[]>>(() => ({}))
// 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<string>('')
// 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<string, number>()
let localRequestSequence = 0
let disposed = false
const localRequests = new Map<string, { sequence: number, model?: string }>()
const cancelPending = new Set<() => void>()
/** Captures configuration and tracks this renderer's cancelable RPC wait. */
async function loadVoicesForProvider(provider: string, model?: string): Promise<VoiceInfo[]> {
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<VoiceInfo[]>((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<VoiceInfo[]> {
// 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
if (provider === activeSpeechProvider.value) {
ensureActiveSpeechModel()
model ??= activeSpeechModel.value || undefined
}
try {
const voices = await providersStore.listProviderVoices(provider, model)
// Reassign to trigger reactivity when adding/updating provider entries
availableVoices.value = {
...availableVoices.value,
[provider]: voices,
const loadSequence = ++voiceLoadSequence
latestVoiceLoads.set(provider, loadSequence)
if (voiceCatalogIdentities.value[provider]?.model !== model) {
discardVoiceCatalog(provider)
}
return voices
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)
}
catch (error) {
console.error(`Error fetching voices for ${provider}:`, error)
speechProviderError.value = errorMessageFrom(error) ?? 'Unknown error'
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,
},
})
@@ -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<Response>((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<typeof fetch>(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<Response>()
let requests = 0
let oldSignal: AbortSignal | null | undefined
vi.stubGlobal('fetch', vi.fn<typeof fetch>(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<typeof fetch>(async (_input, options) => {
requests++
if (requests === 1) {
return new Promise<Response>((_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<typeof fetch>(async (_input, options) => {
requests++
return new Promise<Response>((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()
}
})
})
@@ -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<string, unknown>
}
/** 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<string, ProviderDefinition>
// 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<Record<string, string>>(() => Object.fromEntries(
definedProviders
.filter(definition => definition.configuredBy === 'authentication')
.map(definition => [definition.id, catalogOwner.value]),
))
const providerValidationIntervalMsById = new Map<string, number>()
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<string, Promise<boolean>>()
const providerVoiceListInFlight = new Map<string, Promise<VoiceInfo[]>>()
const providerVoiceListInFlight = new Map<string, Promise<VoiceInfo[] | undefined>>()
// 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<AbortController>()
/** 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<string, { pause: () => 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<VoiceCatalogIdentity> {
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<VoiceInfo[] | undefined> {
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 {
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,
+8
View File
@@ -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.