fix(web): hide the voice button when no voice backend is configured (#1317)

* chore: hide voice button when no voice backend configured (not deployed)

* fix(hub,web): handle unavailable voice backends

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

Co-Authored-By: HAPI <noreply@hapi.run>

---------

Co-authored-by: HAPI <noreply@hapi.run>
This commit is contained in:
2026-08-03 18:05:49 +08:00
committed by GitHub
co-authored by HAPI
parent 2be5a07aae
commit b20bda87f1
9 changed files with 84 additions and 22 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 () => { test('returns elevenlabs by default with backends list', async () => {
delete process.env.VOICE_BACKEND delete process.env.VOICE_BACKEND
delete process.env.GEMINI_API_KEY delete process.env.GEMINI_API_KEY
+10 -2
View File
@@ -38,8 +38,8 @@ describe('listConfiguredVoiceBackends', () => {
expect(backends).toEqual(['elevenlabs', 'gemini-live', 'qwen-realtime']) expect(backends).toEqual(['elevenlabs', 'gemini-live', 'qwen-realtime'])
}) })
test('falls back to elevenlabs when no keys configured', () => { test('returns empty when no keys configured', () => {
expect(listConfiguredVoiceBackends({})).toEqual(['elevenlabs']) expect(listConfiguredVoiceBackends({})).toEqual([])
}) })
}) })
@@ -67,6 +67,10 @@ describe('resolveHubVoiceBackend', () => {
}) })
expect(backend).toBe('elevenlabs') expect(backend).toBe('elevenlabs')
}) })
test('returns null when no backends configured', () => {
expect(resolveHubVoiceBackend({})).toBeNull()
})
}) })
describe('resolveEffectiveVoiceBackend', () => { describe('resolveEffectiveVoiceBackend', () => {
@@ -80,4 +84,8 @@ describe('resolveEffectiveVoiceBackend', () => {
expect(resolveEffectiveVoiceBackend(configured, 'gemini-live', null)).toBe('gemini-live') expect(resolveEffectiveVoiceBackend(configured, 'gemini-live', null)).toBe('gemini-live')
expect(resolveEffectiveVoiceBackend(configured, 'gemini-live', 'qwen-realtime')).toBe('gemini-live') expect(resolveEffectiveVoiceBackend(configured, 'gemini-live', 'qwen-realtime')).toBe('gemini-live')
}) })
test('returns null when no backends configured', () => {
expect(resolveEffectiveVoiceBackend([], null, null)).toBeNull()
})
}) })
+14 -8
View File
@@ -310,25 +310,31 @@ export function listConfiguredVoiceBackends(env: VoiceBackendEnv): VoiceBackendT
if (env.DASHSCOPE_API_KEY?.trim() || env.QWEN_API_KEY?.trim()) { if (env.DASHSCOPE_API_KEY?.trim() || env.QWEN_API_KEY?.trim()) {
backends.push('qwen-realtime') backends.push('qwen-realtime')
} }
return backends.length > 0 ? backends : [DEFAULT_VOICE_BACKEND] return backends
} }
/** Hub default from VOICE_BACKEND when configured, else first available backend. */ /** Hub default from VOICE_BACKEND when configured, else first available backend. null when none configured. */
export function resolveHubVoiceBackend(env: VoiceBackendEnv): VoiceBackendType { export function resolveHubVoiceBackend(env: VoiceBackendEnv): VoiceBackendType | null {
const configured = listConfiguredVoiceBackends(env) const configured = listConfiguredVoiceBackends(env)
if (configured.length === 0) {
return null
}
const raw = env.VOICE_BACKEND const raw = env.VOICE_BACKEND
const fromEnv = VOICE_BACKEND_VALUES.includes(raw as VoiceBackendType) const fromEnv = VOICE_BACKEND_VALUES.includes(raw as VoiceBackendType)
? (raw as VoiceBackendType) ? (raw as VoiceBackendType)
: DEFAULT_VOICE_BACKEND : DEFAULT_VOICE_BACKEND
return configured.includes(fromEnv) ? fromEnv : (configured[0] ?? DEFAULT_VOICE_BACKEND) return configured.includes(fromEnv) ? fromEnv : configured[0]!
} }
/** User preference wins when valid; otherwise hub default. */ /** User preference wins when valid; otherwise hub default. */
export function resolveEffectiveVoiceBackend( export function resolveEffectiveVoiceBackend(
configured: readonly VoiceBackendType[], configured: readonly VoiceBackendType[],
hubDefault: VoiceBackendType, hubDefault: VoiceBackendType | null,
storedPreference: string | null | undefined storedPreference: string | null | undefined
): VoiceBackendType { ): VoiceBackendType | null {
if (configured.length === 0) {
return null
}
if ( if (
storedPreference storedPreference
&& VOICE_BACKEND_VALUES.includes(storedPreference as VoiceBackendType) && VOICE_BACKEND_VALUES.includes(storedPreference as VoiceBackendType)
@@ -336,10 +342,10 @@ export function resolveEffectiveVoiceBackend(
) { ) {
return storedPreference as VoiceBackendType return storedPreference as VoiceBackendType
} }
if (configured.includes(hubDefault)) { if (hubDefault && configured.includes(hubDefault)) {
return hubDefault return hubDefault
} }
return configured[0] ?? hubDefault return configured[0] ?? null
} }
export const GEMINI_LIVE_MODEL = 'gemini-2.5-flash-native-audio-latest' export const GEMINI_LIVE_MODEL = 'gemini-2.5-flash-native-audio-latest'
+10
View File
@@ -138,4 +138,14 @@ describe('ApiClient error mapping', () => {
expect(init?.body).toBeInstanceOf(FormData) expect(init?.body).toBeInstanceOf(FormData)
expect(new Headers(init?.headers).has('content-type')).toBe(false) 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
@@ -963,7 +963,7 @@ export class ApiClient {
return this.getToken ? this.getToken() : this.token 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') return await this.request('/api/voice/backend')
} }
+4 -4
View File
@@ -205,7 +205,7 @@ export async function fetchQwenToken(api: ApiClient): Promise<QwenTokenResponse>
export interface VoiceBackendResponse { export interface VoiceBackendResponse {
/** Hub default (VOICE_BACKEND env, validated against configured backends). */ /** Hub default (VOICE_BACKEND env, validated against configured backends). */
backend: VoiceBackendType backend: VoiceBackendType | null
/** Backends with API keys configured on the hub. */ /** Backends with API keys configured on the hub. */
backends: VoiceBackendType[] backends: VoiceBackendType[]
} }
@@ -229,12 +229,12 @@ function isVoiceBackendType(value: string): value is VoiceBackendType {
export async function fetchVoiceBackend(api: ApiClient): Promise<VoiceBackendResponse> { export async function fetchVoiceBackend(api: ApiClient): Promise<VoiceBackendResponse> {
const result = await api.fetchVoiceBackend() const result = await api.fetchVoiceBackend()
const { backend } = result const { backend } = result
if (!isVoiceBackendType(backend)) { if (backend !== null && !isVoiceBackendType(backend)) {
throw new Error(`Unrecognised voice backend: ${backend}`) throw new Error(`Unrecognised voice backend: ${backend}`)
} }
const rawBackends = Array.isArray(result.backends) ? result.backends : [backend] const rawBackends = Array.isArray(result.backends) ? result.backends : backend !== null ? [backend] : []
const backends = rawBackends.filter(isVoiceBackendType) const backends = rawBackends.filter(isVoiceBackendType)
if (backends.length === 0) { if (backend !== null && backends.length === 0) {
backends.push(backend) backends.push(backend)
} }
return { backend, backends } return { backend, backends }
+2 -2
View File
@@ -58,8 +58,8 @@ export function writeStoredVoiceBackendPreference(backend: VoiceBackendType): vo
export function resolveSelectedVoiceBackend( export function resolveSelectedVoiceBackend(
configured: readonly VoiceBackendType[], configured: readonly VoiceBackendType[],
hubDefault: VoiceBackendType hubDefault: VoiceBackendType | null
): VoiceBackendType { ): VoiceBackendType | null {
return resolveEffectiveVoiceBackend( return resolveEffectiveVoiceBackend(
configured, configured,
hubDefault, hubDefault,
+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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { VoiceBackendSession } from '@/realtime/VoiceBackendSession' import { VoiceBackendSession } from '@/realtime/VoiceBackendSession'
import type { ApiClient } from '@/api/client' import type { ApiClient } from '@/api/client'
@@ -10,15 +10,15 @@ vi.mock('@/api/voice', () => ({
})) }))
vi.mock('@/realtime/RealtimeVoiceSession', () => ({ vi.mock('@/realtime/RealtimeVoiceSession', () => ({
RealtimeVoiceSession: () => null, RealtimeVoiceSession: () => <div data-testid="elevenlabs-session" />,
})) }))
vi.mock('@/realtime/GeminiLiveVoiceSession', () => ({ vi.mock('@/realtime/GeminiLiveVoiceSession', () => ({
GeminiLiveVoiceSession: () => null, GeminiLiveVoiceSession: () => <div data-testid="gemini-session" />,
})) }))
vi.mock('@/realtime/QwenVoiceSession', () => ({ vi.mock('@/realtime/QwenVoiceSession', () => ({
QwenVoiceSession: () => null, QwenVoiceSession: () => <div data-testid="qwen-session" />,
})) }))
const api = {} as ApiClient const api = {} as ApiClient
@@ -48,6 +48,29 @@ describe('VoiceBackendSession', () => {
consoleError.mockRestore() 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 () => { it('reports the detected backend as ready', async () => {
fetchVoiceBackendMock.mockResolvedValue({ backend: 'elevenlabs', backends: ['elevenlabs'] }) fetchVoiceBackendMock.mockResolvedValue({ backend: 'elevenlabs', backends: ['elevenlabs'] })
const onStatusChange = vi.fn() const onStatusChange = vi.fn()
+1 -1
View File
@@ -33,7 +33,7 @@ export function useVoiceSettings() {
setConfiguredBackends(response.backends) setConfiguredBackends(response.backends)
const selected = resolveSelectedVoiceBackend(response.backends, response.backend) const selected = resolveSelectedVoiceBackend(response.backends, response.backend)
setBackendState(selected) setBackendState(selected)
setVoiceIdState(readStoredVoiceSelection(selected)) setVoiceIdState(selected ? readStoredVoiceSelection(selected) : null)
}).catch(() => { }).catch(() => {
if (cancelled) return if (cancelled) return
setConfiguredBackends(['elevenlabs']) setConfiguredBackends(['elevenlabs'])