From 719d0cb7f707813a49322552da32dc3ed52ac8e8 Mon Sep 17 00:00:00 2001 From: Daniel Date: Thu, 23 Apr 2026 23:40:23 +0200 Subject: [PATCH] =?UTF-8?q?feat(client):=20port=20Settings=20=E2=80=94=20V?= =?UTF-8?q?oice=20+=20Content=20(8=20sub-sections)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third and final commit of the Settings port. Adds the remaining eight sub-sections so the React page matches vanilla settings.html 1:1. After this commit Settings is fully ported; Layout already flipped to available in commit 1, and the page fills out cleanly for local-auth and SSO users alike. Voice Preferences (VoicePreferencesCard) GET /api/user/preferences + /api/user/preferences/options populate the STT model / TTS voice selectors. Save POSTs /api/user/preferences. Preview persists the current TTS selection, then fetches /api/text-to- speech (binary blob, bypasses the JSON api wrapper), wraps the blob in an Audio element and plays it. A one-shot hydrated flag drives the first selection sync; after that the fields are local state. Browser Whisper (BrowserWhisperCard) — UI-only port Persists the enabled flag + model choice under the same localStorage keys the vanilla BrowserWhisper module reads, so behavior will light up automatically when the recording components port. The preload + WASM transcription flow stays in vanilla for this commit — noted in the page copy so users aren't surprised. Web Speech Recognition (WebSpeechCard) — UI-only port Same localStorage approach. Enabling surfaces a styled ConfirmModal with the HIPAA privacy warning before persisting. Enabling Web Speech flips Browser Whisper off automatically (mirrors vanilla priority: Web Speech > Browser Whisper > server). My Templates (TemplatesCard) Full Memories CRUD for non-correction entries: category select, name, content textarea, Add/Update toggle (in-place edit), per-row Delete confirm. Hits /api/memories {GET, POST, PUT, DELETE}. AI Corrections (CorrectionsCard) Read-only list filtered to category starting with 'correction_'. Per-row expand reveals the parsed ORIGINAL / CORRECTED TO: split (same text delimiter the vanilla parseCorrection() uses). Delete is wired through /api/memories/:id. Audio Backups (AudioBackupsCard) Lists /api/audio-backups (server-stored, 24h TTL). Play opens the decompressed audio stream in a new tab; Delete hits DELETE /api/audio-backups/:id. Retry flow stays in vanilla for this commit — it re-submits to /api/transcribe and that integration belongs with the recording components. Saved Encounters (SavedEncountersCard) Lists /api/encounters/saved with label / type / expires / preview. Delete only — Resume requires the encounter pages to receive pre-filled state, which ports alongside those pages. Compliance (ComplianceCard) Static info card — plain JSX, no API. shared/types.ts + client/src/shared/types.ts — additive only: UserPreferencesOk, PreferencesOptionsOk, VoiceOption, SavedEncounterRow, SavedEncountersListOk, AudioBackupRow, AudioBackupsListOk, MemoryRow, MemoriesOk. e2e/tests/settings-react-voice-content.spec.js — seven smoke tests covering control presence, templates empty-save validation, and the Web Speech privacy-confirm modal (with the no-native-dialog guard). Client tsc -b, server tsc --noEmit, and vite build all pass locally. Final bundle 425.42 kB / 121.55 kB gzipped (+21 kB over commit 2). The e2e container still predates /app/*; running these specs against it needs a rebuild. --- client/src/pages/Settings.tsx | 819 ++++++++++++++++++ client/src/shared/types.ts | 64 ++ .../settings-react-voice-content.spec.js | 85 ++ public/app/assets/index-BT9JgnfU.css | 2 - public/app/assets/index-DG4q33Hv.js | 52 ++ public/app/assets/index-DdnCNlZ0.js | 51 -- public/app/assets/index-DiAauNJX.css | 2 + public/app/index.html | 4 +- shared/types.ts | 64 ++ 9 files changed, 1088 insertions(+), 55 deletions(-) create mode 100644 e2e/tests/settings-react-voice-content.spec.js delete mode 100644 public/app/assets/index-BT9JgnfU.css create mode 100644 public/app/assets/index-DG4q33Hv.js delete mode 100644 public/app/assets/index-DdnCNlZ0.js create mode 100644 public/app/assets/index-DiAauNJX.css diff --git a/client/src/pages/Settings.tsx b/client/src/pages/Settings.tsx index d24c4dd..7fa0996 100644 --- a/client/src/pages/Settings.tsx +++ b/client/src/pages/Settings.tsx @@ -38,6 +38,15 @@ import type { DocumentUploadOk, DocumentDownloadOk, UserDocument, + UserPreferencesOk, + PreferencesOptionsOk, + VoiceOption, + SavedEncountersListOk, + SavedEncounterRow, + AudioBackupsListOk, + AudioBackupRow, + MemoriesOk, + MemoryRow, } from '@/shared/types'; // ── Small presentational bits ──────────────────────────────── @@ -919,6 +928,803 @@ function DocumentsCard() { ); } +// ── Voice Preferences (STT model + TTS voice) ─────────────── +function VoicePreferencesCard() { + const qc = useQueryClient(); + const [msg, setMsg] = useState(null); + const [sttModel, setSttModel] = useState(''); + const [ttsVoice, setTtsVoice] = useState(''); + const [previewing, setPreviewing] = useState(false); + const [hydrated, setHydrated] = useState(false); + + const { data: options } = useQuery({ + queryKey: ['voice-options'], + queryFn: () => api.get('/api/user/preferences/options'), + }); + const { data: prefs } = useQuery({ + queryKey: ['voice-prefs'], + queryFn: () => api.get('/api/user/preferences'), + }); + + // Hydrate selection from the server once — subsequent typing is local. + if (!hydrated && prefs) { + setSttModel(prefs.stt_model || ''); + setTtsVoice(prefs.tts_voice || ''); + setHydrated(true); + } + + const save = useMutation({ + mutationFn: (body: { stt_model: string | null; tts_voice: string | null }) => + api.post<{ success: true }>('/api/user/preferences', body), + onSuccess: () => { + setMsg({ text: 'Voice preferences saved', kind: 'ok' }); + qc.invalidateQueries({ queryKey: ['voice-prefs'] }); + }, + onError: (e: Error) => setMsg({ text: e.message || 'Save failed', kind: 'err' }), + }); + + async function previewVoice() { + setMsg(null); + setPreviewing(true); + try { + // First persist the selection so the server renders with the chosen voice. + await api.post('/api/user/preferences', { tts_voice: ttsVoice || null }); + qc.invalidateQueries({ queryKey: ['voice-prefs'] }); + + const text = + 'Hello, this is a preview of the ' + + (ttsVoice || 'server default') + + ' voice. This is how your read-aloud feature will sound.'; + const resp = await fetch('/api/text-to-speech', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text }), + }); + if (!resp.ok) throw new Error('Preview failed'); + const blob = await resp.blob(); + const url = URL.createObjectURL(blob); + const audio = new Audio(url); + audio.onended = () => URL.revokeObjectURL(url); + await audio.play(); + } catch (e) { + setMsg({ text: 'Preview failed: ' + (e as Error).message, kind: 'err' }); + } finally { + setPreviewing(false); + } + } + + return ( +
+

