feat(server): add default TTS model handling and update related tests

This commit is contained in:
RainbowBird
2026-06-28 16:55:09 +08:00
parent 1065ed565b
commit 8036501fb0
5 changed files with 63 additions and 15 deletions
@@ -22,6 +22,15 @@ export const OFFICIAL_TRANSCRIPTION_PROVIDER_ID = 'official-provider-transcripti
// 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
}
// Server-curated default streaming model id, populated by the streaming
// provider's listModels(). Pages that need to seed an initial model selection
// read this via getDefaultStreamingModel() instead of hardcoding an id.
@@ -117,14 +126,17 @@ export const providerOfficialSpeech = defineProvider({
validationRequiredWhen: () => false,
extraMethods: {
listModels: async (): Promise<ModelInfo[]> => {
defaultSpeechModelId = null
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))
const data = await res.json() as { models?: { id: string, name: string }[] }
const data = await res.json() as { models?: { id: string, name: string }[], default?: string | null }
if (!Array.isArray(data.models))
throw new Error('audio models upstream returned malformed body')
defaultSpeechModelId = typeof data.default === 'string' && data.default.length > 0 ? data.default : null
return data.models.map(m => ({
id: m.id,
name: m.name,
@@ -1,7 +1,7 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { OFFICIAL_SPEECH_PROVIDER_ID, OFFICIAL_SPEECH_STREAMING_PROVIDER_ID } from '../../libs/providers/providers/official'
import { OFFICIAL_SPEECH_PROVIDER_ID, OFFICIAL_SPEECH_STREAMING_PROVIDER_ID, providerOfficialSpeech } from '../../libs/providers/providers/official'
import { useProvidersStore } from '../providers'
import { toSignedPercent, useSpeechStore, voicePackForSpeechProvider } from './speech'
@@ -289,7 +289,29 @@ describe('speech store helpers', () => {
* @example
* speechStore.ensureActiveSpeechModel()
*/
it('resets stale streaming model when the regular official speech provider is active', () => {
it('resets stale streaming model to the server default when the regular official speech provider is active', async () => {
vi.stubGlobal('localStorage', {
getItem: vi.fn(() => null),
setItem: vi.fn(),
removeItem: vi.fn(),
})
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
const url = input.toString()
if (url.includes('/api/v1/audio/models')) {
return new Response(JSON.stringify({
models: [
{ id: 'alibaba/cosyvoice-v2', name: 'alibaba/cosyvoice-v2' },
{ id: 'microsoft/v1', name: 'microsoft/v1' },
],
default: 'microsoft/v1',
}), { status: 200, headers: { 'Content-Type': 'application/json' } })
}
return new Response(JSON.stringify({ voices: [], recommended: {} }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}) as typeof fetch)
const providersStore = useProvidersStore()
const speechStore = useSpeechStore()
speechStore.activeSpeechProvider = OFFICIAL_SPEECH_PROVIDER_ID
@@ -301,15 +323,20 @@ describe('speech store helpers', () => {
provider: OFFICIAL_SPEECH_STREAMING_PROVIDER_ID,
languages: [],
}
providersStore.providerRuntimeState[OFFICIAL_SPEECH_PROVIDER_ID].models = [
{ id: 'microsoft/v1', name: 'microsoft/v1', provider: OFFICIAL_SPEECH_PROVIDER_ID },
{ id: 'alibaba/cosyvoice-v2', name: 'alibaba/cosyvoice-v2', provider: OFFICIAL_SPEECH_PROVIDER_ID },
]
try {
providersStore.providerRuntimeState[OFFICIAL_SPEECH_PROVIDER_ID].models = await providerOfficialSpeech.extraMethods!.listModels!(
{},
providerOfficialSpeech.createProvider({}),
)
speechStore.ensureActiveSpeechModel()
speechStore.ensureActiveSpeechModel()
expect(speechStore.activeSpeechModel).toBe('microsoft/v1')
expect(speechStore.activeSpeechVoiceId).toBe('')
expect(speechStore.activeSpeechVoice).toBeUndefined()
expect(speechStore.activeSpeechModel).toBe('microsoft/v1')
expect(speechStore.activeSpeechVoiceId).toBe('')
expect(speechStore.activeSpeechVoice).toBeUndefined()
}
finally {
vi.unstubAllGlobals()
}
})
})
@@ -13,7 +13,7 @@ import { useI18n } from 'vue-i18n'
import { toXml } from 'xast-util-to-xml'
import { x } from 'xastscript'
import { getDefaultStreamingModel, OFFICIAL_SPEECH_PROVIDER_ID, OFFICIAL_SPEECH_STREAMING_PROVIDER_ID, setupOfficialSpeechAutoPick } from '../../libs/providers/providers/official'
import { getDefaultSpeechModel, getDefaultStreamingModel, OFFICIAL_SPEECH_PROVIDER_ID, OFFICIAL_SPEECH_STREAMING_PROVIDER_ID, setupOfficialSpeechAutoPick } from '../../libs/providers/providers/official'
import { useProvidersStore } from '../providers'
export function toSignedPercent(value: number): string {
@@ -286,7 +286,10 @@ export const useSpeechStore = defineStore('speech', () => {
if (hasValidSelection)
return
activeSpeechModel.value = models[0]?.id ?? ''
const defaultModel = getDefaultSpeechModel()
activeSpeechModel.value = defaultModel && models.some(m => m.id === defaultModel)
? defaultModel
: models[0]?.id ?? ''
clearVoiceSelection()
}