"use client"; import { Mic, Square, Sparkles, Trash2 } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; import { Dialog, DialogDescription, DialogFooter, DialogHeader, DialogPanel, DialogPopup, DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Spinner } from "@/components/ui/spinner"; import { Tabs, TabsList, TabsPanel, TabsTab, } from "@/components/ui/tabs"; import { Textarea } from "@/components/ui/textarea"; import { uploadAttachment } from "@/lib/attachments"; import type { Encounter, Patient } from "@/lib/patients"; import { draftNote, saveNote, transcribeRecording } from "@/lib/scribe"; import { notify } from "@/lib/toast"; import { ApiError } from "@/lib/api-client"; import { useWalletSync } from "@/components/wallet/use-wallet-sync"; import { DialogStepper, WalletSyncStep, } from "@/components/wallet/wallet-sync-step"; type Phase = "input" | "processing" | "review"; type InputTab = "record" | "paste"; type RecState = "idle" | "recording" | "recorded"; // Pick a supported audio mime for MediaRecorder (Opus in WebM/OGG is tiny for // speech; Safari falls back to mp4). Returns "" to let the browser choose. function pickAudioMime(): string { if (typeof MediaRecorder === "undefined") return ""; const candidates = [ "audio/webm;codecs=opus", "audio/webm", "audio/ogg;codecs=opus", "audio/mp4", ]; return candidates.find((m) => MediaRecorder.isTypeSupported(m)) ?? ""; } function fmtElapsed(sec: number): string { const m = Math.floor(sec / 60); const s = sec % 60; return `${m}:${String(s).padStart(2, "0")}`; } // The ambient AI scribe: record or paste a visit conversation, draft a SOAP // encounter note, review it, and append it to the patient record. export function ScribeDialog({ patient, open, onOpenChange, onSaved, }: { patient: Patient; open: boolean; onOpenChange: (open: boolean) => void; onSaved: (updated: Patient) => void; }) { const { t } = useTranslation(); const [phase, setPhase] = useState("input"); const [tab, setTab] = useState("record"); const [recState, setRecState] = useState("idle"); const [elapsed, setElapsed] = useState(0); const [transcript, setTranscript] = useState(""); const [visitType, setVisitType] = useState(""); const [draft, setDraft] = useState(null); const [veilNote, setVeilNote] = useState(null); const [error, setError] = useState(null); const [walletStep, setWalletStep] = useState(false); const sync = useWalletSync(patient.fileNumber); const mediaRef = useRef(null); const chunksRef = useRef([]); const blobRef = useRef(null); const timerRef = useRef | null>(null); // Tear down any live recording + object state when the dialog closes. const stopTracks = () => { mediaRef.current?.stream.getTracks().forEach((track) => track.stop()); mediaRef.current = null; if (timerRef.current) clearInterval(timerRef.current); timerRef.current = null; }; // Stop tracks on unmount. useEffect(() => () => stopTracks(), []); // Reset all state for the next open. Called on every close (the parent only // ever closes this dialog through our onOpenChange), so no reset-in-effect. const reset = () => { setPhase("input"); setTab("record"); setRecState("idle"); setElapsed(0); setTranscript(""); setVisitType(""); setDraft(null); setVeilNote(null); setError(null); setWalletStep(false); sync.reset(); chunksRef.current = []; blobRef.current = null; }; const handleOpenChange = (next: boolean) => { if (!next) { stopTracks(); reset(); } onOpenChange(next); }; const startRecording = async () => { setError(null); try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); const mime = pickAudioMime(); const recorder = new MediaRecorder( stream, mime ? { mimeType: mime } : undefined, ); chunksRef.current = []; recorder.ondataavailable = (e) => { if (e.data.size > 0) chunksRef.current.push(e.data); }; recorder.onstop = () => { blobRef.current = new Blob(chunksRef.current, { type: recorder.mimeType || "audio/webm", }); setRecState("recorded"); }; recorder.start(); mediaRef.current = recorder; setRecState("recording"); setElapsed(0); timerRef.current = setInterval(() => setElapsed((s) => s + 1), 1000); } catch { setError(t("scribe.errors.mic")); } }; const stopRecording = () => { mediaRef.current?.stop(); mediaRef.current?.stream.getTracks().forEach((track) => track.stop()); if (timerRef.current) clearInterval(timerRef.current); timerRef.current = null; }; const discardRecording = () => { blobRef.current = null; chunksRef.current = []; setRecState("idle"); setElapsed(0); }; const filenameFor = (blob: Blob): string => { const ext = blob.type.includes("mp4") ? "m4a" : blob.type.includes("ogg") ? "ogg" : "webm"; return `visit-${patient.fileNumber}-${Date.now()}.${ext}`; }; const generate = async () => { setError(null); setPhase("processing"); try { let text = transcript.trim(); if (tab === "record") { const blob = blobRef.current; if (!blob) { setError(t("scribe.errors.noRecording")); setPhase("input"); return; } // Store the recording as a patient attachment (auditable), then // transcribe it server-side. const file = new File([blob], filenameFor(blob), { type: blob.type }); const attachment = await uploadAttachment({ file, fileNumber: patient.fileNumber, labKey: "scribe", }); const res = await transcribeRecording(attachment.id); text = res.transcript.trim(); setTranscript(text); } if (!text) { setError(t("scribe.errors.empty")); setPhase("input"); return; } const { draft: note, veil } = await draftNote({ fileNumber: patient.fileNumber, transcript: text, visitType: visitType.trim() || undefined, }); setDraft(note); setVeilNote( veil.active ? t("scribe.review.veil", { provider: veil.provider }) : null, ); setPhase("review"); } catch (err) { setError( err instanceof ApiError ? err.message : t("scribe.errors.generic"), ); setPhase("input"); } }; const save = async () => { if (!draft) return; setPhase("processing"); try { const updated = await saveNote(patient.fileNumber, draft); notify.success(t("scribe.saved.title"), patient.name); onSaved(updated); if (sync.linked) { setPhase("review"); setWalletStep(true); } else { handleOpenChange(false); } } catch (err) { setError( err instanceof ApiError ? err.message : t("scribe.errors.generic"), ); setPhase("review"); } }; const busy = phase === "processing"; return ( {t("scribe.title")} {t("scribe.subtitle", { name: patient.name })} {sync.linked && ( )} {walletStep ? ( handleOpenChange(false)} patientName={patient.name} summary={t("walletSync.summary.note")} sync={sync} /> ) : ( <> {phase === "review" && draft ? (
setDraft({ ...draft, type: e.target.value }) } value={draft.type} />
setDraft({ ...draft, date: e.target.value }) } type="date" value={draft.date} />