Voice Preferences

+

+ Customize your speech-to-text model and text-to-speech voice. These settings apply to all recording and read-aloud features. +

+ +
+ + + + + +
+ + +
+ ); +} + +// ── Browser Whisper (WASM, local transcription) ───────────── +// UI-only port: persists enabled + model choice in localStorage under the +// same keys the vanilla BrowserWhisper module reads. The actual WASM +// model preload / transcription still lives in the vanilla +// public/js/browserWhisper.js module and will wire through when the +// recording components port to React. +const BW_ENABLED_KEY = 'ped_browser_whisper_enabled'; +const BW_MODEL_KEY = 'ped_browser_whisper_model'; +const BW_MODELS: VoiceOption[] = [ + { value: 'Xenova/whisper-tiny.en', label: 'Tiny (~39MB) — fastest, ~2-3s' }, + { value: 'Xenova/whisper-base.en', label: 'Base (~74MB) — balanced, ~3-5s' }, + { value: 'Xenova/whisper-small.en', label: 'Small (~244MB) — best quality, ~6-10s' }, +]; +function readLS(k: string, fallback = ''): string { + try { return window.localStorage.getItem(k) ?? fallback; } catch { return fallback; } +} +function writeLS(k: string, v: string) { + try { window.localStorage.setItem(k, v); } catch { /* ignore */ } +} + +function BrowserWhisperCard() { + const [enabled, setEnabled] = useState(readLS(BW_ENABLED_KEY) === 'true'); + const [model, setModel] = useState(readLS(BW_MODEL_KEY) || BW_MODELS[0].value); + + function toggle(next: boolean) { + setEnabled(next); + writeLS(BW_ENABLED_KEY, String(next)); + } + function changeModel(next: string) { + setModel(next); + writeLS(BW_MODEL_KEY, next); + } + + return ( +
+

Browser Transcription (Local Whisper)

+

+ Transcribes audio entirely in your browser — no audio sent to any server. Powered by OpenAI Whisper running in WebAssembly. Model is downloaded once and cached locally. +

+
+ + +
+
+ + +
+

+ When enabled, overrides server transcription. Falls back to server if browser transcription fails. The actual WASM download runs from the recording components — pre-download will light up when those port to React. +

