Download frontend/src/ui/call/useCallSession.js from ruslanmv/HomePilot: direct link, hf CLI and curl.
- Browser
- Download file 15.2 kB
-
https://huggingface.co/spaces/ruslanmv/HomePilot/resolve/main/frontend/src/ui/call/useCallSession.js
- Command line
-
hf download hf://spaces/ruslanmv/HomePilot/frontend/src/ui/call/useCallSession.js
-
curl -L -o useCallSession.js https://huggingface.co/spaces/ruslanmv/HomePilot/resolve/main/frontend/src/ui/call/useCallSession.js
15.2 kB
| /** | |
| * useCallSession — React hook that owns the voice_call lifecycle. | |
| * | |
| * One instance per mounted `CallOverlayInner`. Composes callApi + | |
| * callSocket into a single, cleanup-safe React surface: | |
| * | |
| * const session = useCallSession({ enabled, backendUrl, authToken, | |
| * conversationId, personaId, | |
| * deviceInfo }) | |
| * | |
| * session.status → 'idle' | 'creating' | 'connecting' | |
| * | 'live' | 'reconnecting' | |
| * | 'unavailable' | 'error' | 'closed' | |
| * session.sendTranscript(text) → route STT capture to the backend | |
| * session.end() → graceful user-initiated close | |
| * session.onAssistantTranscript → subscribe to assistant replies | |
| * session.onAssistantFiller → subscribe to "hmm…" events | |
| * session.onAssistantBackchannel → subscribe to "mm-hm" events | |
| * session.lastError → surface for the UI | |
| * | |
| * When `enabled` flips to true we: | |
| * 1. POST /v1/voice-call/sessions (via callApi) | |
| * 2. Open the WebSocket (via callSocket) | |
| * 3. Wire status + event forwarding into React state | |
| * | |
| * Critically, if the POST returns 404/501 (backend has | |
| * VOICE_CALL_ENABLED=false) we surface `status='unavailable'` and | |
| * stop — the caller then falls back to the chat-REST path. That | |
| * graceful-degradation path is what lets us ship this module | |
| * independent of the backend flag rollout. | |
| */ | |
| import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; | |
| import { createCallSession, resolveWsUrl, CallApiError, } from './callApi'; | |
| import { CallSocket, } from './callSocket'; | |
| // When the backend returns 404/501 for voice_call (VOICE_CALL_ENABLED=false) | |
| // we remember the miss per-backend and short-circuit future session creates | |
| // for UNAVAILABLE_BACKOFF_MS. Without this, every CallOverlay mount posts a | |
| // fresh 404 against the same flag, spamming the console and delaying the | |
| // fallback path by a full network round-trip. | |
| // | |
| // Industry practice: the backend feature-flag rarely flips mid-session, so | |
| // the backoff is long-lived (10 min) and persisted to sessionStorage so it | |
| // survives React 18 StrictMode's double-invoke of passive effects + in-tab | |
| // navigations that tear the CallOverlay down and re-mount it. Clearing | |
| // sessionStorage ( or calling clearVoiceCallUnavailable ) forces a re-probe. | |
| const UNAVAILABLE_BACKOFF_MS = 10 * 60 * 1000; | |
| const BACKOFF_STORAGE_KEY = 'homepilot_voice_call_unavailable_until'; | |
| const unavailableUntilByBackend = new Map(); | |
| // In-flight dedupe — StrictMode's double-invoke of the mount effect fires | |
| // two parallel POSTs before either resolves, so the backoff is useless in | |
| // that window. Share a single Promise per (backendUrl, authToken) so the | |
| // two invokes observe the same network result instead of racing two probes. | |
| const inflightByBackend = new Map(); | |
| function _readPersistedBackoff(backendUrl) { | |
| if (typeof window === 'undefined') | |
| return 0; | |
| try { | |
| const raw = window.sessionStorage.getItem(BACKOFF_STORAGE_KEY); | |
| if (!raw) | |
| return 0; | |
| const map = JSON.parse(raw); | |
| const until = Number(map[backendUrl] ?? 0); | |
| return Number.isFinite(until) ? until : 0; | |
| } | |
| catch { | |
| return 0; | |
| } | |
| } | |
| function _writePersistedBackoff(backendUrl, until) { | |
| if (typeof window === 'undefined') | |
| return; | |
| try { | |
| const raw = window.sessionStorage.getItem(BACKOFF_STORAGE_KEY); | |
| const map = (raw ? JSON.parse(raw) : {}); | |
| map[backendUrl] = until; | |
| window.sessionStorage.setItem(BACKOFF_STORAGE_KEY, JSON.stringify(map)); | |
| } | |
| catch { | |
| /* ignore quota / private-mode errors */ | |
| } | |
| } | |
| function _clearPersistedBackoff(backendUrl) { | |
| if (typeof window === 'undefined') | |
| return; | |
| try { | |
| const raw = window.sessionStorage.getItem(BACKOFF_STORAGE_KEY); | |
| if (!raw) | |
| return; | |
| const map = JSON.parse(raw); | |
| delete map[backendUrl]; | |
| window.sessionStorage.setItem(BACKOFF_STORAGE_KEY, JSON.stringify(map)); | |
| } | |
| catch { | |
| /* ignore */ | |
| } | |
| } | |
| /** Resolve the active backoff deadline for a backend. Checks the in-memory | |
| * cache first, falls through to sessionStorage. Expired entries return 0. */ | |
| function _backoffUntil(backendUrl) { | |
| const mem = unavailableUntilByBackend.get(backendUrl) ?? 0; | |
| const persisted = _readPersistedBackoff(backendUrl); | |
| const until = Math.max(mem, persisted); | |
| if (until && until <= Date.now()) { | |
| unavailableUntilByBackend.delete(backendUrl); | |
| _clearPersistedBackoff(backendUrl); | |
| return 0; | |
| } | |
| return until; | |
| } | |
| /** Force a re-probe of the voice_call backend on the next CallOverlay mount. | |
| * Exposed for future "retry now" UI affordances. Non-destructive: no-op if | |
| * no backoff was set. */ | |
| export function clearVoiceCallUnavailable(backendUrl) { | |
| unavailableUntilByBackend.delete(backendUrl); | |
| _clearPersistedBackoff(backendUrl); | |
| } | |
| export function useCallSession(args) { | |
| const { enabled, backendUrl, authToken, request } = args; | |
| const [status, setStatus] = useState('idle'); | |
| const [callState, setCallState] = useState(null); | |
| const [closeReason, setCloseReason] = useState(null); | |
| const [lastError, setLastError] = useState(null); | |
| const socketRef = useRef(null); | |
| const sessionRef = useRef(null); | |
| // Stable subscribe surface. Keep listener sets here so they survive | |
| // socket reconnects — the old socket's listeners are torn down with | |
| // it, but user subscriptions persist across reconnects and are | |
| // re-attached when a new socket comes up. | |
| const txListeners = useRef(new Set()); | |
| const fillerListeners = useRef(new Set()); | |
| const bcListeners = useRef(new Set()); | |
| // Phase 2/3 listener sets. Kept separate so an old subscribe set | |
| // doesn't churn when a new one mounts (stream wire-up rebinds often | |
| // during CallOverlay renders). | |
| const partialListeners = useRef(new Set()); | |
| const turnEndListeners = useRef(new Set()); | |
| const cancelListeners = useRef(new Set()); | |
| // Negotiated modes — re-derived on session create, stashed here so | |
| // every render reads the same value without re-running the effect. | |
| const [streamingNegotiated, setStreamingNegotiated] = useState(false); | |
| const [bargeInNegotiated, setBargeInNegotiated] = useState(false); | |
| // Ref'd primitive so the session-create effect doesn't re-trigger | |
| // when the request object identity changes every render. | |
| const requestRef = useRef(request); | |
| useEffect(() => { requestRef.current = request; }, [request]); | |
| // Main lifecycle. Runs once per (enabled, backendUrl, authToken) | |
| // triple — which is what we want: flipping enabled true/false | |
| // drives open/close; swapping backendUrl or re-auth reconnects. | |
| useEffect(() => { | |
| if (!enabled) | |
| return; | |
| let disposed = false; | |
| const teardown = () => { | |
| disposed = true; | |
| socketRef.current?.dispose('unmounted'); | |
| socketRef.current = null; | |
| sessionRef.current = null; | |
| }; | |
| const run = async () => { | |
| setStatus('creating'); | |
| setLastError(null); | |
| setCloseReason(null); | |
| setCallState(null); | |
| // Skip the POST entirely if this backend has been 404/501-ing | |
| // recently. _backoffUntil() consults the in-memory map + the | |
| // sessionStorage mirror so StrictMode double-invokes + page | |
| // reloads share the verdict. Expired entries self-clear. | |
| const skipUntil = _backoffUntil(backendUrl); | |
| if (skipUntil) { | |
| // eslint-disable-next-line no-console | |
| console.info('[useCallSession] skipping createCallSession — backend flagged unavailable', { backendUrl, resumesAt: new Date(skipUntil).toISOString() }); | |
| setStatus('unavailable'); | |
| return; | |
| } | |
| // Share the handshake Promise across concurrent callers so | |
| // React 18 StrictMode's two parallel mount effects can't fire | |
| // two POSTs (the backoff is written only after the response | |
| // lands, so a second probe racing the first would otherwise | |
| // get through before the first writes its verdict). | |
| let handshake; | |
| try { | |
| let inflight = inflightByBackend.get(backendUrl); | |
| if (!inflight) { | |
| inflight = createCallSession(backendUrl, requestRef.current, authToken) | |
| .finally(() => { | |
| inflightByBackend.delete(backendUrl); | |
| }); | |
| inflightByBackend.set(backendUrl, inflight); | |
| } | |
| handshake = await inflight; | |
| } | |
| catch (err) { | |
| if (disposed) | |
| return; | |
| if (err instanceof CallApiError && err.isUnavailable) { | |
| const until = Date.now() + UNAVAILABLE_BACKOFF_MS; | |
| unavailableUntilByBackend.set(backendUrl, until); | |
| _writePersistedBackoff(backendUrl, until); | |
| // eslint-disable-next-line no-console | |
| console.info('[useCallSession] voice_call unavailable — falling back to chat REST until', new Date(until).toISOString()); | |
| setStatus('unavailable'); | |
| return; | |
| } | |
| // eslint-disable-next-line no-console | |
| console.error('[useCallSession] createCallSession failed', err); | |
| setStatus('error'); | |
| setLastError(err instanceof Error ? err.message : String(err)); | |
| return; | |
| } | |
| if (disposed) | |
| return; | |
| // Handshake worked — clear any prior backoff so new flag flips | |
| // take effect immediately instead of waiting out the window. | |
| unavailableUntilByBackend.delete(backendUrl); | |
| _clearPersistedBackoff(backendUrl); | |
| sessionRef.current = handshake; | |
| const url = resolveWsUrl(handshake.ws_url, handshake.session_id, handshake.resume_token, authToken, backendUrl); | |
| const sock = new CallSocket({ url }); | |
| socketRef.current = sock; | |
| // Translate socket lifecycle → hook status. | |
| sock.on('statusChange', (s) => { | |
| if (disposed) | |
| return; | |
| setStatus(s === 'idle' ? 'idle' : s); | |
| }); | |
| sock.on('callState', (p) => { | |
| if (disposed) | |
| return; | |
| setCallState(p.status); | |
| }); | |
| sock.on('closed', ({ reason }) => { | |
| if (disposed) | |
| return; | |
| setStatus('closed'); | |
| setCloseReason(reason); | |
| }); | |
| sock.on('serverError', (p) => { | |
| // Non-terminal by contract; surface for the UI but keep the | |
| // socket open. The server will close explicitly if fatal. | |
| if (disposed) | |
| return; | |
| setLastError(`${p.code}: ${p.message}`); | |
| }); | |
| sock.on('assistantTranscript', (p) => { | |
| for (const fn of txListeners.current) | |
| fn(p); | |
| }); | |
| sock.on('assistantFiller', (p) => { | |
| for (const fn of fillerListeners.current) | |
| fn(p); | |
| }); | |
| sock.on('assistantBackchannel', (p) => { | |
| for (const fn of bcListeners.current) | |
| fn(p); | |
| }); | |
| // Phase 2/3 event fan-outs. | |
| sock.on('assistantPartial', (p) => { | |
| for (const fn of partialListeners.current) | |
| fn(p); | |
| }); | |
| sock.on('assistantTurnEnd', (p) => { | |
| for (const fn of turnEndListeners.current) | |
| fn(p); | |
| }); | |
| sock.on('assistantCancel', (p) => { | |
| for (const fn of cancelListeners.current) | |
| fn(p); | |
| }); | |
| // Reflect negotiated modes from the session-create response. | |
| const caps = handshake.capabilities; | |
| setStreamingNegotiated(!!caps?.streaming); | |
| setBargeInNegotiated(!!caps?.streaming && !!caps?.barge_in); | |
| sock.connect(); | |
| }; | |
| void run(); | |
| return teardown; | |
| }, [enabled, backendUrl, authToken]); | |
| // Public actions. Guarded against stale-socket calls. -------------- | |
| const sendTranscript = useCallback((text) => { | |
| const sock = socketRef.current; | |
| if (!sock) | |
| return; | |
| const trimmed = text.trim(); | |
| if (!trimmed) | |
| return; | |
| sock.sendTranscript({ text: trimmed }); | |
| }, []); | |
| const end = useCallback(() => { | |
| socketRef.current?.end(); | |
| }, []); | |
| const sendUiState = useCallback((p) => { | |
| socketRef.current?.sendUiState(p); | |
| }, []); | |
| const sendTranscriptPartial = useCallback((p) => { | |
| socketRef.current?.sendTranscriptPartial(p); | |
| }, []); | |
| const sendBargeIn = useCallback((turn_id) => { | |
| socketRef.current?.sendBargeIn(turn_id); | |
| }, []); | |
| const onAssistantTranscript = useCallback((fn) => { | |
| txListeners.current.add(fn); | |
| return () => { txListeners.current.delete(fn); }; | |
| }, []); | |
| const onAssistantFiller = useCallback((fn) => { | |
| fillerListeners.current.add(fn); | |
| return () => { fillerListeners.current.delete(fn); }; | |
| }, []); | |
| const onAssistantBackchannel = useCallback((fn) => { | |
| bcListeners.current.add(fn); | |
| return () => { bcListeners.current.delete(fn); }; | |
| }, []); | |
| const onAssistantPartial = useCallback((fn) => { | |
| partialListeners.current.add(fn); | |
| return () => { partialListeners.current.delete(fn); }; | |
| }, []); | |
| const onAssistantTurnEnd = useCallback((fn) => { | |
| turnEndListeners.current.add(fn); | |
| return () => { turnEndListeners.current.delete(fn); }; | |
| }, []); | |
| const onAssistantCancel = useCallback((fn) => { | |
| cancelListeners.current.add(fn); | |
| return () => { cancelListeners.current.delete(fn); }; | |
| }, []); | |
| return useMemo(() => ({ | |
| status, | |
| callState, | |
| closeReason, | |
| lastError, | |
| sendTranscript, | |
| end, | |
| sendUiState, | |
| sendTranscriptPartial, | |
| sendBargeIn, | |
| streamingNegotiated, | |
| bargeInNegotiated, | |
| onAssistantTranscript, | |
| onAssistantFiller, | |
| onAssistantBackchannel, | |
| onAssistantPartial, | |
| onAssistantTurnEnd, | |
| onAssistantCancel, | |
| }), [ | |
| status, callState, closeReason, lastError, | |
| sendTranscript, end, sendUiState, | |
| sendTranscriptPartial, sendBargeIn, | |
| streamingNegotiated, bargeInNegotiated, | |
| onAssistantTranscript, onAssistantFiller, onAssistantBackchannel, | |
| onAssistantPartial, onAssistantTurnEnd, onAssistantCancel, | |
| ]); | |
| } | |