fix(hub,web): handle unavailable voice backends

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
This commit is contained in:
2026-08-03 15:13:36 +08:00
co-authored by HAPI
parent 966c667597
commit ecee2ab4c2
4 changed files with 53 additions and 5 deletions
+15
View File
@@ -488,6 +488,21 @@ describe('GET /api/voice/backend', () => {
}
})
test('returns no backend when no voice credentials are configured', async () => {
delete process.env.VOICE_BACKEND
delete process.env.ELEVENLABS_API_KEY
delete process.env.GEMINI_API_KEY
delete process.env.GOOGLE_API_KEY
delete process.env.DASHSCOPE_API_KEY
delete process.env.QWEN_API_KEY
const app = createApp()
const headers = await authHeaders()
const res = await app.request('/api/voice/backend', { headers })
expect(res.status).toBe(200)
const body = await res.json() as { backend: string | null; backends: string[] }
expect(body).toEqual({ backend: null, backends: [] })
})
test('returns elevenlabs by default with backends list', async () => {
delete process.env.VOICE_BACKEND
delete process.env.GEMINI_API_KEY
+10
View File
@@ -127,4 +127,14 @@ describe('ApiClient error mapping', () => {
expect(init?.body).toBeInstanceOf(FormData)
expect(new Headers(init?.headers).has('content-type')).toBe(false)
})
it('preserves an unavailable voice backend response', async () => {
fetchMock.mockResolvedValueOnce(
new Response(JSON.stringify({ backend: null, backends: [] }), { status: 200 })
)
const api = new ApiClient('test-token')
await expect(api.fetchVoiceBackend()).resolves.toEqual({ backend: null, backends: [] })
expect(fetchMock.mock.calls[0]?.[0]).toBe('/api/voice/backend')
})
})
+1 -1
View File
@@ -951,7 +951,7 @@ export class ApiClient {
return this.getToken ? this.getToken() : this.token
}
async fetchVoiceBackend(): Promise<{ backend: string; backends: string[] }> {
async fetchVoiceBackend(): Promise<{ backend: string | null; backends: string[] }> {
return await this.request('/api/voice/backend')
}
+27 -4
View File
@@ -1,4 +1,4 @@
import { cleanup, render, waitFor } from '@testing-library/react'
import { act, cleanup, render, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { VoiceBackendSession } from '@/realtime/VoiceBackendSession'
import type { ApiClient } from '@/api/client'
@@ -10,15 +10,15 @@ vi.mock('@/api/voice', () => ({
}))
vi.mock('@/realtime/RealtimeVoiceSession', () => ({
RealtimeVoiceSession: () => null,
RealtimeVoiceSession: () => <div data-testid="elevenlabs-session" />,
}))
vi.mock('@/realtime/GeminiLiveVoiceSession', () => ({
GeminiLiveVoiceSession: () => null,
GeminiLiveVoiceSession: () => <div data-testid="gemini-session" />,
}))
vi.mock('@/realtime/QwenVoiceSession', () => ({
QwenVoiceSession: () => null,
QwenVoiceSession: () => <div data-testid="qwen-session" />,
}))
const api = {} as ApiClient
@@ -48,6 +48,29 @@ describe('VoiceBackendSession', () => {
consoleError.mockRestore()
})
it('does not mount a voice session when no backend is configured', async () => {
fetchVoiceBackendMock.mockResolvedValue({ backend: null, backends: [] })
const onReadyChange = vi.fn()
const view = render(
<VoiceBackendSession
api={api}
micMuted={false}
onStatusChange={vi.fn()}
onReadyChange={onReadyChange}
/>
)
await act(async () => {
await Promise.resolve()
})
expect(fetchVoiceBackendMock).toHaveBeenCalledWith(api)
expect(view.queryByTestId('elevenlabs-session')).toBeNull()
expect(view.queryByTestId('gemini-session')).toBeNull()
expect(view.queryByTestId('qwen-session')).toBeNull()
expect(onReadyChange).not.toHaveBeenCalled()
})
it('reports the detected backend as ready', async () => {
fetchVoiceBackendMock.mockResolvedValue({ backend: 'elevenlabs', backends: ['elevenlabs'] })
const onStatusChange = vi.fn()