diff --git a/apps/sim/hooks/queries/chats.test.tsx b/apps/sim/hooks/queries/chats.test.tsx index 03cdf31dd18..c485fc1aa6f 100644 --- a/apps/sim/hooks/queries/chats.test.tsx +++ b/apps/sim/hooks/queries/chats.test.tsx @@ -5,7 +5,7 @@ import { act, type ReactNode } from 'react' import { sleep } from '@sim/utils/helpers' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { createRoot, type Root } from 'react-dom/client' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { mockRequestJson, mockInvalidateDeploymentQueries } = vi.hoisted(() => ({ mockRequestJson: vi.fn(), @@ -23,11 +23,15 @@ vi.mock('@/hooks/queries/deployments', async (importOriginal) => ({ import { useCreateChat, useUpdateChat } from '@/hooks/queries/chats' +/** Trees rendered by a test, torn down in afterEach so observers do not leak across tests. */ +const mountedRoots: Root[] = [] + function renderHookWithClient(useHook: () => T): { getResult: () => T } { ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) const container = document.createElement('div') const root: Root = createRoot(container) + mountedRoots.push(root) let result: T | undefined function Probe() { @@ -71,6 +75,12 @@ const FORM_DATA = { includeToolCalls: false, } +afterEach(() => { + act(() => { + for (const root of mountedRoots.splice(0)) root.unmount() + }) +}) + beforeEach(() => { vi.clearAllMocks() mockRequestJson.mockResolvedValue({ chatUrl: 'https://sim.ai/chat/my-chat', chatId: 'chat-1' }) diff --git a/apps/sim/hooks/queries/voice.test.tsx b/apps/sim/hooks/queries/voice.test.tsx new file mode 100644 index 00000000000..994963e812a --- /dev/null +++ b/apps/sim/hooks/queries/voice.test.tsx @@ -0,0 +1,143 @@ +/** + * @vitest-environment jsdom + */ +import { act, type ReactNode } from 'react' +import { sleep } from '@sim/utils/helpers' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockRequestJson } = vi.hoisted(() => ({ mockRequestJson: vi.fn() })) + +vi.mock('@/lib/api/client/request', () => ({ requestJson: mockRequestJson })) + +import { useVoiceSettings, voiceSettingsKeys } from '@/hooks/queries/voice' + +/** Trees rendered by a test, torn down in afterEach so observers do not leak across tests. */ +const mountedRoots: Root[] = [] + +function renderHookWithClient( + useHook: () => T, + queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) +): { getResult: () => T } { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const container = document.createElement('div') + const root: Root = createRoot(container) + mountedRoots.push(root) + let result: T | undefined + + function Probe() { + result = useHook() + return null + } + + act(() => { + root.render( + {() as ReactNode} + ) + }) + + return { + getResult: () => { + if (result === undefined) throw new Error('Hook result is not ready') + return result + }, + } +} + +async function flush() { + await act(async () => { + for (let i = 0; i < 5; i++) { + await Promise.resolve() + await sleep(1) + } + }) +} + +afterEach(() => { + act(() => { + for (const root of mountedRoots.splice(0)) root.unmount() + }) +}) + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('useVoiceSettings', () => { + it('keys the query under the voiceSettings namespace', () => { + expect(voiceSettingsKeys.settings()).toEqual(['voiceSettings', 'settings']) + }) + + it('reports availability from the server response', async () => { + mockRequestJson.mockResolvedValue({ sttAvailable: true }) + + const { getResult } = renderHookWithClient(() => useVoiceSettings()) + await flush() + + expect(getResult().data).toBe(true) + expect(mockRequestJson).toHaveBeenCalledTimes(1) + }) + + /** + * Consumers gate on a browser capability; a client that cannot stream audio + * should never issue the request at all. + */ + it('issues no request when disabled', async () => { + mockRequestJson.mockResolvedValue({ sttAvailable: true }) + + const { getResult } = renderHookWithClient(() => useVoiceSettings({ enabled: false })) + await flush() + + expect(mockRequestJson).not.toHaveBeenCalled() + expect(getResult().data).toBeUndefined() + }) + + /** A failed capability probe must read as unavailable, not throw. */ + it('leaves data undefined when the request fails', async () => { + mockRequestJson.mockRejectedValue(new Error('offline')) + + const { getResult } = renderHookWithClient(() => useVoiceSettings()) + await flush() + + expect(getResult().data).toBeUndefined() + expect(getResult().isError).toBe(true) + }) + + /** + * The app's QueryClient sets `retryOnMount: false`, and an infinite staleTime + * never goes stale, so without an explicit override a single transient + * failure would cache the error for the life of the client and keep the mic + * hidden until a full reload. + */ + it('recovers on a later mount after a failed probe, under the app query defaults', async () => { + const appDefaults = new QueryClient({ + defaultOptions: { queries: { retry: false, retryOnMount: false, staleTime: 30 * 1000 } }, + }) + + mockRequestJson.mockRejectedValueOnce(new Error('offline')) + renderHookWithClient(() => useVoiceSettings(), appDefaults) + await flush() + expect(mockRequestJson).toHaveBeenCalledTimes(1) + + mockRequestJson.mockResolvedValue({ sttAvailable: true }) + const second = renderHookWithClient(() => useVoiceSettings(), appDefaults) + await flush() + + expect(mockRequestJson).toHaveBeenCalledTimes(2) + expect(second.getResult().data).toBe(true) + }) + + it('dedupes across simultaneous consumers', async () => { + mockRequestJson.mockResolvedValue({ sttAvailable: true }) + + renderHookWithClient(() => { + useVoiceSettings() + useVoiceSettings() + return null + }) + await flush() + + expect(mockRequestJson).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/sim/hooks/queries/voice.ts b/apps/sim/hooks/queries/voice.ts new file mode 100644 index 00000000000..065821344f3 --- /dev/null +++ b/apps/sim/hooks/queries/voice.ts @@ -0,0 +1,49 @@ +import { useQuery } from '@tanstack/react-query' +import { requestJson } from '@/lib/api/client/request' +import { getVoiceSettingsContract } from '@/lib/api/contracts' + +/** + * Query key factory for voice capability queries + */ +export const voiceSettingsKeys = { + all: ['voiceSettings'] as const, + settings: () => [...voiceSettingsKeys.all, 'settings'] as const, +} + +/** + * `/api/settings/voice` reports whether the server has an STT provider + * configured, which is read from env at request time and so cannot change + * within a session. + */ +export const VOICE_SETTINGS_STALE_TIME = Number.POSITIVE_INFINITY + +async function fetchSttAvailable(signal?: AbortSignal): Promise { + const data = await requestJson(getVoiceSettingsContract, { signal }) + return data.sttAvailable === true +} + +/** + * Loads whether server-side speech-to-text is configured. + * + * `enabled` is caller-controlled so consumers gated on a browser capability + * skip the request entirely on clients that could not use STT anyway. + * + * Deliberately no `initialData`: consumers derive their support flag from + * `data === true`, so the first client render matches the server render + * (unavailable) until the fetch resolves. + * + * `retryOnMount` overrides the app default of `false`. An infinite staleTime + * never goes stale, and `refetchOnWindowFocus` only refetches stale queries, so + * without this a single transient failure would cache the error for the life of + * the QueryClient and hide the mic until a full reload. Retrying per mount + * matches the effect this replaced, which refetched every time it ran. + */ +export function useVoiceSettings(options?: { enabled?: boolean }) { + return useQuery({ + queryKey: voiceSettingsKeys.settings(), + queryFn: ({ signal }) => fetchSttAvailable(signal), + enabled: options?.enabled ?? true, + staleTime: VOICE_SETTINGS_STALE_TIME, + retryOnMount: true, + }) +} diff --git a/apps/sim/hooks/use-speech-to-text.ts b/apps/sim/hooks/use-speech-to-text.ts index f6d7cd23893..6a1bcb2f726 100644 --- a/apps/sim/hooks/use-speech-to-text.ts +++ b/apps/sim/hooks/use-speech-to-text.ts @@ -4,7 +4,6 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { isApiClientError } from '@/lib/api/client/errors' import { requestJson } from '@/lib/api/client/request' -import { getVoiceSettingsContract } from '@/lib/api/contracts/common' import { speechTokenContract } from '@/lib/api/contracts/media/speech' import { arrayBufferToBase64, floatTo16BitPCM } from '@/lib/speech/audio' import { @@ -13,6 +12,7 @@ import { MAX_SESSION_MS, SAMPLE_RATE, } from '@/lib/speech/config' +import { useVoiceSettings } from '@/hooks/queries/voice' const logger = createLogger('useSpeechToText') @@ -40,7 +40,18 @@ export function useSpeechToText({ workspaceId, }: UseSpeechToTextProps): UseSpeechToTextReturn { const [isListening, setIsListening] = useState(false) - const [isSupported, setIsSupported] = useState(false) + /** + * Gate the capability request on the browser APIs streaming needs, so clients + * that could not use STT anyway never issue it. + */ + const browserSupportsAudioCapture = + typeof window !== 'undefined' && + typeof AudioContext !== 'undefined' && + typeof WebSocket !== 'undefined' && + typeof navigator?.mediaDevices?.getUserMedia === 'function' + + const { data: sttAvailable } = useVoiceSettings({ enabled: browserSupportsAudioCapture }) + const isSupported = browserSupportsAudioCapture && sttAvailable === true const onTranscriptRef = useRef(onTranscript) const onUsageLimitExceededRef = useRef(onUsageLimitExceeded) @@ -64,27 +75,6 @@ export function useSpeechToText({ onUsageLimitExceededRef.current = onUsageLimitExceeded workspaceIdRef.current = workspaceId - useEffect(() => { - const browserOk = - typeof window !== 'undefined' && - typeof AudioContext !== 'undefined' && - typeof WebSocket !== 'undefined' && - typeof navigator?.mediaDevices?.getUserMedia === 'function' - - if (!browserOk) { - setIsSupported(false) - return - } - - requestJson(getVoiceSettingsContract, {}) - .then((data) => { - if (mountedRef.current) setIsSupported(data.sttAvailable === true) - }) - .catch(() => { - if (mountedRef.current) setIsSupported(false) - }) - }, []) - const flushAudioBuffer = useCallback(() => { const ws = wsRef.current if (!ws || ws.readyState !== WebSocket.OPEN) return