fix(stage-pages): initialize streaming speech provider config (#2440)

This commit is contained in:
leafyy
2026-09-02 19:00:05 +08:00
committed by GitHub
parent aafb39c435
commit 13bbad95dc
8 changed files with 478 additions and 80 deletions
@@ -5,7 +5,7 @@ import {
ProviderSettingsLayout,
SpeechPlayground,
} from '@proj-airi/stage-ui/components'
import { getDefaultStreamingModel, selectProviderMetadata, streamingSynthesize } from '@proj-airi/stage-ui/libs'
import { selectProviderMetadata, streamingSynthesize } from '@proj-airi/stage-ui/libs'
import { useAuthStore } from '@proj-airi/stage-ui/stores/auth'
import { useSpeechStore } from '@proj-airi/stage-ui/stores/modules/speech'
import { useProviderConfigStore } from '@proj-airi/stage-ui/stores/providers/config'
@@ -13,7 +13,7 @@ import { useProviderStore } from '@proj-airi/stage-ui/stores/providers/provider'
import { Callout, ComboboxSelect } from '@proj-airi/ui'
import { computedAsync } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, onMounted, ref, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
@@ -42,12 +42,15 @@ const providerConfig = computed(() => providerStore.getProviderConfig(providerId
const providerModels = computed(() => providersStore.getModelsForProvider(providerId))
const modelsLoading = computed(() => providersStore.isLoadingModels[providerId] || false)
const serverDefaultModel = ref<string | null>(null)
const streamingAvailable = ref(false)
const model = computed({
get(): string {
return (providerConfig.value?.model as string | undefined) ?? serverDefaultModel.value ?? ''
},
set(val: string) {
providerConfig.value.model = val
const config = providerConfig.value
if (config)
config.model = val
},
})
const modelOptions = computed(() => providerModels.value.map(m => ({ label: m.name, value: m.id })))
@@ -65,24 +68,61 @@ async function loadVoices() {
}
}
onMounted(async () => {
await providersStore.fetchModelsForProvider(providerId)
// `getDefaultStreamingModel()` is populated by the provider's listModels()
// (just ran via fetchModelsForProvider). If the operator hasn't curated a
// default server-side, fall back to the first model the server returned
// so the picker always has something selected.
serverDefaultModel.value = getDefaultStreamingModel() ?? providerModels.value[0]?.id ?? null
if (!providerConfig.value.model && serverDefaultModel.value)
providerConfig.value.model = serverDefaultModel.value
await loadVoices()
})
watch(isAuthenticated, async (authenticated, _, onCleanup) => {
let active = true
onCleanup(() => active = false)
streamingAvailable.value = false
serverDefaultModel.value = null
if (!authenticated)
return
await providersStore.initializeProvider(providerId)
if (!active)
return
const catalog = await providersStore.fetchModelsForProvider(providerId)
if (!active)
return
// An absent value means that discovery failed before the server returned an
// authoritative state. Keep the last configured state and availability
// override so a transient request failure cannot hide the provider.
if (catalog.available === undefined)
return
const available = catalog.available
await providersStore.setProviderAvailabilityOverride(providerId, available)
if (!active)
return
if (!available) {
await providersStore.setProviderUnconfigured(providerId)
return
}
await providersStore.forceProviderConfigured(providerId)
if (!active)
return
streamingAvailable.value = true
// If the operator did not curate a default server-side, fall back to the
// first model in the same catalog response. Do not read synchronized model
// state here because its follower snapshot can arrive after the action.
serverDefaultModel.value = catalog.defaultModel ?? catalog.models[0]?.id ?? null
const config = providerConfig.value
if (config && !config.model && serverDefaultModel.value)
config.model = serverDefaultModel.value
}, { immediate: true })
// Volcengine TTS 1.0 and 2.0 ship different voice catalogues (mars/moon/ICL
// vs uranus/saturn; see unspeech voices.go). Re-fetch on model change so the
// list switches accordingly.
watch(model, async () => {
watch([isAuthenticated, streamingAvailable, model], async ([authenticated, available, selectedModel]) => {
if (!authenticated || !available || !selectedModel)
return
await loadVoices()
})
}, { immediate: true })
// Synthesize via the streaming session helper. The page uses the SAME
// transport the runtime pipeline uses (ws → API proxy → unspeech
@@ -184,7 +224,7 @@ function handleLogin() {
<ComboboxSelect
v-model="model"
:options="modelOptions"
:disabled="modelsLoading"
:disabled="modelsLoading || !providerConfig"
placeholder="Choose a model..."
/>
</div>
@@ -54,7 +54,6 @@ import './voicevox'
export {
getDefaultStreamingModel,
getStreamingTtsAvailable,
OFFICIAL_TRANSCRIPTION_PROVIDER_ID,
} from './official'
@@ -1,8 +1,8 @@
import type { Ref, WatchSource } from 'vue'
import type { ModelInfo, VoiceInfo } from '../../types'
import type { ModelInfo, ProviderModelCatalog, VoiceInfo } from '../../types'
import { ref, watch } from 'vue'
import { watch } from 'vue'
import { z } from 'zod'
import { getAuthToken } from '../../../../libs/auth'
@@ -42,18 +42,6 @@ export function getDefaultStreamingModel(): string | null {
return defaultStreamingModelId
}
// Operator-controlled visibility switch for the streaming provider. The server
// reports it via `/api/v1/audio/models/streaming` (`available`), and the
// auth-activation glue gates `forceProviderConfigured` on this so the provider
// only surfaces when `UNSPEECH_UPSTREAM.streaming` is configured server-side.
// Reactive so the providers store re-derives configured speech providers when
// the probe resolves after sign-in.
const streamingTtsAvailable = ref(false)
export function getStreamingTtsAvailable(): boolean {
return streamingTtsAvailable.value
}
const officialConfigSchema = z.object({})
function authHeaders(): Record<string, string> {
@@ -64,6 +52,36 @@ function authHeaders(): Record<string, string> {
return headers
}
async function listStreamingModelCatalog(): Promise<ProviderModelCatalog> {
// Streaming TTS catalog is operator-controlled via configKV
// (`UNSPEECH_UPSTREAM.streaming`). Wire shape uses `<backend>/<api_resource_id>`
// (see `unspeech/docs/wire-protocols/audio-speech-stream-v1.md`); the
// server returns whatever the operator put there, no client-side defaults.
// Reset the default up front so a failed probe cannot retain stale data.
defaultStreamingModelId = null
const res = await globalThis.fetch(`${SERVER_URL}/api/v1/audio/models/streaming`, { headers: authHeaders() })
if (!res.ok)
throw new Error(`streaming models upstream ${res.status}: ${await res.text().catch(() => '')}`.slice(0, 256))
const data = await res.json() as { available?: boolean, models: { id: string, name?: string, description?: string }[], default?: string | null }
if (!Array.isArray(data.models))
throw new Error('streaming models upstream missing models[]')
defaultStreamingModelId = typeof data.default === 'string' && data.default.length > 0 ? data.default : null
return {
available: data.available === true,
defaultModel: defaultStreamingModelId,
models: data.models.map(m => ({
id: m.id,
name: m.name ?? m.id,
provider: OFFICIAL_SPEECH_STREAMING_PROVIDER_ID,
description: m.description,
})),
}
}
export const providerOfficialChat = defineProvider({
id: OFFICIAL_CHAT_PROVIDER_ID,
order: -1,
@@ -257,36 +275,8 @@ export const providerOfficialSpeechStreaming = defineProvider({
},
validationRequiredWhen: () => false,
extraMethods: {
listModels: async (): Promise<ModelInfo[]> => {
// Streaming TTS catalog is operator-controlled via configKV
// (`UNSPEECH_UPSTREAM.streaming`). Wire shape uses `<backend>/<api_resource_id>`
// (see `unspeech/docs/wire-protocols/audio-speech-stream-v1.md`); the
// server returns whatever the operator put there, no client-side
// defaults. `default` (when set) seeds initial model selection via
// {@link getDefaultStreamingModel}.
// Reset the operator-driven signals up front so a failed/aborted probe
// leaves the provider hidden rather than stuck on a stale "available".
streamingTtsAvailable.value = false
defaultStreamingModelId = null
const res = await globalThis.fetch(`${SERVER_URL}/api/v1/audio/models/streaming`, { headers: authHeaders() })
if (!res.ok)
throw new Error(`streaming models upstream ${res.status}: ${await res.text().catch(() => '')}`.slice(0, 256))
const data = await res.json() as { available?: boolean, models: { id: string, name?: string, description?: string }[], default?: string | null }
if (!Array.isArray(data.models))
throw new Error('streaming models upstream missing models[]')
streamingTtsAvailable.value = data.available === true
defaultStreamingModelId = typeof data.default === 'string' && data.default.length > 0 ? data.default : null
return data.models.map(m => ({
id: m.id,
name: m.name ?? m.id,
provider: OFFICIAL_SPEECH_STREAMING_PROVIDER_ID,
description: m.description,
}))
},
listModelCatalog: listStreamingModelCatalog,
listModels: async () => (await listStreamingModelCatalog()).models,
listVoices: async (_config, _provider, model): Promise<VoiceInfo[]> => {
// Streaming voices live behind a dedicated endpoint
// (`/audio/voices/streaming`) because they come from the
@@ -74,7 +74,18 @@ export interface ProviderConfigContext<TConfig> {
t: ComposerTranslation
}
/** Serializable model discovery result returned across renderer boundaries. */
export interface ProviderModelCatalog {
/** Models discovered for this provider. */
models: ModelInfo[]
/** Whether the server exposes this catalog. Absent when discovery did not return an authoritative state. */
available?: boolean
/** Server-selected model id, or null when the server has no default. */
defaultModel?: string | null
}
export interface ProviderExtraMethods<TConfig> {
listModelCatalog?: (config: TConfig, provider: ProviderInstance, contextOptions?: { t: (input: string) => string }) => Promise<ProviderModelCatalog>
listModels?: (config: TConfig, provider: ProviderInstance, contextOptions?: { t: (input: string) => string }) => Promise<ModelInfo[]>
/**
* Returns the voice catalogue. `model` lets providers whose voices vary by
@@ -0,0 +1,109 @@
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 { OFFICIAL_SPEECH_STREAMING_PROVIDER_ID } from '../../libs/providers/providers/official'
import { useProviderConfigStore } from './config'
import { useProviderStore } from './provider'
const syncedContexts: Array<{
app: App
pinia: ReturnType<typeof createPinia>
runtime: SyncedPiniaRuntime
}> = []
function createSyncedContext(namespace: string, leadership: LeadershipMode) {
const pinia = createPinia()
const runtime = createSyncedPiniaPlugin({
callTimeout: 1000,
leadership,
namespace,
})
pinia.use(runtime.plugin)
let providerStore: ReturnType<typeof useProviderStore> | undefined
let providerConfigStore: ReturnType<typeof useProviderConfigStore> | undefined
const app = createApp({
setup() {
providerStore = useProviderStore()
providerConfigStore = useProviderConfigStore()
return () => null
},
})
app
.use(createI18n({ legacy: false, locale: 'en', messages: { en } }))
.use(pinia)
.mount(document.createElement('div'))
if (!providerStore || !providerConfigStore)
throw new Error('Provider stores did not initialize')
syncedContexts.push({ app, pinia, runtime })
return { pinia, providerConfigStore, providerStore, runtime }
}
describe('provider model catalog synchronization', () => {
beforeEach(() => {
localStorage.clear()
})
afterEach(() => {
for (const context of syncedContexts.splice(0)) {
context.app.unmount()
context.runtime.dispose()
disposePinia(context.pinia)
}
vi.unstubAllGlobals()
localStorage.clear()
})
// https://github.com/moeru-ai/airi/pull/2440#discussion_r3912226716
// ROOT CAUSE:
//
// The streaming provider stored server availability and its default model
// in module-local variables. A follower-only settings window routed model
// discovery to the leader, then read its own unchanged local variables.
//
// Before: the leader returned only the models and kept the other catalog
// fields in its renderer.
//
// We fixed this by returning one serializable model catalog from the action.
// The follower receives the models, availability, and default model together.
it('returns streaming catalog metadata to a follower-only renderer', async () => {
const fetchMock = vi.fn<typeof fetch>(async () => Response.json({
available: true,
default: 'volcengine/seed-tts-2.0',
models: [
{ id: 'volcengine/seed-tts-2.0', name: 'Seed TTS 2.0' },
],
}))
vi.stubGlobal('fetch', fetchMock)
const namespace = `provider-model-catalog:${crypto.randomUUID()}`
const leaderContext = createSyncedContext(namespace, 'leader-only')
await vi.waitFor(() => expect(leaderContext.runtime.isLeader()).toBe(true))
const followerContext = createSyncedContext(namespace, 'follower-only')
await vi.waitFor(() => expect(followerContext.runtime.getLeaderId()).toBe(leaderContext.runtime.participantId))
await followerContext.providerStore.initializeProvider(OFFICIAL_SPEECH_STREAMING_PROVIDER_ID)
const catalog = await followerContext.providerStore.fetchModelsForProvider(OFFICIAL_SPEECH_STREAMING_PROVIDER_ID)
expect(catalog).toEqual({
available: true,
defaultModel: 'volcengine/seed-tts-2.0',
models: [
expect.objectContaining({
id: 'volcengine/seed-tts-2.0',
name: 'Seed TTS 2.0',
provider: OFFICIAL_SPEECH_STREAMING_PROVIDER_ID,
}),
],
})
})
})
@@ -530,23 +530,33 @@ export const useProviderStore = defineStore('provider', () => {
const definition = getProviderDefinition(providerId)
const provider = await definition.createProvider(config)
try {
if (definition.extraMethods?.listModelCatalog) {
const catalog = await definition.extraMethods.listModelCatalog(config, provider, { t })
return {
...catalog,
models: normalizeProviderModels(providerId, catalog.models),
}
}
if (definition.extraMethods?.listModels) {
const models = await definition.extraMethods.listModels(config, provider, { t })
return normalizeProviderModels(providerId, models)
return { models: normalizeProviderModels(providerId, models) }
}
if (isModelProvider(provider))
return normalizeProviderModels(providerId, await listModels(provider.model()))
return { models: normalizeProviderModels(providerId, await listModels(provider.model())) }
const baseUrl = typeof config.baseUrl === 'string' ? config.baseUrl.trim() : ''
const apiKey = typeof config.apiKey === 'string' ? config.apiKey.trim() : ''
if (!baseUrl)
return []
return { models: [] }
return normalizeProviderModels(providerId, await listModels({
baseURL: baseUrl,
...(apiKey ? { apiKey } : {}),
}))
return {
models: normalizeProviderModels(providerId, await listModels({
baseURL: baseUrl,
...(apiKey ? { apiKey } : {}),
})),
}
}
finally {
await disposeTemporaryProvider(provider)
@@ -603,11 +613,11 @@ export const useProviderStore = defineStore('provider', () => {
async function fetchModelsForProvider(providerId: string) {
const definition = findProviderDefinition(providerId)
if (!definition)
return []
return { models: [] }
const config = providerCredentials.value[providerId]
if (!config && definition.requiresCredentials !== false)
return []
return { models: [] }
initializeProviderRuntimeState(providerId)
providerRuntimeState.value = {
@@ -620,8 +630,8 @@ export const useProviderStore = defineStore('provider', () => {
}
try {
const models = await listProviderModels(providerId, config || {})
const normalizedModels = uniqBy(models.filter(model => !!model.id), m => m.id)
const catalog = await listProviderModels(providerId, config || {})
const normalizedModels = uniqBy(catalog.models.filter(model => !!model.id), m => m.id)
.map(model => ({
id: model.id,
name: model.name,
@@ -646,11 +656,15 @@ export const useProviderStore = defineStore('provider', () => {
modelError: null,
},
}
// Synced action results pass through structuredClone. Return the local
// array because reading the same array from state returns a Vue proxy.
return normalizedModels
// Synced action results pass through structuredClone. Return local
// catalog values because reading models back from state returns a Vue
// proxy and provider-specific metadata is not part of synced state.
return {
...catalog,
models: normalizedModels,
}
}
return []
return { models: [] }
}
catch (error) {
console.error(`Error fetching models for ${providerId}:`, error)
@@ -665,7 +679,7 @@ export const useProviderStore = defineStore('provider', () => {
},
}
}
return []
return { models: [] }
}
}