fix(stage-ui): restore TTS after first login (#2490)
This commit is contained in:
@@ -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)))
|
||||
|
||||
@@ -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
|
||||
|
||||
+33
-8
@@ -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
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user