fix(stage-pages): initialize streaming speech provider config (#2440)
This commit is contained in:
@@ -0,0 +1,234 @@
|
|||||||
|
import type { Session, User } from 'better-auth'
|
||||||
|
|
||||||
|
import en from '@proj-airi/i18n/locales/en'
|
||||||
|
import OfficialProviderSpeechStreamingPage from '@proj-airi/stage-pages/pages/settings/providers/speech/official-provider-speech-streaming.vue'
|
||||||
|
|
||||||
|
import { errorMessageFrom } from '@moeru/std'
|
||||||
|
import { useAuthStore } from '@proj-airi/stage-ui/stores/auth'
|
||||||
|
import { useProviderConfigStore } from '@proj-airi/stage-ui/stores/providers/config'
|
||||||
|
import { useProviderStore } from '@proj-airi/stage-ui/stores/providers/provider'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { render } from 'vitest-browser-vue'
|
||||||
|
import { createI18n } from 'vue-i18n'
|
||||||
|
import { createMemoryHistory, createRouter, routerKey } from 'vue-router'
|
||||||
|
|
||||||
|
import 'virtual:uno.css'
|
||||||
|
|
||||||
|
const providerId = 'official-provider-speech-streaming'
|
||||||
|
|
||||||
|
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'),
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTestRouter() {
|
||||||
|
return createRouter({
|
||||||
|
history: createMemoryHistory(),
|
||||||
|
routes: [{ path: '/', component: { template: '<div />' } }],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTestI18n() {
|
||||||
|
return createI18n({
|
||||||
|
legacy: false,
|
||||||
|
locale: 'en',
|
||||||
|
messages: { en },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function responseFor(input: RequestInfo | URL) {
|
||||||
|
const url = typeof input === 'string'
|
||||||
|
? input
|
||||||
|
: input instanceof Request
|
||||||
|
? input.url
|
||||||
|
: input.toString()
|
||||||
|
|
||||||
|
if (url.endsWith('/api/v1/flux')) {
|
||||||
|
return new Response(JSON.stringify({ userId: user.id, flux: 42 }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.includes('/api/v1/audio/models/streaming')) {
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
available: true,
|
||||||
|
models: [{ id: 'volcengine/seed-tts-2.0', name: 'Seed TTS 2.0' }],
|
||||||
|
default: 'volcengine/seed-tts-2.0',
|
||||||
|
}), { status: 200, headers: { 'Content-Type': 'application/json' } })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.includes('/api/v1/audio/voices/streaming')) {
|
||||||
|
return new Response(JSON.stringify({ voices: [], recommended: {} }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unexpected request: ${url}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderPage(pinia = createPinia()) {
|
||||||
|
setActivePinia(pinia)
|
||||||
|
const errors: unknown[] = []
|
||||||
|
const router = createTestRouter()
|
||||||
|
|
||||||
|
await render(OfficialProviderSpeechStreamingPage, {
|
||||||
|
global: {
|
||||||
|
config: {
|
||||||
|
errorHandler: error => errors.push(error),
|
||||||
|
},
|
||||||
|
directives: { motion: {} },
|
||||||
|
plugins: [pinia, createTestI18n()],
|
||||||
|
provide: { [routerKey as symbol]: router },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return { errors, pinia }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('official streaming speech provider settings', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
localStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
// https://airi.moeru.ai/settings/providers/speech/official-provider-speech-streaming
|
||||||
|
it('does not request the protected catalog before authentication', async () => {
|
||||||
|
const fetchMock = vi.fn<typeof fetch>(async input => responseFor(input))
|
||||||
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
|
|
||||||
|
const { errors } = await renderPage()
|
||||||
|
|
||||||
|
await expect.poll(() => errors.map(error => errorMessageFrom(error))).toEqual([])
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ROOT CAUSE:
|
||||||
|
//
|
||||||
|
// The provider store stopped creating every provider configuration at startup.
|
||||||
|
// This page still read config.model before it initialized the streaming provider.
|
||||||
|
// A direct page load therefore failed after the model catalog request completed.
|
||||||
|
//
|
||||||
|
// Before: fetch the catalog, then write providerConfig.value.model.
|
||||||
|
//
|
||||||
|
// We fixed this by waiting for authentication and initializing the provider.
|
||||||
|
// The page now applies server availability before it loads model voices.
|
||||||
|
it('initializes provider configuration before it applies the server default model', async () => {
|
||||||
|
const fetchMock = vi.fn<typeof fetch>(async input => responseFor(input))
|
||||||
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
|
const pinia = createPinia()
|
||||||
|
|
||||||
|
const { errors } = await renderPage(pinia)
|
||||||
|
useAuthStore(pinia).$patch({ user, session })
|
||||||
|
const providerConfigStore = useProviderConfigStore(pinia)
|
||||||
|
|
||||||
|
await expect.poll(() => providerConfigStore.getProviderConfig(providerId)?.model).toBe('volcengine/seed-tts-2.0')
|
||||||
|
await expect.poll(() => providerConfigStore.providers[providerId]?.status).toBe('configured')
|
||||||
|
expect(errors.map(error => errorMessageFrom(error))).toEqual([])
|
||||||
|
expect(fetchMock.mock.calls.some(([input]) => responseUrl(input).includes('/api/v1/audio/models/streaming'))).toBe(true)
|
||||||
|
expect(fetchMock.mock.calls.some(([input]) => responseUrl(input).includes('/api/v1/audio/voices/streaming'))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
// https://github.com/moeru-ai/airi/pull/2440#discussion_r3912226728
|
||||||
|
// ROOT CAUSE:
|
||||||
|
//
|
||||||
|
// The Electron settings renderer routes forceProviderConfigured to its
|
||||||
|
// leader. The page enabled its voice watcher without awaiting that action,
|
||||||
|
// so the public voice loader still saw an unconfigured provider and stopped.
|
||||||
|
// The later configuration snapshot did not change any watcher dependency.
|
||||||
|
//
|
||||||
|
// Before: start voice loading while forceProviderConfigured is pending.
|
||||||
|
//
|
||||||
|
// We fixed this by awaiting the configuration action before publishing the
|
||||||
|
// local availability state that enables model-specific voice loading.
|
||||||
|
it('waits for provider configuration before it loads streaming voices', async () => {
|
||||||
|
const fetchMock = vi.fn<typeof fetch>(async input => responseFor(input))
|
||||||
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
|
const pinia = createPinia()
|
||||||
|
|
||||||
|
const { errors } = await renderPage(pinia)
|
||||||
|
const providerStore = useProviderStore(pinia)
|
||||||
|
const forceProviderConfigured = providerStore.forceProviderConfigured
|
||||||
|
let finishConfiguration: (() => void) | undefined
|
||||||
|
const configurationPending = new Promise<void>((resolve) => {
|
||||||
|
finishConfiguration = resolve
|
||||||
|
})
|
||||||
|
vi.spyOn(providerStore, 'forceProviderConfigured').mockImplementation(async (requestedProviderId) => {
|
||||||
|
await configurationPending
|
||||||
|
forceProviderConfigured(requestedProviderId)
|
||||||
|
})
|
||||||
|
|
||||||
|
useAuthStore(pinia).$patch({ user, session })
|
||||||
|
await expect.poll(() => fetchMock.mock.calls.some(([input]) => responseUrl(input).includes('/api/v1/audio/models/streaming'))).toBe(true)
|
||||||
|
expect(fetchMock.mock.calls.some(([input]) => responseUrl(input).includes('/api/v1/audio/voices/streaming'))).toBe(false)
|
||||||
|
|
||||||
|
finishConfiguration?.()
|
||||||
|
await expect.poll(() => fetchMock.mock.calls.some(([input]) => responseUrl(input).includes('/api/v1/audio/voices/streaming'))).toBe(true)
|
||||||
|
expect(errors.map(error => errorMessageFrom(error))).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
// https://github.com/moeru-ai/airi/pull/2440#discussion_r3912777731
|
||||||
|
// ROOT CAUSE:
|
||||||
|
//
|
||||||
|
// Model discovery returns no availability field when its request fails.
|
||||||
|
// The page treated that unknown state as an authoritative unavailable state,
|
||||||
|
// which hid the provider and marked its existing configuration as unconfigured.
|
||||||
|
//
|
||||||
|
// Before: catalog.available === true converted a discovery failure to false.
|
||||||
|
//
|
||||||
|
// We fixed this by changing provider state only when discovery returns an
|
||||||
|
// explicit availability value.
|
||||||
|
it('preserves configured provider state when catalog discovery fails', async () => {
|
||||||
|
const fetchMock = vi.fn<typeof fetch>(async (input) => {
|
||||||
|
if (responseUrl(input).includes('/api/v1/audio/models/streaming'))
|
||||||
|
return new Response('upstream unavailable', { status: 502 })
|
||||||
|
return responseFor(input)
|
||||||
|
})
|
||||||
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
|
const pinia = createPinia()
|
||||||
|
|
||||||
|
const { errors } = await renderPage(pinia)
|
||||||
|
const providerStore = useProviderStore(pinia)
|
||||||
|
const providerConfigStore = useProviderConfigStore(pinia)
|
||||||
|
providerConfigStore.ensureProvider(providerId, providerId, { model: 'volcengine/seed-tts-2.0' })
|
||||||
|
providerConfigStore.setProviderStatus(providerId, 'configured')
|
||||||
|
providerConfigStore.markProviderAdded(providerId)
|
||||||
|
const setProviderUnconfigured = vi.spyOn(providerStore, 'setProviderUnconfigured')
|
||||||
|
const setProviderAvailabilityOverride = vi.spyOn(providerStore, 'setProviderAvailabilityOverride')
|
||||||
|
|
||||||
|
useAuthStore(pinia).$patch({ user, session })
|
||||||
|
await expect.poll(() => providerStore.modelLoadError[providerId]).toContain('streaming models upstream 502')
|
||||||
|
|
||||||
|
expect(setProviderAvailabilityOverride).not.toHaveBeenCalledWith(providerId, false)
|
||||||
|
expect(setProviderUnconfigured).not.toHaveBeenCalledWith(providerId)
|
||||||
|
expect(providerConfigStore.providers[providerId]?.status).toBe('configured')
|
||||||
|
expect(errors.map(error => errorMessageFrom(error))).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function responseUrl(input: RequestInfo | URL): string {
|
||||||
|
if (typeof input === 'string')
|
||||||
|
return input
|
||||||
|
if (input instanceof Request)
|
||||||
|
return input.url
|
||||||
|
return input.toString()
|
||||||
|
}
|
||||||
@@ -234,8 +234,9 @@ export default defineConfig({
|
|||||||
fullInstall: true,
|
fullInstall: true,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// https://github.com/webfansplz/vite-plugin-vue-devtools
|
// Browser tests mount short-lived apps. A delayed DevTools setup can run
|
||||||
VueDevTools(),
|
// after the test app is gone, so only load this plugin for the real app.
|
||||||
|
...(env.VITEST ? [] : [VueDevTools()]),
|
||||||
|
|
||||||
DownloadLive2DSDK(),
|
DownloadLive2DSDK(),
|
||||||
Download('https://dist.ayaka.moe/live2d-models/hiyori_free_zh.zip', 'hiyori_free_zh.zip', 'live2d/models', { parentDir: stageUIAssetsRoot, cacheDir: sharedCacheDir }),
|
Download('https://dist.ayaka.moe/live2d-models/hiyori_free_zh.zip', 'hiyori_free_zh.zip', 'live2d/models', { parentDir: stageUIAssetsRoot, cacheDir: sharedCacheDir }),
|
||||||
|
|||||||
+57
-17
@@ -5,7 +5,7 @@ import {
|
|||||||
ProviderSettingsLayout,
|
ProviderSettingsLayout,
|
||||||
SpeechPlayground,
|
SpeechPlayground,
|
||||||
} from '@proj-airi/stage-ui/components'
|
} 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 { useAuthStore } from '@proj-airi/stage-ui/stores/auth'
|
||||||
import { useSpeechStore } from '@proj-airi/stage-ui/stores/modules/speech'
|
import { useSpeechStore } from '@proj-airi/stage-ui/stores/modules/speech'
|
||||||
import { useProviderConfigStore } from '@proj-airi/stage-ui/stores/providers/config'
|
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 { Callout, ComboboxSelect } from '@proj-airi/ui'
|
||||||
import { computedAsync } from '@vueuse/core'
|
import { computedAsync } from '@vueuse/core'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
@@ -42,12 +42,15 @@ const providerConfig = computed(() => providerStore.getProviderConfig(providerId
|
|||||||
const providerModels = computed(() => providersStore.getModelsForProvider(providerId))
|
const providerModels = computed(() => providersStore.getModelsForProvider(providerId))
|
||||||
const modelsLoading = computed(() => providersStore.isLoadingModels[providerId] || false)
|
const modelsLoading = computed(() => providersStore.isLoadingModels[providerId] || false)
|
||||||
const serverDefaultModel = ref<string | null>(null)
|
const serverDefaultModel = ref<string | null>(null)
|
||||||
|
const streamingAvailable = ref(false)
|
||||||
const model = computed({
|
const model = computed({
|
||||||
get(): string {
|
get(): string {
|
||||||
return (providerConfig.value?.model as string | undefined) ?? serverDefaultModel.value ?? ''
|
return (providerConfig.value?.model as string | undefined) ?? serverDefaultModel.value ?? ''
|
||||||
},
|
},
|
||||||
set(val: string) {
|
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 })))
|
const modelOptions = computed(() => providerModels.value.map(m => ({ label: m.name, value: m.id })))
|
||||||
@@ -65,24 +68,61 @@ async function loadVoices() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
watch(isAuthenticated, async (authenticated, _, onCleanup) => {
|
||||||
await providersStore.fetchModelsForProvider(providerId)
|
let active = true
|
||||||
// `getDefaultStreamingModel()` is populated by the provider's listModels()
|
onCleanup(() => active = false)
|
||||||
// (just ran via fetchModelsForProvider). If the operator hasn't curated a
|
streamingAvailable.value = false
|
||||||
// default server-side, fall back to the first model the server returned
|
serverDefaultModel.value = null
|
||||||
// so the picker always has something selected.
|
if (!authenticated)
|
||||||
serverDefaultModel.value = getDefaultStreamingModel() ?? providerModels.value[0]?.id ?? null
|
return
|
||||||
if (!providerConfig.value.model && serverDefaultModel.value)
|
|
||||||
providerConfig.value.model = serverDefaultModel.value
|
await providersStore.initializeProvider(providerId)
|
||||||
await loadVoices()
|
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
|
// 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
|
// vs uranus/saturn; see unspeech voices.go). Re-fetch on model change so the
|
||||||
// list switches accordingly.
|
// list switches accordingly.
|
||||||
watch(model, async () => {
|
watch([isAuthenticated, streamingAvailable, model], async ([authenticated, available, selectedModel]) => {
|
||||||
|
if (!authenticated || !available || !selectedModel)
|
||||||
|
return
|
||||||
await loadVoices()
|
await loadVoices()
|
||||||
})
|
}, { immediate: true })
|
||||||
|
|
||||||
// Synthesize via the streaming session helper. The page uses the SAME
|
// Synthesize via the streaming session helper. The page uses the SAME
|
||||||
// transport the runtime pipeline uses (ws → API proxy → unspeech
|
// transport the runtime pipeline uses (ws → API proxy → unspeech
|
||||||
@@ -184,7 +224,7 @@ function handleLogin() {
|
|||||||
<ComboboxSelect
|
<ComboboxSelect
|
||||||
v-model="model"
|
v-model="model"
|
||||||
:options="modelOptions"
|
:options="modelOptions"
|
||||||
:disabled="modelsLoading"
|
:disabled="modelsLoading || !providerConfig"
|
||||||
placeholder="Choose a model..."
|
placeholder="Choose a model..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ import './voicevox'
|
|||||||
|
|
||||||
export {
|
export {
|
||||||
getDefaultStreamingModel,
|
getDefaultStreamingModel,
|
||||||
getStreamingTtsAvailable,
|
|
||||||
OFFICIAL_TRANSCRIPTION_PROVIDER_ID,
|
OFFICIAL_TRANSCRIPTION_PROVIDER_ID,
|
||||||
} from './official'
|
} from './official'
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { Ref, WatchSource } from 'vue'
|
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 { z } from 'zod'
|
||||||
|
|
||||||
import { getAuthToken } from '../../../../libs/auth'
|
import { getAuthToken } from '../../../../libs/auth'
|
||||||
@@ -42,18 +42,6 @@ export function getDefaultStreamingModel(): string | null {
|
|||||||
return defaultStreamingModelId
|
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({})
|
const officialConfigSchema = z.object({})
|
||||||
|
|
||||||
function authHeaders(): Record<string, string> {
|
function authHeaders(): Record<string, string> {
|
||||||
@@ -64,6 +52,36 @@ function authHeaders(): Record<string, string> {
|
|||||||
return headers
|
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({
|
export const providerOfficialChat = defineProvider({
|
||||||
id: OFFICIAL_CHAT_PROVIDER_ID,
|
id: OFFICIAL_CHAT_PROVIDER_ID,
|
||||||
order: -1,
|
order: -1,
|
||||||
@@ -257,36 +275,8 @@ export const providerOfficialSpeechStreaming = defineProvider({
|
|||||||
},
|
},
|
||||||
validationRequiredWhen: () => false,
|
validationRequiredWhen: () => false,
|
||||||
extraMethods: {
|
extraMethods: {
|
||||||
listModels: async (): Promise<ModelInfo[]> => {
|
listModelCatalog: listStreamingModelCatalog,
|
||||||
// Streaming TTS catalog is operator-controlled via configKV
|
listModels: async () => (await listStreamingModelCatalog()).models,
|
||||||
// (`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,
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
listVoices: async (_config, _provider, model): Promise<VoiceInfo[]> => {
|
listVoices: async (_config, _provider, model): Promise<VoiceInfo[]> => {
|
||||||
// Streaming voices live behind a dedicated endpoint
|
// Streaming voices live behind a dedicated endpoint
|
||||||
// (`/audio/voices/streaming`) because they come from the
|
// (`/audio/voices/streaming`) because they come from the
|
||||||
|
|||||||
@@ -74,7 +74,18 @@ export interface ProviderConfigContext<TConfig> {
|
|||||||
t: ComposerTranslation
|
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> {
|
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[]>
|
listModels?: (config: TConfig, provider: ProviderInstance, contextOptions?: { t: (input: string) => string }) => Promise<ModelInfo[]>
|
||||||
/**
|
/**
|
||||||
* Returns the voice catalogue. `model` lets providers whose voices vary by
|
* 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 definition = getProviderDefinition(providerId)
|
||||||
const provider = await definition.createProvider(config)
|
const provider = await definition.createProvider(config)
|
||||||
try {
|
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) {
|
if (definition.extraMethods?.listModels) {
|
||||||
const models = await definition.extraMethods.listModels(config, provider, { t })
|
const models = await definition.extraMethods.listModels(config, provider, { t })
|
||||||
return normalizeProviderModels(providerId, models)
|
return { models: normalizeProviderModels(providerId, models) }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isModelProvider(provider))
|
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 baseUrl = typeof config.baseUrl === 'string' ? config.baseUrl.trim() : ''
|
||||||
const apiKey = typeof config.apiKey === 'string' ? config.apiKey.trim() : ''
|
const apiKey = typeof config.apiKey === 'string' ? config.apiKey.trim() : ''
|
||||||
if (!baseUrl)
|
if (!baseUrl)
|
||||||
return []
|
return { models: [] }
|
||||||
|
|
||||||
return normalizeProviderModels(providerId, await listModels({
|
return {
|
||||||
baseURL: baseUrl,
|
models: normalizeProviderModels(providerId, await listModels({
|
||||||
...(apiKey ? { apiKey } : {}),
|
baseURL: baseUrl,
|
||||||
}))
|
...(apiKey ? { apiKey } : {}),
|
||||||
|
})),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
await disposeTemporaryProvider(provider)
|
await disposeTemporaryProvider(provider)
|
||||||
@@ -603,11 +613,11 @@ export const useProviderStore = defineStore('provider', () => {
|
|||||||
async function fetchModelsForProvider(providerId: string) {
|
async function fetchModelsForProvider(providerId: string) {
|
||||||
const definition = findProviderDefinition(providerId)
|
const definition = findProviderDefinition(providerId)
|
||||||
if (!definition)
|
if (!definition)
|
||||||
return []
|
return { models: [] }
|
||||||
|
|
||||||
const config = providerCredentials.value[providerId]
|
const config = providerCredentials.value[providerId]
|
||||||
if (!config && definition.requiresCredentials !== false)
|
if (!config && definition.requiresCredentials !== false)
|
||||||
return []
|
return { models: [] }
|
||||||
|
|
||||||
initializeProviderRuntimeState(providerId)
|
initializeProviderRuntimeState(providerId)
|
||||||
providerRuntimeState.value = {
|
providerRuntimeState.value = {
|
||||||
@@ -620,8 +630,8 @@ export const useProviderStore = defineStore('provider', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const models = await listProviderModels(providerId, config || {})
|
const catalog = await listProviderModels(providerId, config || {})
|
||||||
const normalizedModels = uniqBy(models.filter(model => !!model.id), m => m.id)
|
const normalizedModels = uniqBy(catalog.models.filter(model => !!model.id), m => m.id)
|
||||||
.map(model => ({
|
.map(model => ({
|
||||||
id: model.id,
|
id: model.id,
|
||||||
name: model.name,
|
name: model.name,
|
||||||
@@ -646,11 +656,15 @@ export const useProviderStore = defineStore('provider', () => {
|
|||||||
modelError: null,
|
modelError: null,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
// Synced action results pass through structuredClone. Return the local
|
// Synced action results pass through structuredClone. Return local
|
||||||
// array because reading the same array from state returns a Vue proxy.
|
// catalog values because reading models back from state returns a Vue
|
||||||
return normalizedModels
|
// proxy and provider-specific metadata is not part of synced state.
|
||||||
|
return {
|
||||||
|
...catalog,
|
||||||
|
models: normalizedModels,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return []
|
return { models: [] }
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
console.error(`Error fetching models for ${providerId}:`, error)
|
console.error(`Error fetching models for ${providerId}:`, error)
|
||||||
@@ -665,7 +679,7 @@ export const useProviderStore = defineStore('provider', () => {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return []
|
return { models: [] }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user