feat(notes): restore audio recording + save/load across all 7 note pages
Recovered from git history (commit be14578) — the vanilla recording UI
was deleted during the "minimum-viable" note ports without being
re-implemented. Every note page now gets the full vanilla behavior
back:
Recording (Encounter, Dictation, SOAP, Sick Visit, Hospital Course):
• AudioRecorder — MediaRecorder mono/16kHz/EC+NS/opus 32kbps
• Pause / Resume (native where supported, restart-on-same-stream
fallback for Safari)
• Live preview via Web Speech API (opt-in in Settings)
• On stop → upload to /api/transcribe; fall back to live preview
if server unavailable or blob > 24 MB
• Failed uploads → /api/audio-backups (IndexedDB fallback)
Save / Load / New-patient toolbar (all 7 note pages):
• sessionStorage keys _savedEncId_<type> + _idempKey_<type>
survive page refresh + sign-out within the same tab
• Optimistic locking via expected_version (409 → "Someone else
edited this encounter")
• Load popover lists saved encounters of matching type only
• Draft #N chip shows current session-bound row
Well Visit and Chart Review: toolbar only — vanilla had no recorder
on those tabs (paste-based workflows).
New components:
client/src/components/Recorder.tsx
client/src/components/EncounterToolbar.tsx
New libraries:
client/src/lib/recorder.ts — AudioRecorder class
client/src/lib/transcribe.ts — /api/transcribe + audio backup
client/src/lib/web-speech.ts — webkit speech preview + dedupe
client/src/lib/encounter-persistence.ts — save/load/version tracking
This commit is contained in:
parent
88dd878492
commit
cd1f762f34
13 changed files with 1077 additions and 23 deletions
159
client/src/components/EncounterToolbar.tsx
Normal file
159
client/src/components/EncounterToolbar.tsx
Normal file
|
|
@ -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<SavedEncounterListEntry[]>([]);
|
||||
const [loadingList, setLoadingList] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [savedId, setSavedId] = useState<number | null>(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 (
|
||||
<div className="space-y-2" data-testid={'enc-toolbar-' + type}>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
className={input + ' flex-1 min-w-[180px] max-w-md'}
|
||||
placeholder="Patient label (e.g. JD-2026-04-24)"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
data-testid="enc-label"
|
||||
/>
|
||||
<button type="button" onClick={save} disabled={saving} className={btnPrimary} data-testid="enc-save">
|
||||
💾 {saving ? 'Saving…' : (savedId != null ? 'Save (update)' : 'Save')}
|
||||
</button>
|
||||
<div className="relative">
|
||||
<button type="button" onClick={openLoad} className={btn} data-testid="enc-load-toggle">
|
||||
📂 Load
|
||||
</button>
|
||||
{popoverOpen && (
|
||||
<div className="absolute z-30 mt-1 right-0 w-80 max-h-96 overflow-auto rounded-md border border-border bg-card shadow-lg">
|
||||
<div className="sticky top-0 bg-card p-2 border-b border-border">
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Filter saved encounters…"
|
||||
className={input + ' w-full text-xs'}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
autoFocus
|
||||
data-testid="enc-load-search"
|
||||
/>
|
||||
</div>
|
||||
{loadingList && <div className="p-3 text-xs text-muted-foreground">Loading…</div>}
|
||||
{!loadingList && filtered.length === 0 && (
|
||||
<div className="p-3 text-xs text-muted-foreground italic">No saved {type} encounters.</div>
|
||||
)}
|
||||
{filtered.map((e) => (
|
||||
<button
|
||||
key={e.id}
|
||||
type="button"
|
||||
onClick={() => pick(e.id)}
|
||||
className="w-full text-left px-3 py-2 hover:bg-muted border-b border-border last:border-0"
|
||||
data-testid={'enc-load-pick-' + e.id}
|
||||
>
|
||||
<div className="text-sm font-medium truncate">{e.label}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Updated {new Date(e.updated_at).toLocaleString()} · expires {new Date(e.expires_at).toLocaleDateString()}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button type="button" onClick={newPatient} className={btn} data-testid="enc-new">
|
||||
🆕 New patient
|
||||
</button>
|
||||
{savedId != null && (
|
||||
<span className="text-xs text-muted-foreground">Draft #{savedId}</span>
|
||||
)}
|
||||
</div>
|
||||
{msg && (
|
||||
<div className={
|
||||
'text-sm ' +
|
||||
(msg.kind === 'ok' ? 'text-green-600' : msg.kind === 'err' ? 'text-destructive' : 'text-muted-foreground')
|
||||
}>{msg.text}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
215
client/src/components/Recorder.tsx
Normal file
215
client/src/components/Recorder.tsx
Normal file
|
|
@ -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<AudioRecorder | null>(null);
|
||||
const speechRef = useRef<SpeechHandle | null>(null);
|
||||
const finalTextRef = useRef('');
|
||||
const intervalRef = useRef<number | null>(null);
|
||||
const startTimeRef = useRef<number>(0);
|
||||
const pauseAccumRef = useRef<number>(0);
|
||||
const pauseStartRef = useRef<number>(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 (
|
||||
<div className="flex flex-wrap items-center gap-2" data-testid={'recorder-' + module}>
|
||||
{state === 'idle' && (
|
||||
<button type="button" onClick={start} disabled={disabled}
|
||||
className={btnRecord + ' bg-destructive text-white border-destructive hover:bg-red-700'}
|
||||
data-testid="recorder-start">
|
||||
🎙 Start recording
|
||||
</button>
|
||||
)}
|
||||
{(state === 'recording' || state === 'paused') && (
|
||||
<>
|
||||
<button type="button" onClick={state === 'recording' ? pause : resume}
|
||||
className={btnRecord + ' bg-amber-500 text-white border-amber-500 hover:bg-amber-600'}
|
||||
data-testid="recorder-pause">
|
||||
{state === 'recording' ? '⏸ Pause' : '▶ Resume'}
|
||||
</button>
|
||||
<button type="button" onClick={stop}
|
||||
className={btnRecord + ' bg-slate-800 text-white border-slate-800 hover:bg-slate-900'}
|
||||
data-testid="recorder-stop">
|
||||
⏹ Stop
|
||||
</button>
|
||||
<span className="inline-flex items-center gap-1.5 text-sm text-destructive font-mono"
|
||||
data-testid="recorder-timer">
|
||||
<span className={state === 'recording' ? 'animate-pulse' : 'opacity-50'}>●</span>
|
||||
{state === 'paused' ? 'paused ' : ''}{mm}:{ss}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{state === 'transcribing' && (
|
||||
<span className="text-sm text-muted-foreground" data-testid="recorder-transcribing">
|
||||
⌛ Transcribing…
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
124
client/src/lib/encounter-persistence.ts
Normal file
124
client/src/lib/encounter-persistence.ts
Normal file
|
|
@ -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<number, number>();
|
||||
|
||||
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<SaveEncounterResult> {
|
||||
if (!input.label.trim()) throw new Error('Enter a patient label first');
|
||||
const id = getSavedEncId(input.type);
|
||||
const body: Record<string, unknown> = {
|
||||
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<LoadedEncounter> {
|
||||
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<SavedEncounterListEntry[]> {
|
||||
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);
|
||||
}
|
||||
59
client/src/lib/recorder.ts
Normal file
59
client/src/lib/recorder.ts
Normal file
|
|
@ -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<void> {
|
||||
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<Blob | null> {
|
||||
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 */ }
|
||||
}
|
||||
}
|
||||
99
client/src/lib/transcribe.ts
Normal file
99
client/src/lib/transcribe.ts
Normal file
|
|
@ -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<void> {
|
||||
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<TranscribeResult> {
|
||||
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<number | null> {
|
||||
// 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<IDBDatabase> {
|
||||
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<number | null> {
|
||||
return openDB().then((db) => new Promise<number>((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);
|
||||
}
|
||||
117
client/src/lib/web-speech.ts
Normal file
117
client/src/lib/web-speech.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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<ReviewType>('outpatient');
|
||||
const [patientAge, setPatientAge] = useState('');
|
||||
const [patientGender, setPatientGender] = useState('');
|
||||
|
|
@ -56,6 +59,34 @@ export default function ChartReview() {
|
|||
</p>
|
||||
</header>
|
||||
|
||||
<EncounterToolbar
|
||||
type={TYPE}
|
||||
label={label} setLabel={setLabel}
|
||||
transcript={JSON.stringify(visits)} generatedNote={result || ''}
|
||||
partialData={{ type, age: patientAge, gender: patientGender, pmh, additionalInstructions }}
|
||||
onLoad={(enc) => {
|
||||
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('');
|
||||
}}
|
||||
/>
|
||||
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<label className="flex flex-col gap-1">
|
||||
|
|
|
|||
|
|
@ -13,16 +13,22 @@ import { useMutation } from '@tanstack/react-query';
|
|||
import { api, ApiError } from '@/lib/api';
|
||||
import type { HpiOk } from '@/shared/types';
|
||||
import { HpiEncounterRequestSchema, type HpiEncounterRequest } from '@/shared/schemas';
|
||||
import Recorder from '@/components/Recorder';
|
||||
import EncounterToolbar from '@/components/EncounterToolbar';
|
||||
|
||||
type Setting = 'outpatient' | 'inpatient';
|
||||
const TYPE = 'dictation' as const;
|
||||
|
||||
export default function Dictation() {
|
||||
const [label, setLabel] = useState('');
|
||||
const [patientAge, setPatientAge] = useState('');
|
||||
const [patientGender, setPatientGender] = useState('');
|
||||
const [setting, setSetting] = useState<Setting>('outpatient');
|
||||
const [transcript, setTranscript] = useState('');
|
||||
const [interim, setInterim] = useState('');
|
||||
const [result, setResult] = useState<string | null>(null);
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
const [recError, setRecError] = useState<string | null>(null);
|
||||
|
||||
const generate = useMutation<HpiOk, Error, HpiEncounterRequest>({
|
||||
mutationFn: (body) => api.post<HpiOk>('/api/generate-hpi-dictation', body),
|
||||
|
|
@ -33,7 +39,7 @@ export default function Dictation() {
|
|||
function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setValidationError(null);
|
||||
const body: HpiEncounterRequest = { transcript, patientAge, patientGender, setting };
|
||||
const body: HpiEncounterRequest = { transcript: (interim || transcript).trim(), patientAge, patientGender, setting };
|
||||
const parsed = HpiEncounterRequestSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
setValidationError(parsed.error.issues.map((i: { message: string }) => i.message).join(', '));
|
||||
|
|
@ -45,11 +51,13 @@ export default function Dictation() {
|
|||
|
||||
function clear() {
|
||||
setTranscript('');
|
||||
setInterim('');
|
||||
setResult(null);
|
||||
setValidationError(null);
|
||||
}
|
||||
|
||||
const input = 'w-full rounded-md border border-input bg-background px-3 py-2 text-sm';
|
||||
const displayedTranscript = interim || transcript;
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6 space-y-4">
|
||||
|
|
@ -57,10 +65,29 @@ export default function Dictation() {
|
|||
<h1 className="text-2xl font-semibold">Voice Dictation → HPI</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Dictate your narrative → AI restructures into polished HPI.
|
||||
Audio-capture UI is a follow-up; this minimal form supports typed/pasted transcripts.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<EncounterToolbar
|
||||
type={TYPE}
|
||||
label={label} setLabel={setLabel}
|
||||
transcript={transcript} generatedNote={result || ''}
|
||||
partialData={{ age: patientAge, gender: patientGender, setting }}
|
||||
onLoad={(enc) => {
|
||||
setTranscript(enc.transcript || '');
|
||||
setInterim('');
|
||||
setResult(enc.generated_note || null);
|
||||
try {
|
||||
const pd = enc.partial_data ? JSON.parse(enc.partial_data) : null;
|
||||
if (pd?.age) setPatientAge(pd.age);
|
||||
if (pd?.gender) setPatientGender(pd.gender);
|
||||
if (pd?.setting) setSetting(pd.setting);
|
||||
} catch { /* ignore */ }
|
||||
setLabel(enc.label || '');
|
||||
}}
|
||||
onClear={() => { clear(); setPatientAge(''); setPatientGender(''); setSetting('outpatient'); }}
|
||||
/>
|
||||
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<label className="flex flex-col gap-1">
|
||||
|
|
@ -84,6 +111,18 @@ export default function Dictation() {
|
|||
</label>
|
||||
</div>
|
||||
|
||||
<Recorder
|
||||
module="dictation"
|
||||
onTranscript={(text, meta) => {
|
||||
setTranscript((prev) => (meta.appended ? (prev ? prev + ' ' + text : text) : text));
|
||||
setInterim('');
|
||||
setRecError(null);
|
||||
}}
|
||||
onInterim={(t) => setInterim(t ? (transcript ? transcript + ' ' + t : t) : '')}
|
||||
onError={(msg) => setRecError(msg)}
|
||||
/>
|
||||
{recError && <div className="text-sm text-destructive">{recError}</div>}
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
|
|
@ -95,9 +134,9 @@ export default function Dictation() {
|
|||
</div>
|
||||
<textarea
|
||||
className={input + ' min-h-[200px] font-mono text-sm'}
|
||||
placeholder="Type or paste your dictation here, then click Generate."
|
||||
value={transcript}
|
||||
onChange={(e) => setTranscript(e.target.value)}
|
||||
placeholder="Click Start recording, or type / paste your dictation here."
|
||||
value={displayedTranscript}
|
||||
onChange={(e) => { setTranscript(e.target.value); setInterim(''); }}
|
||||
/>
|
||||
</label>
|
||||
|
||||
|
|
@ -107,7 +146,7 @@ export default function Dictation() {
|
|||
<div className="flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={generate.isPending || !transcript.trim()}
|
||||
disabled={generate.isPending || !displayedTranscript.trim()}
|
||||
className="rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{generate.isPending ? 'Generating…' : 'Generate HPI'}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
// ============================================================
|
||||
// ENCOUNTER — live encounter → HPI via /api/generate-hpi-encounter
|
||||
// Minimum-viable port: same shape as Dictation (same endpoint family).
|
||||
// Audio capture + save/load + refine deferred to follow-up commits.
|
||||
// ENCOUNTER — live encounter → HPI via /api/generate-hpi-encounter.
|
||||
// Full port — mic recorder + Web Speech live preview + transcribe
|
||||
// + save/resume across sign-outs (mirrors public/js/liveEncounter.js
|
||||
// + encounters.js).
|
||||
// ============================================================
|
||||
|
||||
import { useState } from 'react';
|
||||
|
|
@ -9,16 +10,22 @@ import { useMutation } from '@tanstack/react-query';
|
|||
import { api, ApiError } from '@/lib/api';
|
||||
import type { HpiOk } from '@/shared/types';
|
||||
import { HpiEncounterRequestSchema, type HpiEncounterRequest } from '@/shared/schemas';
|
||||
import Recorder from '@/components/Recorder';
|
||||
import EncounterToolbar from '@/components/EncounterToolbar';
|
||||
|
||||
type Setting = 'outpatient' | 'inpatient';
|
||||
const TYPE = 'encounter' as const;
|
||||
|
||||
export default function Encounter() {
|
||||
const [label, setLabel] = useState('');
|
||||
const [patientAge, setPatientAge] = useState('');
|
||||
const [patientGender, setPatientGender] = useState('');
|
||||
const [setting, setSetting] = useState<Setting>('outpatient');
|
||||
const [transcript, setTranscript] = useState('');
|
||||
const [interim, setInterim] = useState('');
|
||||
const [result, setResult] = useState<string | null>(null);
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
const [recError, setRecError] = useState<string | null>(null);
|
||||
|
||||
const generate = useMutation<HpiOk, Error, HpiEncounterRequest>({
|
||||
mutationFn: (body) => api.post<HpiOk>('/api/generate-hpi-encounter', body),
|
||||
|
|
@ -28,7 +35,7 @@ export default function Encounter() {
|
|||
function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setValidationError(null);
|
||||
const body: HpiEncounterRequest = { transcript, patientAge, patientGender, setting };
|
||||
const body: HpiEncounterRequest = { transcript: (interim || transcript).trim(), patientAge, patientGender, setting };
|
||||
const parsed = HpiEncounterRequestSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
setValidationError(parsed.error.issues.map((i: { message: string }) => i.message).join(', '));
|
||||
|
|
@ -39,6 +46,7 @@ export default function Encounter() {
|
|||
}
|
||||
|
||||
const input = 'w-full rounded-md border border-input bg-background px-3 py-2 text-sm';
|
||||
const displayedTranscript = interim || transcript;
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6 space-y-4">
|
||||
|
|
@ -49,6 +57,26 @@ export default function Encounter() {
|
|||
</p>
|
||||
</header>
|
||||
|
||||
<EncounterToolbar
|
||||
type={TYPE}
|
||||
label={label} setLabel={setLabel}
|
||||
transcript={transcript} generatedNote={result || ''}
|
||||
partialData={{ age: patientAge, gender: patientGender, setting }}
|
||||
onLoad={(enc) => {
|
||||
setTranscript(enc.transcript || '');
|
||||
setInterim('');
|
||||
setResult(enc.generated_note || null);
|
||||
try {
|
||||
const pd = enc.partial_data ? JSON.parse(enc.partial_data) : null;
|
||||
if (pd?.age) setPatientAge(pd.age);
|
||||
if (pd?.gender) setPatientGender(pd.gender);
|
||||
if (pd?.setting) setSetting(pd.setting);
|
||||
} catch { /* ignore malformed partial */ }
|
||||
setLabel(enc.label || '');
|
||||
}}
|
||||
onClear={() => { setTranscript(''); setInterim(''); setResult(null); setPatientAge(''); setPatientGender(''); setSetting('outpatient'); }}
|
||||
/>
|
||||
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<label className="flex flex-col gap-1">
|
||||
|
|
@ -72,15 +100,30 @@ export default function Encounter() {
|
|||
</label>
|
||||
</div>
|
||||
|
||||
<Recorder
|
||||
module="encounter"
|
||||
onTranscript={(text, meta) => {
|
||||
// appended=true means the live preview text is being kept;
|
||||
// appended=false means we got a fresh transcription that
|
||||
// should replace what's there.
|
||||
setTranscript((prev) => (meta.appended ? (prev ? prev + ' ' + text : text) : text));
|
||||
setInterim('');
|
||||
setRecError(null);
|
||||
}}
|
||||
onInterim={(t) => setInterim(t ? (transcript ? transcript + ' ' + t : t) : '')}
|
||||
onError={(msg) => setRecError(msg)}
|
||||
/>
|
||||
{recError && <div className="text-sm text-destructive">{recError}</div>}
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Transcript
|
||||
</span>
|
||||
<textarea
|
||||
className={input + ' min-h-[220px] font-mono text-sm'}
|
||||
placeholder="Type or paste an encounter transcript, then click Generate."
|
||||
value={transcript}
|
||||
onChange={(e) => setTranscript(e.target.value)}
|
||||
placeholder="Click Start recording, or type / paste an encounter transcript here."
|
||||
value={displayedTranscript}
|
||||
onChange={(e) => { setTranscript(e.target.value); setInterim(''); }}
|
||||
/>
|
||||
</label>
|
||||
|
||||
|
|
@ -89,7 +132,7 @@ export default function Encounter() {
|
|||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={generate.isPending || !transcript.trim()}
|
||||
disabled={generate.isPending || !displayedTranscript.trim()}
|
||||
className="rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{generate.isPending ? 'Generating…' : 'Generate HPI'}
|
||||
|
|
|
|||
|
|
@ -6,13 +6,18 @@ import { useState } from 'react';
|
|||
import { useMutation } from '@tanstack/react-query';
|
||||
import { api, ApiError } from '@/lib/api';
|
||||
import type { HospitalCourseOk } from '@/shared/types';
|
||||
import Recorder from '@/components/Recorder';
|
||||
import EncounterToolbar from '@/components/EncounterToolbar';
|
||||
|
||||
type SettingKind = 'floor' | 'picu' | 'nicu' | 'psych';
|
||||
type FormatKind = 'auto' | 'prose' | 'dayByDay' | 'organSystem';
|
||||
const TYPE = 'hospital' as const;
|
||||
|
||||
interface NoteEntry { date: string; type: string; content: string }
|
||||
|
||||
export default function HospitalCourse() {
|
||||
const [label, setLabel] = useState('');
|
||||
const [recError, setRecError] = useState<string | null>(null);
|
||||
const [patientAge, setPatientAge] = useState('');
|
||||
const [patientGender, setPatientGender] = useState('');
|
||||
const [pmh, setPmh] = useState('');
|
||||
|
|
@ -61,6 +66,35 @@ export default function HospitalCourse() {
|
|||
</p>
|
||||
</header>
|
||||
|
||||
<EncounterToolbar
|
||||
type={TYPE}
|
||||
label={label} setLabel={setLabel}
|
||||
transcript={notesText} generatedNote={result?.hospitalCourse || ''}
|
||||
partialData={{ age: patientAge, gender: patientGender, pmh, setting, los, format, hAndPContent, additionalInstructions }}
|
||||
onLoad={(enc) => {
|
||||
setNotesText(enc.transcript || '');
|
||||
setResult(enc.generated_note ? { hospitalCourse: enc.generated_note, format: 'auto' } : null);
|
||||
try {
|
||||
const pd = enc.partial_data ? JSON.parse(enc.partial_data) : null;
|
||||
if (pd?.age) setPatientAge(pd.age);
|
||||
if (pd?.gender) setPatientGender(pd.gender);
|
||||
if (pd?.pmh) setPmh(pd.pmh);
|
||||
if (pd?.setting) setSetting(pd.setting);
|
||||
if (pd?.los) setLos(pd.los);
|
||||
if (pd?.format) setFormat(pd.format);
|
||||
if (pd?.hAndPContent) setHAndPContent(pd.hAndPContent);
|
||||
if (pd?.additionalInstructions) setAdditionalInstructions(pd.additionalInstructions);
|
||||
} catch { /* ignore */ }
|
||||
setLabel(enc.label || '');
|
||||
}}
|
||||
onClear={() => {
|
||||
setNotesText(''); setResult(null);
|
||||
setPatientAge(''); setPatientGender(''); setPmh('');
|
||||
setSetting('floor'); setLos(''); setFormat('auto');
|
||||
setHAndPContent(''); setAdditionalInstructions('');
|
||||
}}
|
||||
/>
|
||||
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<label className="flex flex-col gap-1">
|
||||
|
|
@ -110,6 +144,20 @@ export default function HospitalCourse() {
|
|||
<textarea className={input + ' min-h-[120px] font-mono text-sm'} value={hAndPContent} onChange={(e) => setHAndPContent(e.target.value)} />
|
||||
</label>
|
||||
|
||||
<Recorder
|
||||
module="hospital"
|
||||
onTranscript={(text, meta) => {
|
||||
if (meta.appended) {
|
||||
setNotesText((prev) => prev ? prev + '\n\n' + text : text);
|
||||
} else {
|
||||
setNotesText((prev) => prev ? prev + '\n\n' + text : text);
|
||||
}
|
||||
setRecError(null);
|
||||
}}
|
||||
onError={(msg) => setRecError(msg)}
|
||||
/>
|
||||
{recError && <div className="text-sm text-destructive">{recError}</div>}
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Progress notes <span className="normal-case font-normal text-muted-foreground">(separate each note with a blank line)</span>
|
||||
|
|
|
|||
|
|
@ -7,14 +7,21 @@ import { useMutation } from '@tanstack/react-query';
|
|||
import { api, ApiError } from '@/lib/api';
|
||||
import type { VisitNoteOk } from '@/shared/types';
|
||||
import { SickVisitRequestSchema, type SickVisitRequest } from '@/shared/schemas';
|
||||
import Recorder from '@/components/Recorder';
|
||||
import EncounterToolbar from '@/components/EncounterToolbar';
|
||||
|
||||
const TYPE = 'sickvisit' as const;
|
||||
|
||||
export default function SickVisit() {
|
||||
const [label, setLabel] = useState('');
|
||||
const [patientAge, setPatientAge] = useState('');
|
||||
const [patientGender, setPatientGender] = useState('');
|
||||
const [chiefComplaint, setChiefComplaint] = useState('');
|
||||
const [transcript, setTranscript] = useState('');
|
||||
const [interim, setInterim] = useState('');
|
||||
const [result, setResult] = useState<string | null>(null);
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
const [recError, setRecError] = useState<string | null>(null);
|
||||
|
||||
const generate = useMutation<VisitNoteOk, Error, SickVisitRequest>({
|
||||
mutationFn: (body) => api.post<VisitNoteOk>('/api/sick-visit/note', body),
|
||||
|
|
@ -24,7 +31,7 @@ export default function SickVisit() {
|
|||
function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setValidationError(null);
|
||||
const body: SickVisitRequest = { patientAge, patientGender, chiefComplaint, transcript };
|
||||
const body: SickVisitRequest = { patientAge, patientGender, chiefComplaint, transcript: (interim || transcript).trim() };
|
||||
const parsed = SickVisitRequestSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
setValidationError(parsed.error.issues.map((i: { message: string }) => i.message).join(', '));
|
||||
|
|
@ -35,6 +42,7 @@ export default function SickVisit() {
|
|||
}
|
||||
|
||||
const input = 'w-full rounded-md border border-input bg-background px-3 py-2 text-sm';
|
||||
const displayedTranscript = interim || transcript;
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6 space-y-4">
|
||||
|
|
@ -45,6 +53,29 @@ export default function SickVisit() {
|
|||
</p>
|
||||
</header>
|
||||
|
||||
<EncounterToolbar
|
||||
type={TYPE}
|
||||
label={label} setLabel={setLabel}
|
||||
transcript={transcript} generatedNote={result || ''}
|
||||
partialData={{ age: patientAge, gender: patientGender, chiefComplaint }}
|
||||
onLoad={(enc) => {
|
||||
setTranscript(enc.transcript || '');
|
||||
setInterim('');
|
||||
setResult(enc.generated_note || null);
|
||||
try {
|
||||
const pd = enc.partial_data ? JSON.parse(enc.partial_data) : null;
|
||||
if (pd?.age) setPatientAge(pd.age);
|
||||
if (pd?.gender) setPatientGender(pd.gender);
|
||||
if (pd?.chiefComplaint) setChiefComplaint(pd.chiefComplaint);
|
||||
} catch { /* ignore */ }
|
||||
setLabel(enc.label || '');
|
||||
}}
|
||||
onClear={() => {
|
||||
setTranscript(''); setInterim(''); setResult(null); setValidationError(null);
|
||||
setPatientAge(''); setPatientGender(''); setChiefComplaint('');
|
||||
}}
|
||||
/>
|
||||
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<label className="flex flex-col gap-1">
|
||||
|
|
@ -65,13 +96,25 @@ export default function SickVisit() {
|
|||
</label>
|
||||
</div>
|
||||
|
||||
<Recorder
|
||||
module="sickvisit"
|
||||
onTranscript={(text, meta) => {
|
||||
setTranscript((prev) => (meta.appended ? (prev ? prev + ' ' + text : text) : text));
|
||||
setInterim('');
|
||||
setRecError(null);
|
||||
}}
|
||||
onInterim={(t) => setInterim(t ? (transcript ? transcript + ' ' + t : t) : '')}
|
||||
onError={(msg) => setRecError(msg)}
|
||||
/>
|
||||
{recError && <div className="text-sm text-destructive">{recError}</div>}
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Transcript / dictation</span>
|
||||
<textarea
|
||||
className={input + ' min-h-[200px] font-mono text-sm'}
|
||||
placeholder="Encounter narrative — transcribed or dictated."
|
||||
value={transcript}
|
||||
onChange={(e) => setTranscript(e.target.value)}
|
||||
placeholder="Click Start recording, or type / paste encounter narrative."
|
||||
value={displayedTranscript}
|
||||
onChange={(e) => { setTranscript(e.target.value); setInterim(''); }}
|
||||
/>
|
||||
</label>
|
||||
|
||||
|
|
|
|||
|
|
@ -7,17 +7,23 @@ import { useMutation } from '@tanstack/react-query';
|
|||
import { api, ApiError } from '@/lib/api';
|
||||
import type { SoapOk } from '@/shared/types';
|
||||
import { SoapRequestSchema, type SoapRequest } from '@/shared/schemas';
|
||||
import Recorder from '@/components/Recorder';
|
||||
import EncounterToolbar from '@/components/EncounterToolbar';
|
||||
|
||||
type SoapType = 'full' | 'subjective';
|
||||
const TYPE = 'soap' as const;
|
||||
|
||||
export default function Soap() {
|
||||
const [label, setLabel] = useState('');
|
||||
const [patientAge, setPatientAge] = useState('');
|
||||
const [patientGender, setPatientGender] = useState('');
|
||||
const [type, setType] = useState<SoapType>('full');
|
||||
const [transcript, setTranscript] = useState('');
|
||||
const [interim, setInterim] = useState('');
|
||||
const [additionalInstructions, setAdditionalInstructions] = useState('');
|
||||
const [result, setResult] = useState<string | null>(null);
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
const [recError, setRecError] = useState<string | null>(null);
|
||||
|
||||
const generate = useMutation<SoapOk, Error, SoapRequest>({
|
||||
mutationFn: (body) => api.post<SoapOk>('/api/generate-soap', body),
|
||||
|
|
@ -27,7 +33,7 @@ export default function Soap() {
|
|||
function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setValidationError(null);
|
||||
const body: SoapRequest = { transcript, patientAge, patientGender, type, additionalInstructions };
|
||||
const body: SoapRequest = { transcript: (interim || transcript).trim(), patientAge, patientGender, type, additionalInstructions };
|
||||
const parsed = SoapRequestSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
setValidationError(parsed.error.issues.map((i: { message: string }) => i.message).join(', '));
|
||||
|
|
@ -38,6 +44,7 @@ export default function Soap() {
|
|||
}
|
||||
|
||||
const input = 'w-full rounded-md border border-input bg-background px-3 py-2 text-sm';
|
||||
const displayedTranscript = interim || transcript;
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6 space-y-4">
|
||||
|
|
@ -48,6 +55,30 @@ export default function Soap() {
|
|||
</p>
|
||||
</header>
|
||||
|
||||
<EncounterToolbar
|
||||
type={TYPE}
|
||||
label={label} setLabel={setLabel}
|
||||
transcript={transcript} generatedNote={result || ''}
|
||||
partialData={{ age: patientAge, gender: patientGender, type, additionalInstructions }}
|
||||
onLoad={(enc) => {
|
||||
setTranscript(enc.transcript || '');
|
||||
setInterim('');
|
||||
setResult(enc.generated_note || null);
|
||||
try {
|
||||
const pd = enc.partial_data ? JSON.parse(enc.partial_data) : null;
|
||||
if (pd?.age) setPatientAge(pd.age);
|
||||
if (pd?.gender) setPatientGender(pd.gender);
|
||||
if (pd?.type) setType(pd.type);
|
||||
if (pd?.additionalInstructions) setAdditionalInstructions(pd.additionalInstructions);
|
||||
} catch { /* ignore */ }
|
||||
setLabel(enc.label || '');
|
||||
}}
|
||||
onClear={() => {
|
||||
setTranscript(''); setInterim(''); setResult(null); setValidationError(null);
|
||||
setPatientAge(''); setPatientGender(''); setType('full'); setAdditionalInstructions('');
|
||||
}}
|
||||
/>
|
||||
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<label className="flex flex-col gap-1">
|
||||
|
|
@ -71,13 +102,25 @@ export default function Soap() {
|
|||
</label>
|
||||
</div>
|
||||
|
||||
<Recorder
|
||||
module="soap"
|
||||
onTranscript={(text, meta) => {
|
||||
setTranscript((prev) => (meta.appended ? (prev ? prev + ' ' + text : text) : text));
|
||||
setInterim('');
|
||||
setRecError(null);
|
||||
}}
|
||||
onInterim={(t) => setInterim(t ? (transcript ? transcript + ' ' + t : t) : '')}
|
||||
onError={(msg) => setRecError(msg)}
|
||||
/>
|
||||
{recError && <div className="text-sm text-destructive">{recError}</div>}
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Transcript</span>
|
||||
<textarea
|
||||
className={input + ' min-h-[200px] font-mono text-sm'}
|
||||
placeholder="Type or paste the encounter transcript."
|
||||
value={transcript}
|
||||
onChange={(e) => setTranscript(e.target.value)}
|
||||
placeholder="Click Start recording, or type / paste the encounter transcript."
|
||||
value={displayedTranscript}
|
||||
onChange={(e) => { setTranscript(e.target.value); setInterim(''); }}
|
||||
/>
|
||||
</label>
|
||||
|
||||
|
|
@ -98,7 +141,7 @@ export default function Soap() {
|
|||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={generate.isPending || !transcript.trim()}
|
||||
disabled={generate.isPending || !displayedTranscript.trim()}
|
||||
className="rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{generate.isPending ? 'Generating…' : 'Generate SOAP'}
|
||||
|
|
|
|||
|
|
@ -9,8 +9,12 @@ import { useState } from 'react';
|
|||
import { useMutation } from '@tanstack/react-query';
|
||||
import { api, ApiError } from '@/lib/api';
|
||||
import type { VisitNoteOk } from '@/shared/types';
|
||||
import EncounterToolbar from '@/components/EncounterToolbar';
|
||||
|
||||
const TYPE = 'wellvisit' as const;
|
||||
|
||||
export default function WellVisit() {
|
||||
const [label, setLabel] = useState('');
|
||||
const [patientAge, setPatientAge] = useState('');
|
||||
const [patientGender, setPatientGender] = useState('');
|
||||
const [visitAge, setVisitAge] = useState('');
|
||||
|
|
@ -50,6 +54,36 @@ export default function WellVisit() {
|
|||
</p>
|
||||
</header>
|
||||
|
||||
<EncounterToolbar
|
||||
type={TYPE}
|
||||
label={label} setLabel={setLabel}
|
||||
transcript={transcript} generatedNote={result || ''}
|
||||
partialData={{ age: patientAge, gender: patientGender, visitAge, vitals, measurements, parentConcerns, screenings, vaccines, noteStyle }}
|
||||
onLoad={(enc) => {
|
||||
setTranscript(enc.transcript || '');
|
||||
setResult(enc.generated_note || null);
|
||||
try {
|
||||
const pd = enc.partial_data ? JSON.parse(enc.partial_data) : null;
|
||||
if (pd?.age) setPatientAge(pd.age);
|
||||
if (pd?.gender) setPatientGender(pd.gender);
|
||||
if (pd?.visitAge) setVisitAge(pd.visitAge);
|
||||
if (pd?.vitals) setVitals(pd.vitals);
|
||||
if (pd?.measurements) setMeasurements(pd.measurements);
|
||||
if (pd?.parentConcerns) setParentConcerns(pd.parentConcerns);
|
||||
if (pd?.screenings) setScreenings(pd.screenings);
|
||||
if (pd?.vaccines) setVaccines(pd.vaccines);
|
||||
if (pd?.noteStyle) setNoteStyle(pd.noteStyle);
|
||||
} catch { /* ignore */ }
|
||||
setLabel(enc.label || '');
|
||||
}}
|
||||
onClear={() => {
|
||||
setTranscript(''); setResult(null);
|
||||
setPatientAge(''); setPatientGender(''); setVisitAge('');
|
||||
setVitals(''); setMeasurements(''); setParentConcerns('');
|
||||
setScreenings(''); setVaccines(''); setNoteStyle('full');
|
||||
}}
|
||||
/>
|
||||
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<label className="flex flex-col gap-1">
|
||||
|
|
|
|||
Loading…
Reference in a new issue