import { useCallback, useEffect, useRef, useState } from "react"; import type { VisitTurn } from "./types"; const API_BASE: string = import.meta.env.VITE_API_BASE ?? ""; // The websocket needs its own base. Vercel rewrites /api/* to the API host so // HTTP is same-origin and no CORS allow-list applies, but a rewrite will not // carry a websocket upgrade to an external host — so /ws/* still goes direct. // That is safe: the origin check in scribe/carepath/main.py is HTTP middleware // and never runs for a websocket scope. Verified against the live Space from // carepath-medicaltranslation.vercel.app: the socket opens. const WS_BASE: string = import.meta.env.VITE_WS_BASE ?? API_BASE; export type ConnectionState = "idle" | "connecting" | "open" | "closed"; export interface VisitError { message: string; retryable: boolean; } function socketUrl(visitId: string): string { const base = WS_BASE || window.location.origin; const url = new URL(`/ws/sessions/${visitId}`, base); url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; return url.toString(); } /** * Client for the existing interpreter websocket protocol * (session_state / turn_result / turn_error). Reconnecting replays the whole * transcript for free: the server sends session_state on every accept. */ export function useVisitSocket(visitId: string | null) { const [turns, setTurns] = useState([]); const [connection, setConnection] = useState("idle"); const [error, setError] = useState(null); const [pending, setPending] = useState(false); const socketRef = useRef(null); const onTurnRef = useRef<((turn: VisitTurn) => void) | null>(null); useEffect(() => { if (!visitId) return; setConnection("connecting"); const socket = new WebSocket(socketUrl(visitId)); socketRef.current = socket; socket.onopen = () => setConnection("open"); socket.onclose = () => setConnection("closed"); socket.onerror = () => setError({ message: "Mất kết nối tới máy chủ.", retryable: true }); socket.onmessage = (event) => { const payload = JSON.parse(event.data as string); if (payload.type === "session_state") { setTurns(payload.turns ?? []); return; } if (payload.type === "turn_result") { setPending(false); setError(null); const turn: VisitTurn = { ...payload.turn, requires_confirmation: payload.requires_confirmation, low_confidence: payload.low_confidence, }; setTurns((current) => [...current.filter((item) => item.id !== turn.id), turn]); onTurnRef.current?.(turn); return; } if (payload.type === "turn_error") { setPending(false); setError({ message: payload.message, retryable: payload.retryable !== false }); } }; return () => { socketRef.current = null; socket.close(); }; }, [visitId]); const sendTurn = useCallback( (speaker: string, lang: string, text: string, confidence?: number) => { const socket = socketRef.current; if (!socket || socket.readyState !== WebSocket.OPEN) { setError({ message: "Chưa kết nối tới máy chủ.", retryable: true }); return; } setPending(true); setError(null); socket.send( JSON.stringify({ type: "text_turn", speaker, lang, text, // Omitted rather than defaulted when the browser does not report one: // the server treats an absent confidence as a typed turn. ...(confidence === undefined ? {} : { confidence }), }), ); }, [], ); /** Patch a turn in place after the clinician confirms or edits it. */ const replaceTurn = useCallback((turn: VisitTurn) => { setTurns((current) => current.map((item) => (item.id === turn.id ? turn : item))); }, []); /** Merge turns created outside the socket, e.g. read from a document. */ const addTurns = useCallback((incoming: VisitTurn[]) => { setTurns((current) => { const ids = new Set(incoming.map((turn) => turn.id)); return [...current.filter((turn) => !ids.has(turn.id)), ...incoming]; }); }, []); const setOnTurn = useCallback((handler: ((turn: VisitTurn) => void) | null) => { onTurnRef.current = handler; }, []); return { turns, connection, error, pending, sendTurn, replaceTurn, addTurns, setOnTurn, clearError: () => setError(null), }; } export async function startVisit(patientContext: Record): Promise { const response = await fetch(`${API_BASE}/api/sessions`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ consent: { ai_disclosure: true, interpreter_right: true, scope: "translation_aid", recorded_at: new Date().toISOString(), patient_context: patientContext, }, }), }); if (!response.ok) throw new Error(`start visit failed: ${response.status}`); return (await response.json()).session_id as string; } export async function confirmTurn( turnId: string, editedTranslation?: string, ): Promise { const response = await fetch(`${API_BASE}/api/turns/${turnId}/confirm`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ edited_translation: editedTranslation ?? null }), }); if (!response.ok) throw new Error(`confirm failed: ${response.status}`); return (await response.json()) as VisitTurn; } export async function escalateVisit(visitId: string): Promise { await fetch(`${API_BASE}/api/sessions/${visitId}/escalate`, { method: "POST" }); } export async function finishVisit(visitId: string) { const response = await fetch(`${API_BASE}/api/v1/visits/${visitId}/note`, { method: "POST", headers: { "Content-Type": "application/json" }, }); if (!response.ok) { const detail = await response.text(); throw new Error(detail || `finish failed: ${response.status}`); } return response.json(); }