import { FormEvent, KeyboardEvent, useEffect, useRef, useState } from "react"; import { speakTurn } from "../tts"; import type { PipelineFailureContext, TranscriptTurn, WsEvent } from "../types"; import { confirmTurn, deleteSession, endSession, escalateSession, getHealth, submitFeedback, websocketUrl } from "../api"; import { copy, type Language } from "../copy"; import { Transcript } from "./Transcript"; import { SessionChecklist } from "./SessionChecklist"; type InterpreterConsoleProps = { deviceId?: string; initialSpeaker?: Speaker; language?: Language; sessionId: string; voiceReady?: boolean; onComplete?: () => void; }; type Speaker = "doctor" | "patient"; type InputState = "connecting" | "ready" | "recording" | "processing" | "delivered" | "review-required" | "disconnected"; const speakerConfig: Record = { doctor: { lang: "vi" }, patient: { lang: "en" }, }; export function InterpreterConsole({ deviceId, initialSpeaker = "doctor", language = "vi", onComplete = () => {}, sessionId, voiceReady = true }: InterpreterConsoleProps) { const text = copy[language].workspace; const textRef = useRef(text); textRef.current = text; const [turns, setTurns] = useState([]); const [speaker, setSpeaker] = useState(initialSpeaker); const [typedText, setTypedText] = useState(""); const [inputState, setInputState] = useState("connecting"); const [warning, setWarning] = useState(null); const [pipelineFailure, setPipelineFailure] = useState(null); const [escalated, setEscalated] = useState(false); const [playbackSuppressed, setPlaybackSuppressed] = useState(false); const [providerMode, setProviderMode] = useState(""); const [edits, setEdits] = useState>({}); const [openReview, setOpenReview] = useState(null); const [closed, setClosed] = useState(false); const [lifecycleError, setLifecycleError] = useState<"end" | "delete" | null>(null); const [escalationLocated, setEscalationLocated] = useState(false); const [focusTarget, setFocusTarget] = useState<{ speaker: Speaker; target: "talk" | "type" } | null>(null); const socketRef = useRef(null); const recorderRef = useRef(null); const streamRef = useRef(null); const turnActiveRef = useRef(false); const playbackSuppressedRef = useRef(false); const closedRef = useRef(false); const reviewRef = useRef(null); const talkRefs = useRef>>({}); const inputRefs = useRef>>({}); useEffect(() => { if (openReview) { reviewRef.current?.focus(); } }, [openReview]); useEffect(() => { if (focusTarget?.speaker === speaker) { (focusTarget.target === "talk" ? talkRefs.current[speaker] : inputRefs.current[speaker])?.focus(); setFocusTarget(null); } }, [focusTarget, speaker]); useEffect(() => { void getHealth() .then((health) => setProviderMode(health.provider_mode)) .catch(() => setProviderMode("unknown")); const socket = new WebSocket(websocketUrl(`/ws/sessions/${sessionId}`)); socketRef.current = socket; const isCurrentSocket = () => socketRef.current === socket; if (socket.readyState === WebSocket.OPEN) { setInputState("ready"); } socket.addEventListener("open", () => { if (isCurrentSocket()) { setInputState("ready"); } }); socket.addEventListener("close", () => { if (isCurrentSocket()) { stopRecording(); turnActiveRef.current = false; setInputState("disconnected"); } }); socket.addEventListener("message", (event: MessageEvent) => { if (!isCurrentSocket() || closedRef.current) { return; } const data = JSON.parse(event.data) as WsEvent; if (data.type === "session_state") { setTurns(data.turns); } if (data.type === "turn_result") { setPipelineFailure(null); const nextTurn = { ...data.turn, low_confidence: data.low_confidence, requires_confirmation: data.requires_confirmation, }; setTurns((current) => [...current, nextTurn]); turnActiveRef.current = false; setInputState(data.requires_confirmation ? "review-required" : "delivered"); if (data.requires_confirmation) { setWarning(textRef.current.confirmationRequired); } else { setWarning(null); speakTurn(nextTurn, playbackSuppressedRef.current); } } if (data.type === "turn_error") { stopRecording(); turnActiveRef.current = false; setInputState("ready"); setWarning(textRef.current.turnError); if (data.failure_context) setPipelineFailure(data.failure_context); } }); return () => { turnActiveRef.current = false; stopRecording(); if (isCurrentSocket()) { socketRef.current = null; } socket.close(); }; }, [sessionId]); function sendJson(payload: unknown) { if (closedRef.current) return false; const socket = socketRef.current; if (!socket || socket.readyState !== WebSocket.OPEN) { setWarning(text.connectionNotReady); return false; } socket.send(JSON.stringify(payload)); return true; } function submitTyped(event: FormEvent) { event.preventDefault(); const text = typedText.trim(); if (!text) { return; } if (turnActiveRef.current || inputState === "disconnected" || inputState === "connecting") { return; } const config = speakerConfig[speaker]; setPipelineFailure(null); turnActiveRef.current = true; setInputState("processing"); if (sendJson({ type: "text_turn", speaker, lang: config.lang, text })) { setTypedText(""); } else { turnActiveRef.current = false; setInputState("ready"); } } async function handleConfirm(turn: TranscriptTurn, editedTranslation?: string) { if (closed) return; try { const confirmed = await confirmTurn(turn.id, editedTranslation); const nextTurn = { ...confirmed, low_confidence: turn.low_confidence, requires_confirmation: false, }; setTurns((current) => current.map((item) => (item.id === turn.id ? nextTurn : item))); setOpenReview(null); setWarning(null); speakTurn(nextTurn, playbackSuppressedRef.current); } catch { setWarning(text.confirmFailed); } } async function handleEscalate() { if (closed) return; setEscalationLocated(true); playbackSuppressedRef.current = true; setPlaybackSuppressed(true); window.speechSynthesis?.cancel(); try { await escalateSession(sessionId); setEscalated(true); setWarning(null); } catch { setWarning(text.escalationFailed); } } async function startRecording(nextSpeaker: Speaker) { if (turnActiveRef.current || inputState === "disconnected" || inputState === "connecting") { return; } const config = speakerConfig[nextSpeaker]; if (!voiceReady) { setWarning(text.microphoneUnavailable); return; } const socket = socketRef.current; if (!socket || socket.readyState !== WebSocket.OPEN) { setWarning(text.connectionNotReadyType); return; } if (!navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === "undefined") { setWarning(text.microphoneUnavailable); return; } turnActiveRef.current = true; setPipelineFailure(null); setInputState("recording"); try { const stream = await navigator.mediaDevices.getUserMedia({ audio: deviceId ? { deviceId: { exact: deviceId } } : true }); if (!turnActiveRef.current) { stream.getTracks().forEach((track) => track.stop()); return; } streamRef.current = stream; const recorder = new MediaRecorder(stream); recorderRef.current = recorder; socket.send(JSON.stringify({ type: "start_turn", speaker: nextSpeaker, lang: config.lang })); recorder.addEventListener("dataavailable", async (event) => { if (event.data.size > 0 && socket.readyState === WebSocket.OPEN) { socket.send(await event.data.arrayBuffer()); } }); recorder.addEventListener("stop", () => { if (!closedRef.current && socket.readyState === WebSocket.OPEN) { socket.send(JSON.stringify({ type: "end_turn" })); } stream.getTracks().forEach((track) => track.stop()); if (streamRef.current === stream) { streamRef.current = null; } }); recorder.addEventListener("error", () => { turnActiveRef.current = false; stream.getTracks().forEach((track) => track.stop()); streamRef.current = null; setInputState("ready"); setWarning(text.turnError); }); recorder.start(250); } catch { turnActiveRef.current = false; streamRef.current?.getTracks().forEach((track) => track.stop()); streamRef.current = null; setInputState("ready"); setWarning(text.microphoneDenied); } } function closeLocally() { closedRef.current = true; playbackSuppressedRef.current = true; setPlaybackSuppressed(true); window.speechSynthesis?.cancel(); stopRecording(); socketRef.current?.close(); socketRef.current = null; setClosed(true); } async function finish(action: "end" | "delete") { closeLocally(); setLifecycleError(null); try { if (action === "end") await endSession(sessionId); else await deleteSession(sessionId); onComplete(); } catch { setLifecycleError(action); } } function resumePlayback() { playbackSuppressedRef.current = false; setPlaybackSuppressed(false); } function stopRecording() { const recorder = recorderRef.current; if (recorder && recorder.state !== "inactive") { recorder.stop(); recorderRef.current = null; setInputState("processing"); } else if (turnActiveRef.current) { turnActiveRef.current = false; setInputState("ready"); } streamRef.current?.getTracks().forEach((track) => track.stop()); streamRef.current = null; } function handlePttKeyDown(event: KeyboardEvent, nextSpeaker: Speaker) { if ((event.key === " " || event.key === "Enter") && !event.repeat) { event.preventDefault(); void startRecording(nextSpeaker); } } function handlePttKeyUp(event: KeyboardEvent) { if (event.key === " " || event.key === "Enter") { event.preventDefault(); stopRecording(); } } const canSubmit = !closed && !turnActiveRef.current && inputState !== "connecting" && inputState !== "disconnected"; const recording = inputState === "recording"; const talkDisabled = (!canSubmit && !recording) || !voiceReady; const pttState = recording ? "recording" : inputState === "processing" ? "processing" : talkDisabled ? "disabled" : "ready"; return (
{escalated ? (

{text.escalationTitle}

{text.escalationBody}

) : null} {playbackSuppressed && !closed ? (

{text.playbackSuppressed}

) : null}
{providerMode === "mock" ?

{text.mockDisclaimer}

: null}

{text.title}

{lifecycleError ?

{text.lifecycleFailed}

: null}
turn.status === "delivered")} confirmed={turns.some((turn) => turn.status === "confirmed" || turn.status === "corrected")} escalationLocated={escalationLocated} />
{(Object.keys(speakerConfig) as Speaker[]).map((key) => (

{speaker === key ? ( <>
) : (

{text.regionInactiveHint}

)}
))}
{inputState === "recording" ? text.recording.replace("{speaker}", speaker === "doctor" ? text.doctor : text.patient) : inputState === "processing" ? text.inputProcessing : inputState === "delivered" ? text.inputDelivered : inputState === "review-required" ? text.inputReviewRequired : inputState === "ready" ? text.inputReady : text[inputState]} {warning ? {warning} : null}
{pipelineFailure ? (

{text.pipelineFailureTitle}

{text.source}:{" "} {pipelineFailure.source_text || text.sourceUnavailable}

{text.translation}:{" "} {pipelineFailure.translation || text.translationUnavailable}

{text.pipelineFailureAction}

) : null} {turns .filter( (turn) => turn.requires_confirmation || turn.status === "awaiting_confirm" || turn.status === "blocked", ) .map((turn) => { const edited = edits[turn.id] !== undefined && edits[turn.id] !== turn.translation; const editIsEmpty = edited && !edits[turn.id].trim(); if (openReview !== turn.id) { return (

{text.patientSafeMask}

{turn.risk_tier === "critical" ? : null}
); } return (

{text.reviewOpened}

{text.confirmationRequiredLabel} · {riskText(text, turn.risk_tier)}

{text.speaker}: {turn.speaker === "doctor" ? text.doctor : text.patient}

{text.sourceLanguage}: {turn.src_lang === "vi" ? text.vietnamese : text.english}

{text.source}: {turn.source_text}

{text.draftTranslation}: {turn.translation}

{text.riskReason}: {riskText(text, turn.risk_tier)}

{turn.risk_spans.length ?
    {turn.risk_spans.map((span, index) =>
  • {riskText(text, span.severity)}: {riskKindText(text, span.kind)}
  • )}
: null} {turn.readback?.entities.length ? (

{text.criticalEntities}:

{turn.readback.entities.map((entity, index) => )}
{text.entity}{text.source}{text.translation}
{riskKindText(text, entity.kind)}{entity.source_text}{entity.translated_text}
) : null}