import { useEffect, useRef, useState } from "react"; import { Avatar } from "./avatar"; import { CallAudio } from "./call-audio"; import { t } from "./i18n"; import type { AvatarShape, Bot } from "./types"; export type CallPhase = "connecting" | "listening" | "speaking" | "working"; type ComputerEvent = { status?: string; step?: string | null; takeover?: boolean }; type ServerEvent = | { type: "ready"; callId?: string; voice?: string } | { type: "transcript"; role: "user" | "assistant"; text: string; final?: boolean } | { type: "speech"; state: "started" | "stopped" } | { type: "computer"; status?: string; step?: string | null; takeover?: boolean } | { type: "error"; message: string }; function wsUrl(sessionId: string): string { const protocol = location.protocol === "https:" ? "wss:" : "ws:"; return `${protocol}//${location.host}/api/sessions/${sessionId}/call`; } function phaseLabel(phase: CallPhase, computer: ComputerEvent | null): string { if (computer?.takeover) return t("takeOverToContinue"); if (phase === "connecting") return t("callConnecting"); if (phase === "speaking") return t("speaking"); if (phase === "working") return t("workingOnComputer"); return t("listening"); } function computerIsBusy(computer: ComputerEvent | null): boolean { return computer?.status === "running" || computer?.status === "queued" || Boolean(computer?.takeover); } export function PhoneIcon({ size = 16 }: { size?: number }) { return ( ); } export function CallOverlay({ bot, sessionId, takeover, onHangUp, onTakeOver, }: { bot: Bot; sessionId: string; takeover: boolean; onHangUp: () => void; onTakeOver: () => void; }) { const [connected, setConnected] = useState(false); const [speaking, setSpeaking] = useState(false); const [caption, setCaption] = useState<{ role: "user" | "assistant"; text: string } | null>(null); const [error, setError] = useState(""); const [elapsed, setElapsed] = useState(0); const [computer, setComputer] = useState(null); const audioRef = useRef(null); const socketRef = useRef(null); const hanging = useRef(false); function hangUp() { hanging.current = true; audioRef.current?.stop(); audioRef.current = null; socketRef.current?.close(); socketRef.current = null; onHangUp(); } function interrupt() { audioRef.current?.interrupt(); // Stopping local playback alone lets the model keep streaming the rest of // its answer, so the provider has to be told to abandon the response too. if (socketRef.current?.readyState === WebSocket.OPEN) { socketRef.current.send(JSON.stringify({ type: "interrupt" })); } } useEffect(() => { hanging.current = false; let disposed = false; const started = Date.now(); const tick = window.setInterval(() => setElapsed(Math.floor((Date.now() - started) / 1000)), 1000); const audio = new CallAudio({ onCapture: (pcm) => { if (socketRef.current?.readyState === WebSocket.OPEN) socketRef.current.send(pcm); }, onError: () => setError(t("callAudioFailed")), onPlaybackChange: setSpeaking, }); audioRef.current = audio; const socket = new WebSocket(wsUrl(sessionId)); socket.binaryType = "arraybuffer"; socketRef.current = socket; socket.onopen = () => { void audio.start().catch((err) => { if (disposed) return; audio.stop(); socket.close(); setError(err instanceof Error && /NotAllowedError|PermissionDenied/.test(err.name + err.message) ? t("micDenied") : t("micFailed")); }); setConnected(true); }; socket.onmessage = (event) => { if (typeof event.data !== "string") { audio.play(event.data as ArrayBuffer); return; } let payload: ServerEvent; try { payload = JSON.parse(event.data) as ServerEvent; } catch { return; } if (payload.type === "speech") { if (payload.state === "started") audio.interrupt(); return; } if (payload.type === "transcript") { setCaption({ role: payload.role, text: payload.text }); return; } if (payload.type === "computer") { setComputer(payload); return; } if (payload.type === "error") setError(payload.message); }; socket.onerror = () => { if (!disposed && !hanging.current) setError(t("callFailed")); }; socket.onclose = () => { audio.stop(); if (!disposed && !hanging.current) setError((previous) => previous || t("callFailed")); }; function onKey(event: KeyboardEvent) { if (event.key === "Escape") { event.preventDefault(); hangUp(); } if (event.key === " " && !event.repeat && event.target === document.body) { event.preventDefault(); interrupt(); } } window.addEventListener("keydown", onKey); return () => { disposed = true; hanging.current = true; window.clearInterval(tick); window.removeEventListener("keydown", onKey); audio.stop(); socket.close(); }; }, [sessionId, bot.id]); const minutes = Math.floor(elapsed / 60); const seconds = String(elapsed % 60).padStart(2, "0"); const showTakeover = takeover || Boolean(computer?.takeover); const phase: CallPhase = !connected ? "connecting" : speaking ? "speaking" : computerIsBusy(computer) ? "working" : "listening"; return ( {t("call")} {bot.name} {phaseLabel(phase, computer)} {caption?.text || t("callPrompt")} {error ? {error} : null} {minutes}:{seconds} {t("interrupt")} {t("hangUp")} {showTakeover ? ( {t("takeOverNow")} ) : null} {t("callShortcuts")} ); }
{caption?.text || t("callPrompt")}
{error}
{t("callShortcuts")}