+
+ ); +} + +// ── Web Speech Recognition (real-time streaming) ──────────── +const WS_ENABLED_KEY = 'ped_web_speech_enabled'; + +function WebSpeechCard() { + const [enabled, setEnabled] = useState(readLS(WS_ENABLED_KEY) === 'true'); + const [confirmOpen, setConfirmOpen] = useState(false); + + const supported = + typeof window !== 'undefined' && + ('webkitSpeechRecognition' in window || 'SpeechRecognition' in window); + + function enable() { + // Enabling Web Speech flips Browser Whisper off (the vanilla app enforces + // the same priority: Web Speech > Browser Whisper > server). + writeLS(BW_ENABLED_KEY, 'false'); + setEnabled(true); + writeLS(WS_ENABLED_KEY, 'true'); + } + function disable() { + setEnabled(false); + writeLS(WS_ENABLED_KEY, 'false'); + } + + return ( +
+

Real-Time Streaming Transcription

+
+ Privacy Warning: Uses your browser's built-in speech recognition, which may send audio to cloud servers (Chrome/Edge send to Google). Only enable if you accept this trade-off for real-time transcription. +
+

+ See words appear as you speak (streaming). Overrides browser and server transcription when enabled. Not HIPAA-compliant in most browsers. +

+
+ + +
+ {supported && ( +

+ Streaming integration lights up when the recording components port to React. +

+ )} + + { + enable(); + setConfirmOpen(false); + }} + onCancel={() => setConfirmOpen(false)} + /> +
+ ); +} + +// ── My Templates (Memories, non-correction entries) ───────── +const TEMPLATE_CATEGORIES: Array<{ value: string; label: string }> = [ + { value: 'physical_exam', label: 'Physical Exam Template' }, + { value: 'ros', label: 'Review of Systems Template' }, + { value: 'encounter_format', label: 'Encounter Note Format' }, + { value: 'family_history', label: 'Family History Format' }, + { value: 'assessment_plan', label: 'Assessment & Plan Format' }, + { value: 'template_soap', label: 'SOAP Note Template' }, + { value: 'template_hpi', label: 'HPI Template' }, + { value: 'template_wellvisit', label: 'Well Visit Template' }, + { value: 'template_sickvisit', label: 'Sick Visit Template' }, + { value: 'custom', label: 'Custom' }, +]; +const CATEGORY_LABEL: Record = Object.fromEntries( + TEMPLATE_CATEGORIES.map((c) => [c.value, c.label.replace(/ Template$| Format$/, '')]) +); + +function TemplatesCard() { + const qc = useQueryClient(); + const [msg, setMsg] = useState(null); + const [editingId, setEditingId] = useState(null); + const [category, setCategory] = useState(TEMPLATE_CATEGORIES[0].value); + const [name, setName] = useState(''); + const [content, setContent] = useState(''); + const [deleteTarget, setDeleteTarget] = useState(null); + + const { data } = useQuery({ + queryKey: ['memories'], + queryFn: () => api.get('/api/memories'), + }); + + const templates = (data?.memories || []).filter((m) => !m.category.startsWith('correction_')); + + const save = useMutation({ + mutationFn: (body: { id?: number; category: string; name: string; content: string }) => { + const { id, ...rest } = body; + return id + ? api.put<{ success: true }>('/api/memories/' + id, rest) + : api.post<{ success: true }>('/api/memories', rest); + }, + onSuccess: () => { + setMsg({ text: editingId ? 'Template updated' : 'Template saved', kind: 'ok' }); + setEditingId(null); + setName(''); + setContent(''); + qc.invalidateQueries({ queryKey: ['memories'] }); + }, + onError: (e: Error) => setMsg({ text: e.message || 'Save failed', kind: 'err' }), + }); + + const del = useMutation({ + mutationFn: (id: number) => api.delete<{ success: true }>('/api/memories/' + id), + onSuccess: () => { + setMsg({ text: 'Template deleted', kind: 'info' }); + qc.invalidateQueries({ queryKey: ['memories'] }); + }, + onError: (e: Error) => setMsg({ text: e.message || 'Delete failed', kind: 'err' }), + }); + + function beginEdit(m: MemoryRow) { + setEditingId(m.id); + setCategory(m.category); + setName(m.name); + setContent(m.content); + } + function cancelEdit() { + setEditingId(null); + setName(''); + setContent(''); + } + function submit(e: React.FormEvent) { + e.preventDefault(); + setMsg(null); + if (!name.trim()) return setMsg({ text: 'Enter a template name', kind: 'err' }); + if (!content.trim()) return setMsg({ text: 'Enter template content', kind: 'err' }); + save.mutate({ id: editingId ?? undefined, category, name: name.trim(), content: content.trim() }); + } + + return ( +
+

My Templates

+

+ Save reusable templates for physical exam, ROS, encounter format, etc. The AI will use these when generating notes. +

+ +
+
+ + setName(e.target.value)} + data-testid="mem-name" + /> +
+