HomePilot / frontend /src /ui /call /useCallSession.js
HomePilot Deploy Bot
chore(hf): sync HomePilot to HF Space
921d377
Raw
History Blame Contribute Delete
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,
]);
}