carepath-api / interpreter /frontend /src /components /InterpreterConsole.tsx
tranth3truong's picture
Deploy public Scribe-only CarePath Space
cc678b9
Raw
History Blame Contribute Delete
22.2 kB
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<Speaker, { lang: "vi" | "en" }> = {
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<TranscriptTurn[]>([]);
const [speaker, setSpeaker] = useState<Speaker>(initialSpeaker);
const [typedText, setTypedText] = useState("");
const [inputState, setInputState] = useState<InputState>("connecting");
const [warning, setWarning] = useState<string | null>(null);
const [pipelineFailure, setPipelineFailure] = useState<PipelineFailureContext | null>(null);
const [escalated, setEscalated] = useState(false);
const [playbackSuppressed, setPlaybackSuppressed] = useState(false);
const [providerMode, setProviderMode] = useState("");
const [edits, setEdits] = useState<Record<string, string>>({});
const [openReview, setOpenReview] = useState<string | null>(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<WebSocket | null>(null);
const recorderRef = useRef<MediaRecorder | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const turnActiveRef = useRef(false);
const playbackSuppressedRef = useRef(false);
const closedRef = useRef(false);
const reviewRef = useRef<HTMLElement | null>(null);
const talkRefs = useRef<Partial<Record<Speaker, HTMLButtonElement>>>({});
const inputRefs = useRef<Partial<Record<Speaker, HTMLInputElement>>>({});
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<string>) => {
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<HTMLFormElement>) {
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<HTMLButtonElement>, nextSpeaker: Speaker) {
if ((event.key === " " || event.key === "Enter") && !event.repeat) {
event.preventDefault();
void startRecording(nextSpeaker);
}
}
function handlePttKeyUp(event: KeyboardEvent<HTMLButtonElement>) {
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 (
<main className="workspace" lang={language}>
{escalated ? (
<section className="escalation-card" role="alert" aria-live="assertive">
<h1>{text.escalationTitle}</h1>
<p>{text.escalationBody}</p>
</section>
) : null}
{playbackSuppressed && !closed ? (
<section className="playback-warning" role="alert">
<p>{text.playbackSuppressed}</p>
<button type="button" onClick={resumePlayback}>{text.resumePlayback}</button>
</section>
) : null}
<header className="topbar">
<div>
{providerMode === "mock" ? <p className="eyebrow">{text.mockDisclaimer}</p> : null}
<h1>{text.title}</h1>
</div>
<button className="escalate" disabled={closed} type="button" onFocus={() => setEscalationLocated(true)} onClick={() => void handleEscalate()}>
{text.requestInterpreter}
</button>
</header>
<section className="lifecycle-actions" aria-label={text.lifecycle}>
<button disabled={closed} type="button" onClick={() => void finish("end")}>{text.endSession}</button>
<button disabled={closed} type="button" onClick={() => void finish("end")}>{text.withdrawConsent}</button>
<button disabled={closed} type="button" onClick={() => { if (window.confirm(text.deleteConfirm)) void finish("delete"); }}>{text.deleteSession}</button>
{lifecycleError ? <p className="error" role="alert">{text.lifecycleFailed} <button type="button" onClick={() => void finish(lifecycleError)}>{text.retry}</button></p> : null}
</section>
<SessionChecklist
language={language}
delivered={turns.some((turn) => turn.status === "delivered")}
confirmed={turns.some((turn) => turn.status === "confirmed" || turn.status === "corrected")}
escalationLocated={escalationLocated}
/>
<section className="input-regions" aria-label={text.controls}>
{(Object.keys(speakerConfig) as Speaker[]).map((key) => (
<section className="input-region" key={key}>
<h2>
<button className="region-select" type="button" aria-pressed={speaker === key} disabled={!canSubmit} onClick={() => setSpeaker(key)}>
{key === "doctor" ? `${text.doctor} · ${text.vietnamese}` : `${language === "vi" ? "Người bệnh" : text.patient} · English`}
</button>
</h2>
{speaker === key ? (
<>
<button
className={`talk talk--${pttState}`}
type="button"
aria-pressed={recording}
aria-describedby="input-state"
disabled={talkDisabled}
ref={(element) => { talkRefs.current[key] = element ?? undefined; }}
onPointerDown={() => void startRecording(key)}
onPointerUp={stopRecording}
onPointerCancel={stopRecording}
onTouchStart={() => void startRecording(key)}
onTouchEnd={stopRecording}
onTouchCancel={stopRecording}
onKeyDown={(event) => handlePttKeyDown(event, key)}
onKeyUp={handlePttKeyUp}
>
<span className="talk__icon" aria-hidden="true">
{pttState === "processing" ? <span className="talk__spinner" /> : <MicGlyph />}
</span>
<span className="talk__label">{text.holdToTalk}</span>
</button>
<form className="typed" onSubmit={submitTyped}>
<label>
{text.typedFallback}
<input
placeholder={text.typedPlaceholder}
value={typedText}
disabled={!canSubmit}
ref={(element) => { inputRefs.current[key] = element ?? undefined; }}
onChange={(event) => setTypedText(event.target.value)}
/>
</label>
<button disabled={!canSubmit} type="submit">{text.send}</button>
</form>
</>
) : (
<p className="empty region-empty">{text.regionInactiveHint}</p>
)}
</section>
))}
</section>
<div className="status" id="input-state" role="status">
<span>{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]}</span>
{warning ? <strong>{warning}</strong> : null}
</div>
<section className="confirmations" aria-label={text.confirmations}>
{pipelineFailure ? (
<article className="confirmation" role="alert">
<p className="meta">{text.pipelineFailureTitle}</p>
<p>
<strong>{text.source}:</strong>{" "}
<span lang={pipelineFailure.src_lang}>
{pipelineFailure.source_text || text.sourceUnavailable}
</span>
</p>
<p>
<strong>{text.translation}:</strong>{" "}
<span lang={pipelineFailure.tgt_lang}>
{pipelineFailure.translation || text.translationUnavailable}
</span>
</p>
<p>{text.pipelineFailureAction}</p>
<button className="escalate" disabled={closed} type="button" onClick={() => void handleEscalate()}>
{text.requestInterpreter}
</button>
</article>
) : 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 (
<article className="confirmation confirmation-mask" key={turn.id}>
<p>{text.patientSafeMask}</p>
<button type="button" onClick={() => setOpenReview(turn.id)}>{text.openReview}</button>
{turn.risk_tier === "critical" ? <button className="escalate" disabled={closed} type="button" onClick={() => void handleEscalate()}>{text.escalate}</button> : null}
</article>
);
}
return (
<article className="confirmation" key={turn.id} ref={reviewRef} tabIndex={-1} aria-label={`${text.confirmations} ${turn.seq}`}>
<p className="sr-only" role="status">{text.reviewOpened}</p>
<div>
<p className="meta">{text.confirmationRequiredLabel} · {riskText(text, turn.risk_tier)}</p>
<p><strong>{text.speaker}:</strong> {turn.speaker === "doctor" ? text.doctor : text.patient}</p>
<p><strong>{text.sourceLanguage}:</strong> {turn.src_lang === "vi" ? text.vietnamese : text.english}</p>
<p><strong>{text.source}:</strong> <span lang={turn.src_lang}>{turn.source_text}</span></p>
<p><strong>{text.draftTranslation}:</strong> <span lang={turn.tgt_lang}>{turn.translation}</span></p>
<p><strong>{text.riskReason}:</strong> {riskText(text, turn.risk_tier)}</p>
{turn.risk_spans.length ? <ul className="risk-list" aria-label={text.criticalEntities}>{turn.risk_spans.map((span, index) => <li className={`risk-badge ${span.severity}`} key={`${span.kind}-${index}`}>{riskText(text, span.severity)}: {riskKindText(text, span.kind)}</li>)}</ul> : null}
{turn.readback?.entities.length ? (
<div className="readback">
<p><strong>{text.criticalEntities}:</strong></p>
<table>
<thead><tr><th>{text.entity}</th><th>{text.source}</th><th>{text.translation}</th></tr></thead>
<tbody>{turn.readback.entities.map((entity, index) => <tr key={`${entity.kind}-${index}`}><td>{riskKindText(text, entity.kind)}</td><td lang={turn.src_lang}>{entity.source_text}</td><td lang={turn.tgt_lang}>{entity.translated_text}</td></tr>)}</tbody>
</table>
</div>
) : null}
<label className="edit-translation">
{text.editTranslation}
<textarea value={edits[turn.id] ?? turn.translation} onChange={(event) => setEdits((current) => ({ ...current, [turn.id]: event.target.value }))} />
</label>
{editIsEmpty ? <p className="error">{text.editRequired}</p> : null}
</div>
<div className="confirmation-actions">
{edited ? <button disabled={closed || editIsEmpty} type="button" onClick={() => void handleConfirm(turn, edits[turn.id])}>{text.saveEditAndPlay}</button> : <button disabled={closed} type="button" onClick={() => void handleConfirm(turn)}>{text.confirmAndPlay}</button>}
{turn.risk_tier === "critical" ? <button className="escalate" disabled={closed} type="button" onClick={() => void handleEscalate()}>{text.escalate}</button> : null}
</div>
</article>
);
})}
</section>
<Transcript
language={language}
turns={turns}
onRepeat={(turn) => { setSpeaker(turn.speaker); setFocusTarget({ speaker: turn.speaker, target: "talk" }); }}
onType={(turn) => { setSpeaker(turn.speaker); setFocusTarget({ speaker: turn.speaker, target: "type" }); }}
onFeedback={async (turnId, reason, comment) => {
await submitFeedback(turnId, { reason, comment });
}}
/>
</main>
);
}
function MicGlyph() {
return (
<svg aria-hidden="true" viewBox="0 0 24 24" width="26" height="26" focusable="false">
<rect x="9" y="3" width="6" height="11" rx="3" fill="none" stroke="currentColor" strokeWidth="1.8" />
<path d="M6 11a6 6 0 0 0 12 0M12 17v4M9 21h6" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function riskText(text: (typeof copy)[Language]["workspace"], value: string) {
return text.risk[value as keyof typeof text.risk] ?? text.risk.other;
}
function riskKindText(text: (typeof copy)[Language]["workspace"], value: string) {
return text.riskKind[value as keyof typeof text.riskKind] ?? text.riskKind.other;
}