| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { useCallback, useEffect, useMemo, useRef, useState } from 'react' |
| import { |
| createCallSession, |
| resolveWsUrl, |
| CallApiError, |
| type CreateCallSessionRequest, |
| type CreateCallSessionResponse, |
| } from './callApi' |
| import { |
| CallSocket, |
| type AssistantBackchannelPayload, |
| type AssistantCancelPayload, |
| type AssistantFillerPayload, |
| type AssistantPartialPayload, |
| type AssistantTranscriptPayload, |
| type AssistantTurnEndPayload, |
| type CallCloseReason, |
| type CallLifecycleStatus, |
| } from './callSocket' |
|
|
| export type CallSessionStatus = |
| | 'idle' |
| | 'creating' |
| | 'connecting' |
| | 'live' |
| | 'reconnecting' |
| | 'draining' |
| | 'closed' |
| | 'unavailable' |
| | 'error' |
|
|
| export interface UseCallSessionArgs { |
| |
| |
| enabled: boolean |
| |
| backendUrl: string |
| |
| authToken: string | null |
| |
| request: CreateCallSessionRequest |
| } |
|
|
| export interface CallSessionHandle { |
| status: CallSessionStatus |
| |
| callState: 'live' | 'ending' | 'ended' | null |
| |
| |
| closeReason: CallCloseReason | null |
| |
| |
| lastError: string | null |
| |
| |
| sendTranscript: (text: string) => void |
| |
| end: () => void |
| |
| sendUiState: (p: { muted?: boolean; speaker_on?: boolean; backgrounded?: boolean }) => void |
| |
| sendTranscriptPartial: (p: { text: string; stable_prefix_len?: number }) => void |
| |
| sendBargeIn: (turn_id: string) => void |
| |
| |
| streamingNegotiated: boolean |
| |
| bargeInNegotiated: boolean |
| |
| |
| |
| onAssistantTranscript: (fn: (p: AssistantTranscriptPayload) => void) => () => void |
| onAssistantFiller: (fn: (p: AssistantFillerPayload) => void) => () => void |
| onAssistantBackchannel: (fn: (p: AssistantBackchannelPayload) => void) => () => void |
| |
| onAssistantPartial: (fn: (p: AssistantPartialPayload) => void) => () => void |
| onAssistantTurnEnd: (fn: (p: AssistantTurnEndPayload) => void) => () => void |
| onAssistantCancel: (fn: (p: AssistantCancelPayload) => void) => () => void |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const UNAVAILABLE_BACKOFF_MS = 10 * 60 * 1000 |
| const BACKOFF_STORAGE_KEY = 'homepilot_voice_call_unavailable_until' |
| const unavailableUntilByBackend = new Map<string, number>() |
|
|
| |
| |
| |
| |
| const inflightByBackend = new Map<string, Promise<CreateCallSessionResponse>>() |
|
|
| function _readPersistedBackoff(backendUrl: string): number { |
| if (typeof window === 'undefined') return 0 |
| try { |
| const raw = window.sessionStorage.getItem(BACKOFF_STORAGE_KEY) |
| if (!raw) return 0 |
| const map = JSON.parse(raw) as Record<string, number> |
| const until = Number(map[backendUrl] ?? 0) |
| return Number.isFinite(until) ? until : 0 |
| } catch { |
| return 0 |
| } |
| } |
|
|
| function _writePersistedBackoff(backendUrl: string, until: number): void { |
| if (typeof window === 'undefined') return |
| try { |
| const raw = window.sessionStorage.getItem(BACKOFF_STORAGE_KEY) |
| const map = (raw ? JSON.parse(raw) : {}) as Record<string, number> |
| map[backendUrl] = until |
| window.sessionStorage.setItem(BACKOFF_STORAGE_KEY, JSON.stringify(map)) |
| } catch { |
| |
| } |
| } |
|
|
| function _clearPersistedBackoff(backendUrl: string): void { |
| if (typeof window === 'undefined') return |
| try { |
| const raw = window.sessionStorage.getItem(BACKOFF_STORAGE_KEY) |
| if (!raw) return |
| const map = JSON.parse(raw) as Record<string, number> |
| delete map[backendUrl] |
| window.sessionStorage.setItem(BACKOFF_STORAGE_KEY, JSON.stringify(map)) |
| } catch { |
| |
| } |
| } |
|
|
| |
| |
| function _backoffUntil(backendUrl: string): number { |
| 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: string): void { |
| unavailableUntilByBackend.delete(backendUrl) |
| _clearPersistedBackoff(backendUrl) |
| } |
|
|
| export function useCallSession(args: UseCallSessionArgs): CallSessionHandle { |
| const { enabled, backendUrl, authToken, request } = args |
|
|
| const [status, setStatus] = useState<CallSessionStatus>('idle') |
| const [callState, setCallState] = useState<'live' | 'ending' | 'ended' | null>(null) |
| const [closeReason, setCloseReason] = useState<CallCloseReason | null>(null) |
| const [lastError, setLastError] = useState<string | null>(null) |
|
|
| const socketRef = useRef<CallSocket | null>(null) |
| const sessionRef = useRef<CreateCallSessionResponse | null>(null) |
|
|
| |
| |
| |
| |
| const txListeners = useRef(new Set<(p: AssistantTranscriptPayload) => void>()) |
| const fillerListeners = useRef(new Set<(p: AssistantFillerPayload) => void>()) |
| const bcListeners = useRef(new Set<(p: AssistantBackchannelPayload) => void>()) |
| |
| |
| |
| const partialListeners = useRef(new Set<(p: AssistantPartialPayload) => void>()) |
| const turnEndListeners = useRef(new Set<(p: AssistantTurnEndPayload) => void>()) |
| const cancelListeners = useRef(new Set<(p: AssistantCancelPayload) => void>()) |
| |
| |
| 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: CreateCallSessionResponse |
| 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: CallLifecycleStatus) => { |
| 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 as |
| | { streaming?: boolean; barge_in?: boolean } | undefined |
| setStreamingNegotiated(!!caps?.streaming) |
| setBargeInNegotiated(!!caps?.streaming && !!caps?.barge_in) |
|
|
| sock.connect() |
| } |
|
|
| void run() |
| return teardown |
| }, [enabled, backendUrl, authToken]) |
|
|
| |
|
|
| const sendTranscript = useCallback((text: string) => { |
| 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: { muted?: boolean; speaker_on?: boolean; backgrounded?: boolean }) => { |
| socketRef.current?.sendUiState(p) |
| }, |
| [], |
| ) |
|
|
| const sendTranscriptPartial = useCallback( |
| (p: { text: string; stable_prefix_len?: number }) => { |
| socketRef.current?.sendTranscriptPartial(p) |
| }, |
| [], |
| ) |
|
|
| const sendBargeIn = useCallback((turn_id: string) => { |
| socketRef.current?.sendBargeIn(turn_id) |
| }, []) |
|
|
| const onAssistantTranscript = useCallback( |
| (fn: (p: AssistantTranscriptPayload) => void) => { |
| txListeners.current.add(fn) |
| return () => { txListeners.current.delete(fn) } |
| }, [], |
| ) |
| const onAssistantFiller = useCallback( |
| (fn: (p: AssistantFillerPayload) => void) => { |
| fillerListeners.current.add(fn) |
| return () => { fillerListeners.current.delete(fn) } |
| }, [], |
| ) |
| const onAssistantBackchannel = useCallback( |
| (fn: (p: AssistantBackchannelPayload) => void) => { |
| bcListeners.current.add(fn) |
| return () => { bcListeners.current.delete(fn) } |
| }, [], |
| ) |
| const onAssistantPartial = useCallback( |
| (fn: (p: AssistantPartialPayload) => void) => { |
| partialListeners.current.add(fn) |
| return () => { partialListeners.current.delete(fn) } |
| }, [], |
| ) |
| const onAssistantTurnEnd = useCallback( |
| (fn: (p: AssistantTurnEndPayload) => void) => { |
| turnEndListeners.current.add(fn) |
| return () => { turnEndListeners.current.delete(fn) } |
| }, [], |
| ) |
| const onAssistantCancel = useCallback( |
| (fn: (p: AssistantCancelPayload) => void) => { |
| cancelListeners.current.add(fn) |
| return () => { cancelListeners.current.delete(fn) } |
| }, [], |
| ) |
|
|
| return useMemo<CallSessionHandle>(() => ({ |
| 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, |
| ]) |
| } |
|
|