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" || computer?.status === "running" || computer?.status === "queued") { return t("workingOnComputer"); } return t("listening"); } 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 [phase, setPhase] = useState("connecting"); const [caption, setCaption] = useState(""); 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(); setPhase("listening"); } 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: (message) => setError(message), }); 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")); }); setPhase("listening"); }; 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(); setPhase("listening"); } else setPhase("listening"); return; } if (payload.type === "transcript") { setCaption(payload.text); if (payload.role === "assistant") setPhase(payload.final ? "listening" : "speaking"); return; } if (payload.type === "computer") { setComputer(payload); if (payload.status === "running" || payload.status === "queued" || payload.takeover) setPhase("working"); 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); return ( {t("call")} {bot.name} {phaseLabel(phase, computer)} {caption || t("callPrompt")} {error ? {error} : null} {minutes}:{seconds} {t("interrupt")} {t("hangUp")} {showTakeover ? ( {t("takeOverNow")} ) : null} {t("callShortcuts")} ); }
{caption || t("callPrompt")}
{error}
{t("callShortcuts")}