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
@@ -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: [] }
}
}