| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; |
| import { createCallSession, resolveWsUrl, CallApiError, } from './callApi'; |
| import { CallSocket, } from './callSocket'; |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const UNAVAILABLE_BACKOFF_MS = 10 * 60 * 1000; |
| const BACKOFF_STORAGE_KEY = 'homepilot_voice_call_unavailable_until'; |
| const unavailableUntilByBackend = new Map(); |
| |
| |
| |
| |
| 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 { |
| |
| } |
| } |
| 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 { |
| |
| } |
| } |
| |
| |
| 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; |
| } |
| |
| |
| |
| 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); |
| |
| |
| |
| |
| const txListeners = useRef(new Set()); |
| const fillerListeners = useRef(new Set()); |
| const bcListeners = useRef(new Set()); |
| |
| |
| |
| const partialListeners = useRef(new Set()); |
| const turnEndListeners = useRef(new Set()); |
| const cancelListeners = useRef(new Set()); |
| |
| |
| const [streamingNegotiated, setStreamingNegotiated] = useState(false); |
| const [bargeInNegotiated, setBargeInNegotiated] = useState(false); |
| |
| |
| const requestRef = useRef(request); |
| useEffect(() => { requestRef.current = request; }, [request]); |
| |
| |
| |
| 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); |
| |
| |
| |
| |
| const skipUntil = _backoffUntil(backendUrl); |
| if (skipUntil) { |
| |
| console.info('[useCallSession] skipping createCallSession — backend flagged unavailable', { backendUrl, resumesAt: new Date(skipUntil).toISOString() }); |
| setStatus('unavailable'); |
| return; |
| } |
| |
| |
| |
| |
| |
| 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); |
| |
| console.info('[useCallSession] voice_call unavailable — falling back to chat REST until', new Date(until).toISOString()); |
| setStatus('unavailable'); |
| return; |
| } |
| |
| console.error('[useCallSession] createCallSession failed', err); |
| setStatus('error'); |
| setLastError(err instanceof Error ? err.message : String(err)); |
| return; |
| } |
| if (disposed) |
| return; |
| |
| |
| 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; |
| |
| 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) => { |
| |
| |
| 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); |
| }); |
| |
| 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); |
| }); |
| |
| const caps = handshake.capabilities; |
| setStreamingNegotiated(!!caps?.streaming); |
| setBargeInNegotiated(!!caps?.streaming && !!caps?.barge_in); |
| sock.connect(); |
| }; |
| void run(); |
| return teardown; |
| }, [enabled, backendUrl, authToken]); |
| |
| 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, |
| ]); |
| } |
|
|