diff --git a/client/src/components/EncounterToolbar.tsx b/client/src/components/EncounterToolbar.tsx new file mode 100644 index 0000000..3374201 --- /dev/null +++ b/client/src/components/EncounterToolbar.tsx @@ -0,0 +1,159 @@ +// ============================================================ +// Encounter Toolbar — Save / New Patient / Load + label input. +// Mirrors the vanilla saveFromTab / clearTab flow from +// public/js/encounters.js. Each note page gets one of these so +// transcripts + generated notes persist across sign-outs. +// ============================================================ + +import { useEffect, useState } from 'react'; +import { + saveEncounter, loadEncounter, listSavedEncounters, + getSavedEncId, clearTabState, + type EncType, type SavedEncounterListEntry, type LoadedEncounter, +} from '@/lib/encounter-persistence'; + +const input = 'rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-ring'; +const btn = 'inline-flex items-center gap-1 rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium hover:bg-muted disabled:opacity-50'; +const btnPrimary = 'inline-flex items-center gap-1 rounded-md bg-primary text-primary-foreground px-3 py-1.5 text-xs font-medium disabled:opacity-50'; + +interface Props { + type: EncType; + label: string; + setLabel: (s: string) => void; + transcript: string; + generatedNote: string; + partialData?: unknown; + onLoad: (enc: LoadedEncounter) => void; + onClear: () => void; +} + +export default function EncounterToolbar({ + type, label, setLabel, transcript, generatedNote, partialData, onLoad, onClear, +}: Props) { + const [msg, setMsg] = useState<{ kind: 'ok' | 'err' | 'info'; text: string } | null>(null); + const [saving, setSaving] = useState(false); + const [popoverOpen, setPopoverOpen] = useState(false); + const [saved, setSaved] = useState([]); + const [loadingList, setLoadingList] = useState(false); + const [search, setSearch] = useState(''); + const [savedId, setSavedId] = useState(getSavedEncId(type)); + + // Sync the savedId chip whenever sessionStorage changes (e.g. after Load). + useEffect(() => { + const v = getSavedEncId(type); + setSavedId(v); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [transcript, generatedNote]); + + async function save() { + if (!label.trim()) { setMsg({ kind: 'err', text: 'Enter a patient label first' }); return; } + setMsg(null); setSaving(true); + try { + const r = await saveEncounter({ type, label, transcript, generatedNote, partialData }); + setSavedId(r.id); + setMsg({ kind: 'ok', text: 'Saved (' + label + ')' }); + } catch (e) { + setMsg({ kind: 'err', text: (e as Error).message }); + } finally { setSaving(false); } + } + + function newPatient() { + clearTabState(type); + setSavedId(null); + setLabel(''); + onClear(); + setMsg({ kind: 'info', text: 'New patient — fields cleared.' }); + } + + async function openLoad() { + if (popoverOpen) { setPopoverOpen(false); return; } + setPopoverOpen(true); + setLoadingList(true); + try { + const list = await listSavedEncounters(); + setSaved(list.filter((e) => e.enc_type === type)); + } finally { setLoadingList(false); } + } + async function pick(id: number) { + try { + const enc = await loadEncounter(id); + setSavedId(id); + onLoad(enc); + setPopoverOpen(false); + setMsg({ kind: 'ok', text: 'Loaded ' + (enc.label || '') }); + } catch (e) { + setMsg({ kind: 'err', text: (e as Error).message }); + } + } + + const filtered = search + ? saved.filter((e) => (e.label || '').toLowerCase().includes(search.toLowerCase())) + : saved; + + return ( +
+
+ setLabel(e.target.value)} + data-testid="enc-label" + /> + +
+ + {popoverOpen && ( +
+
+ setSearch(e.target.value)} + autoFocus + data-testid="enc-load-search" + /> +
+ {loadingList &&
Loading…
} + {!loadingList && filtered.length === 0 && ( +
No saved {type} encounters.
+ )} + {filtered.map((e) => ( + + ))} +
+ )} +
+ + {savedId != null && ( + Draft #{savedId} + )} +
+ {msg && ( +
{msg.text}
+ )} +
+ ); +} diff --git a/client/src/components/Recorder.tsx b/client/src/components/Recorder.tsx new file mode 100644 index 0000000..ca5ea28 --- /dev/null +++ b/client/src/components/Recorder.tsx @@ -0,0 +1,215 @@ +// ============================================================ +// Recorder — shared mic-capture component for every note page. +// Faithful React port of public/js/liveEncounter.js (and the +// equivalent dictation/SOAP recording wrappers). Mirror behavior: +// • Mic permission via getUserMedia (mono / 16 kHz / EC+NS) +// • Live preview via Web Speech API (when enabled by user) +// • Pause / Resume native MediaRecorder where supported +// • On Stop → upload blob to /api/transcribe +// • If transcription unavailable, fall back to the live preview text +// • Failed uploads → /api/audio-backups (or IndexedDB) for retry +// +// The recorder is dumb about persistence — the parent note page +// owns the transcript text and uses encounter-persistence.ts to +// save/resume across sign-outs. +// ============================================================ + +import { useEffect, useRef, useState } from 'react'; +import { AudioRecorder } from '@/lib/recorder'; +import { transcribeAudio, isTranscribeAvailable, checkTranscribeStatus } from '@/lib/transcribe'; +import { createSpeechSession, isSpeechRecognitionEnabled, deduplicateFinal, type SpeechHandle } from '@/lib/web-speech'; + +interface Props { + module: string; // 'encounter' | 'dictation' | 'soap' | … + // Called when transcription completes. The text replaces (or merges with) + // whatever the parent currently has in the transcript field. + onTranscript: (text: string, meta: { provider?: string; durationSec: number; appended: boolean }) => void; + // Called continuously with the live (interim) preview while recording. + // Parents can show a faded "interim" string concatenated to the + // confirmed transcript for instant visual feedback. + onInterim?: (text: string) => void; + // Called when transcription fails (so the parent can decide what to + // do — typically appending the live preview text instead). + onError?: (msg: string) => void; + disabled?: boolean; +} + +const btnRecord = 'inline-flex items-center gap-2 px-4 py-2 rounded-md text-sm font-semibold border transition-colors'; + +export default function Recorder({ module, onTranscript, onInterim, onError, disabled }: Props) { + const recorderRef = useRef(null); + const speechRef = useRef(null); + const finalTextRef = useRef(''); + const intervalRef = useRef(null); + const startTimeRef = useRef(0); + const pauseAccumRef = useRef(0); + const pauseStartRef = useRef(0); + + const [state, setState] = useState<'idle' | 'recording' | 'paused' | 'transcribing'>('idle'); + const [seconds, setSeconds] = useState(0); + + useEffect(() => { + if (isTranscribeAvailable() === null) checkTranscribeStatus(); + }, []); + + // Stop everything cleanly on unmount (page navigation while recording). + useEffect(() => { + return () => { + if (intervalRef.current) window.clearInterval(intervalRef.current); + if (speechRef.current) speechRef.current.stop(); + if (recorderRef.current) recorderRef.current.stop().catch(() => { /* ignore */ }); + }; + }, []); + + function tickStart() { + if (intervalRef.current) window.clearInterval(intervalRef.current); + intervalRef.current = window.setInterval(() => { + const now = Date.now(); + const elapsed = Math.floor((now - startTimeRef.current - pauseAccumRef.current) / 1000); + setSeconds(elapsed); + }, 1000); + } + function tickStop() { + if (intervalRef.current) { window.clearInterval(intervalRef.current); intervalRef.current = null; } + } + + async function start() { + if (state !== 'idle') return; + finalTextRef.current = ''; + pauseAccumRef.current = 0; + setSeconds(0); + try { + const rec = new AudioRecorder(); + await rec.start(); + recorderRef.current = rec; + startTimeRef.current = Date.now(); + tickStart(); + setState('recording'); + // Live preview via Web Speech (only if user enabled it in Settings). + if (isSpeechRecognitionEnabled()) { + const handle = createSpeechSession({ + onFinal: (chunk) => { + const deduped = deduplicateFinal(chunk, finalTextRef.current); + finalTextRef.current += deduped; + onInterim?.(finalTextRef.current); + }, + onInterim: (interim) => onInterim?.(finalTextRef.current + interim), + onError: () => { /* swallow */ }, + }); + speechRef.current = handle; + handle?.start(); + } + } catch { + onError?.('Microphone permission denied'); + setState('idle'); + } + } + + function pause() { + if (state !== 'recording') return; + recorderRef.current?.pause(); + speechRef.current?.stop(); + pauseStartRef.current = Date.now(); + tickStop(); + setState('paused'); + } + function resume() { + if (state !== 'paused') return; + recorderRef.current?.resume(); + pauseAccumRef.current += Date.now() - pauseStartRef.current; + tickStart(); + if (isSpeechRecognitionEnabled() && !speechRef.current) { + const handle = createSpeechSession({ + onFinal: (chunk) => { + const deduped = deduplicateFinal(chunk, finalTextRef.current); + finalTextRef.current += deduped; + onInterim?.(finalTextRef.current); + }, + onInterim: (interim) => onInterim?.(finalTextRef.current + interim), + }); + speechRef.current = handle; + } + speechRef.current?.start(); + setState('recording'); + } + + async function stop() { + if (state !== 'recording' && state !== 'paused') return; + const liveText = finalTextRef.current.trim(); + speechRef.current?.stop(); + speechRef.current = null; + tickStop(); + const dur = seconds; + setState('transcribing'); + try { + const blob = await recorderRef.current!.stop(); + recorderRef.current = null; + if (!blob || blob.size === 0) { + // Recording produced nothing — fall back to live preview if any. + if (liveText) onTranscript(liveText, { durationSec: dur, appended: true }); + setState('idle'); + return; + } + // Server-side too-large guard mirrors the vanilla 24 MB cap. + if (blob.size > 24 * 1024 * 1024) { + onError?.('Recording too large for AI transcription — using live transcript'); + if (liveText) onTranscript(liveText, { durationSec: dur, appended: true }); + setState('idle'); + return; + } + const result = await transcribeAudio(blob, module); + if (result.success && result.text) { + onTranscript(result.text, { provider: result.provider, durationSec: dur, appended: false }); + } else if (result.noProvider) { + if (liveText) onTranscript(liveText, { durationSec: dur, appended: true }); + else onError?.('No transcription API configured'); + } else { + if (liveText) onTranscript(liveText, { durationSec: dur, appended: true }); + else onError?.(result.error || 'Transcription failed'); + } + } catch (e) { + onError?.((e as Error).message); + } finally { + setState('idle'); + } + } + + const mm = String(Math.floor(seconds / 60)).padStart(2, '0'); + const ss = String(seconds % 60).padStart(2, '0'); + + return ( +
+ {state === 'idle' && ( + + )} + {(state === 'recording' || state === 'paused') && ( + <> + + + + + {state === 'paused' ? 'paused ' : ''}{mm}:{ss} + + + )} + {state === 'transcribing' && ( + + ⌛ Transcribing… + + )} +
+ ); +} diff --git a/client/src/lib/encounter-persistence.ts b/client/src/lib/encounter-persistence.ts new file mode 100644 index 0000000..875f293 --- /dev/null +++ b/client/src/lib/encounter-persistence.ts @@ -0,0 +1,124 @@ +// ============================================================ +// Encounter persistence — port of public/js/encounters.js save/load +// flow. sessionStorage keys survive page refresh + sign-out so a +// resumed session lands on the same DB row instead of creating a +// duplicate. Optimistic-locking via expected_version preserved. +// ============================================================ + +export type EncType = 'encounter' | 'dictation' | 'hospital' | 'chart' | 'wellvisit' | 'sickvisit' | 'soap'; + +const SAVED_KEY = (t: EncType) => '_savedEncId_' + t; +const IDEMP_KEY = (t: EncType) => '_idempKey_' + t; + +export function getSavedEncId(t: EncType): number | null { + try { + const v = sessionStorage.getItem(SAVED_KEY(t)); + return v ? Number(v) : null; + } catch { return null; } +} +export function setSavedEncId(t: EncType, id: number | null) { + try { + if (id == null) sessionStorage.removeItem(SAVED_KEY(t)); + else sessionStorage.setItem(SAVED_KEY(t), String(id)); + } catch { /* ignore */ } +} + +export function getIdempotencyKey(t: EncType): string { + try { + const existing = sessionStorage.getItem(IDEMP_KEY(t)); + if (existing) return existing; + } catch { /* ignore */ } + const fresh = (typeof crypto !== 'undefined' && crypto.randomUUID) + ? crypto.randomUUID() + : 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = Math.random() * 16 | 0; + return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16); + }); + try { sessionStorage.setItem(IDEMP_KEY(t), fresh); } catch { /* ignore */ } + return fresh; +} +export function resetIdempotencyKey(t: EncType) { + try { sessionStorage.removeItem(IDEMP_KEY(t)); } catch { /* ignore */ } +} + +const _versions = new Map(); + +export interface SaveEncounterInput { + type: EncType; + label: string; + transcript: string; + generatedNote: string; + partialData?: unknown; // serialized to JSON +} +export interface SaveEncounterResult { + id: number; + version: number; +} + +export async function saveEncounter(input: SaveEncounterInput): Promise { + if (!input.label.trim()) throw new Error('Enter a patient label first'); + const id = getSavedEncId(input.type); + const body: Record = { + label: input.label.trim(), + enc_type: input.type, + transcript: input.transcript, + generated_note: input.generatedNote, + partial_data: input.partialData != null ? JSON.stringify(input.partialData) : undefined, + idempotency_key: getIdempotencyKey(input.type), + }; + if (id != null) { + body.id = id; + const expected = _versions.get(id); + if (expected != null) body.expected_version = expected; + } + const r = await fetch('/api/encounters/saved', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const data = await r.json(); + if (r.status === 409) throw new Error('Someone else edited this encounter. Reload to see the latest version.'); + if (!data.success) throw new Error(data.error || 'Save failed'); + if (data.id != null) { + setSavedEncId(input.type, data.id); + if (data.version != null) _versions.set(data.id, data.version); + } + return { id: data.id, version: data.version }; +} + +export interface LoadedEncounter { + id: number; + label: string; + enc_type: string; + transcript: string; + generated_note: string; + partial_data: string | null; + version?: number; +} +export async function loadEncounter(id: number): Promise { + const r = await fetch('/api/encounters/saved/' + id, { credentials: 'include' }); + const data = await r.json(); + if (!data.success) throw new Error(data.error || 'Load failed'); + if (data.encounter && data.encounter.version != null) _versions.set(id, data.encounter.version); + return data.encounter as LoadedEncounter; +} + +export interface SavedEncounterListEntry { + id: number; + label: string; + enc_type: EncType; + status?: string; + updated_at: string; + expires_at: string; +} +export async function listSavedEncounters(): Promise { + const r = await fetch('/api/encounters/saved', { credentials: 'include' }); + const data = await r.json(); + return (data.encounters || []) as SavedEncounterListEntry[]; +} + +export function clearTabState(t: EncType) { + setSavedEncId(t, null); + resetIdempotencyKey(t); +} diff --git a/client/src/lib/recorder.ts b/client/src/lib/recorder.ts new file mode 100644 index 0000000..9dbfebb --- /dev/null +++ b/client/src/lib/recorder.ts @@ -0,0 +1,59 @@ +// ============================================================ +// AudioRecorder — verbatim port of public/js/app.js:660-687. +// Same constraints (mono, 16kHz, EC+NS), same opus 32kbps target. +// Exposes the underlying mediaRecorder + stream so the React +// Recorder component can pause/resume + restart on the same stream +// (matches vanilla liveEncounter.js fallback behavior). +// ============================================================ + +export class AudioRecorder { + mediaRecorder: MediaRecorder | null = null; + chunks: Blob[] = []; + stream: MediaStream | null = null; + + start(): Promise { + this.chunks = []; + return navigator.mediaDevices.getUserMedia({ + audio: { channelCount: 1, sampleRate: 16000, echoCancellation: true, noiseSuppression: true }, + }).then((stream) => { + this.stream = stream; + const mime = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm'; + this.mediaRecorder = new MediaRecorder(stream, { mimeType: mime, audioBitsPerSecond: 32000 }); + this.mediaRecorder.ondataavailable = (e) => { if (e.data.size > 0) this.chunks.push(e.data); }; + this.mediaRecorder.start(1000); + }); + } + + stop(): Promise { + return new Promise((resolve) => { + if (!this.mediaRecorder || this.mediaRecorder.state === 'inactive') { resolve(null); return; } + this.mediaRecorder.onstop = () => { + const blob = new Blob(this.chunks, { type: this.mediaRecorder!.mimeType }); + if (this.stream) this.stream.getTracks().forEach((t) => t.stop()); + resolve(blob); + }; + this.mediaRecorder.stop(); + }); + } + + pause() { + if (this.mediaRecorder && typeof this.mediaRecorder.pause === 'function' && this.mediaRecorder.state === 'recording') { + try { this.mediaRecorder.pause(); } catch { /* ignore */ } + } + } + + resume() { + if (!this.mediaRecorder) return; + try { + if (typeof this.mediaRecorder.resume === 'function' && this.mediaRecorder.state === 'paused') { + this.mediaRecorder.resume(); + } else if (this.mediaRecorder.state === 'inactive' && this.stream && this.stream.active) { + // Browser killed it — restart on the same stream (matches vanilla fallback). + const mime = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm'; + this.mediaRecorder = new MediaRecorder(this.stream, { mimeType: mime, audioBitsPerSecond: 32000 }); + this.mediaRecorder.ondataavailable = (e) => { if (e.data.size > 0) this.chunks.push(e.data); }; + this.mediaRecorder.start(1000); + } + } catch { /* ignore */ } + } +} diff --git a/client/src/lib/transcribe.ts b/client/src/lib/transcribe.ts new file mode 100644 index 0000000..3558025 --- /dev/null +++ b/client/src/lib/transcribe.ts @@ -0,0 +1,99 @@ +// ============================================================ +// Transcribe + audio-backup helpers — port of public/js/app.js +// transcribeAudio() / _serverTranscribe() / saveAudioBackup(). +// +// Server-first via /api/transcribe (cookie auth — same-origin). +// On failure, blob is saved to /api/audio-backups so the user can +// retry from Settings → Audio Backups (or vanilla Audio Backups page). +// IndexedDB fallback preserved from vanilla audioBackup.js. +// ============================================================ + +export interface TranscribeResult { + success: boolean; + text?: string; + provider?: string; + duration?: number; + noProvider?: boolean; + error?: string; +} + +let transcribeAvailable: boolean | null = null; +let transcribeProvider = 'none'; + +export async function checkTranscribeStatus(): Promise { + try { + const r = await fetch('/api/transcribe/status', { credentials: 'include' }); + const data = await r.json(); + transcribeAvailable = !!data.available; + transcribeProvider = data.provider || 'none'; + } catch { + transcribeAvailable = false; + } +} + +export function isTranscribeAvailable(): boolean | null { return transcribeAvailable; } +export function getTranscribeProvider(): string { return transcribeProvider; } + +export async function transcribeAudio(blob: Blob, module = 'encounter'): Promise { + if (transcribeAvailable === null) await checkTranscribeStatus(); + if (transcribeAvailable === false) { + return { success: false, noProvider: true, error: 'No transcription API configured — using live transcript' }; + } + const form = new FormData(); + form.append('audio', blob, 'audio.webm'); + try { + const r = await fetch('/api/transcribe', { method: 'POST', credentials: 'include', body: form }); + const data: TranscribeResult = await r.json(); + if (!data.success && blob.size > 0) { + // Best-effort: save the blob so the user can retry later. + saveAudioBackup(blob, module + '-failed-transcription').catch(() => { /* ignore */ }); + } + return data; + } catch (e) { + saveAudioBackup(blob, module + '-failed-transcription').catch(() => { /* ignore */ }); + return { success: false, error: (e as Error).message }; + } +} + +// ── Audio backup (server-first, IndexedDB fallback) ── +export async function saveAudioBackup(blob: Blob, module: string): Promise { + // Server first. + try { + const form = new FormData(); + form.append('audio', blob, 'audio.webm'); + form.append('module', module); + const r = await fetch('/api/audio-backups', { method: 'POST', credentials: 'include', body: form }); + const data = await r.json(); + if (data.success && data.id) return data.id; + } catch { /* fall through */ } + // IndexedDB fallback. + return saveToIndexedDB(blob, module); +} + +const DB_NAME = 'PedScribeAudioBackup'; +const STORE = 'recordings'; +let _db: IDBDatabase | null = null; +function openDB(): Promise { + if (_db) return Promise.resolve(_db); + return new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, 1); + req.onupgradeneeded = (e) => { + const db = (e.target as IDBOpenDBRequest).result; + if (!db.objectStoreNames.contains(STORE)) { + const store = db.createObjectStore(STORE, { keyPath: 'id', autoIncrement: true }); + store.createIndex('timestamp', 'timestamp', { unique: false }); + } + }; + req.onsuccess = () => { _db = req.result; resolve(_db); }; + req.onerror = () => reject(new Error('IndexedDB open failed')); + }); +} +function saveToIndexedDB(blob: Blob, module: string): Promise { + return openDB().then((db) => new Promise((resolve, reject) => { + const tx = db.transaction(STORE, 'readwrite'); + const store = tx.objectStore(STORE); + const req = store.add({ blob, module, timestamp: Date.now(), size: blob.size, mimeType: blob.type }); + req.onsuccess = () => resolve(req.result as number); + req.onerror = () => reject(new Error('Failed to save audio backup')); + })).catch(() => null); +} diff --git a/client/src/lib/web-speech.ts b/client/src/lib/web-speech.ts new file mode 100644 index 0000000..3d36f91 --- /dev/null +++ b/client/src/lib/web-speech.ts @@ -0,0 +1,117 @@ +// ============================================================ +// Web Speech API wrapper — verbatim port of +// public/js/speechRecognition.js. Browser-side live transcription +// for showing words as the user speaks. Falls back to no-op when +// the API is unsupported. Privacy warning preserved. +// ============================================================ + +const STORAGE_ENABLED = 'ped_web_speech_enabled'; + +interface SpeechRecognitionResult { + isFinal: boolean; + [index: number]: { transcript: string }; +} +interface SpeechRecognitionResults { + length: number; + [index: number]: SpeechRecognitionResult; +} +interface SpeechRecognitionEvent { + resultIndex: number; + results: SpeechRecognitionResults; +} +interface SpeechRecognitionErrorEvent { error: string } +interface SpeechRecognitionLike { + continuous: boolean; + interimResults: boolean; + lang: string; + maxAlternatives: number; + onresult: ((e: SpeechRecognitionEvent) => void) | null; + onerror: ((e: SpeechRecognitionErrorEvent) => void) | null; + onend: (() => void) | null; + onstart: (() => void) | null; + start(): void; + stop(): void; +} +type SRCtor = new () => SpeechRecognitionLike; + +function getCtor(): SRCtor | null { + if (typeof window === 'undefined') return null; + const w = window as unknown as { SpeechRecognition?: SRCtor; webkitSpeechRecognition?: SRCtor }; + return (w.SpeechRecognition || w.webkitSpeechRecognition) || null; +} + +export function isSpeechRecognitionSupported(): boolean { + return !!getCtor(); +} +export function isSpeechRecognitionEnabled(): boolean { + try { return localStorage.getItem(STORAGE_ENABLED) === '1' || localStorage.getItem(STORAGE_ENABLED) === 'true'; } + catch { return false; } +} +export function setSpeechRecognitionEnabled(v: boolean) { + try { localStorage.setItem(STORAGE_ENABLED, v ? 'true' : 'false'); } catch { /* ignore */ } +} + +export interface SpeechHandle { + start: () => void; + stop: () => void; +} + +// Creates a continuous-listening session that calls handlers as words +// come in. Mirrors the liveEncounter.js wiring (final + interim results). +export function createSpeechSession(handlers: { + onFinal: (text: string) => void; + onInterim: (text: string) => void; + onError?: (err: string) => void; +}): SpeechHandle | null { + const Ctor = getCtor(); + if (!Ctor) return null; + let rec: SpeechRecognitionLike | null = null; + let active = false; + + function build(): SpeechRecognitionLike { + const r = new (Ctor as SRCtor)(); + r.continuous = true; + r.interimResults = true; + r.lang = 'en-US'; + r.maxAlternatives = 1; + r.onresult = (e: SpeechRecognitionEvent) => { + let interim = ''; + for (let i = e.resultIndex; i < e.results.length; i++) { + const t = e.results[i][0].transcript; + if (e.results[i].isFinal) handlers.onFinal(t + ' '); + else interim = t; + } + handlers.onInterim(interim); + }; + r.onerror = (e: SpeechRecognitionErrorEvent) => { + if (e.error === 'no-speech' || e.error === 'aborted') return; + handlers.onError?.(e.error); + }; + r.onend = () => { if (active) try { r.start(); } catch { /* ignore */ } }; + return r; + } + + return { + start: () => { active = true; rec = build(); try { rec.start(); } catch { /* ignore */ } }, + stop: () => { active = false; if (rec) try { rec.stop(); } catch { /* ignore */ } }, + }; +} + +// Verbatim port of deduplicateFinal from public/js/app.js:964-984. +export function deduplicateFinal(newText: string, existingText: string): string { + if (!newText || !existingText) return newText; + const trimmed = newText.trim(); + if (!trimmed) return ''; + if (existingText.trimEnd().endsWith(trimmed)) return ''; + const words = trimmed.split(/\s+/); + if (words.length >= 3) { + const tail = existingText.trimEnd().split(/\s+/).slice(-words.length).join(' '); + if (tail === trimmed) return ''; + const half = Math.ceil(words.length / 2); + const firstHalf = words.slice(0, half).join(' '); + if (existingText.trimEnd().endsWith(firstHalf)) { + return words.slice(half).join(' ') + ' '; + } + } + return newText; +} diff --git a/client/src/pages/ChartReview.tsx b/client/src/pages/ChartReview.tsx index 673c34e..a24783f 100644 --- a/client/src/pages/ChartReview.tsx +++ b/client/src/pages/ChartReview.tsx @@ -6,14 +6,17 @@ import { useState } from 'react'; import { useMutation } from '@tanstack/react-query'; import { api, ApiError } from '@/lib/api'; import type { ChartReviewOk } from '@/shared/types'; +import EncounterToolbar from '@/components/EncounterToolbar'; type ReviewType = 'outpatient' | 'subspecialty' | 'ed'; +const TYPE = 'chart' as const; interface VisitInput { date: string; content: string; labs: string } function emptyVisit(): VisitInput { return { date: '', content: '', labs: '' }; } export default function ChartReview() { + const [label, setLabel] = useState(''); const [type, setType] = useState('outpatient'); const [patientAge, setPatientAge] = useState(''); const [patientGender, setPatientGender] = useState(''); @@ -56,6 +59,34 @@ export default function ChartReview() {

+ { + try { + const parsedVisits = enc.transcript ? JSON.parse(enc.transcript) as VisitInput[] : [emptyVisit()]; + setVisits(parsedVisits.length ? parsedVisits : [emptyVisit()]); + } catch { setVisits([emptyVisit()]); } + setResult(enc.generated_note || null); + try { + const pd = enc.partial_data ? JSON.parse(enc.partial_data) : null; + if (pd?.type) setType(pd.type); + if (pd?.age) setPatientAge(pd.age); + if (pd?.gender) setPatientGender(pd.gender); + if (pd?.pmh) setPmh(pd.pmh); + if (pd?.additionalInstructions) setAdditionalInstructions(pd.additionalInstructions); + } catch { /* ignore */ } + setLabel(enc.label || ''); + }} + onClear={() => { + setVisits([emptyVisit()]); setResult(null); + setType('outpatient'); setPatientAge(''); setPatientGender(''); + setPmh(''); setAdditionalInstructions(''); + }} + /> +