>
)}
{
if (deleteTarget) del.mutate(deleteTarget.id);
setDeleteTarget(null);
}}
onCancel={() => setDeleteTarget(null)}
/>
);
}
// ── 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.
The AI automatically learns from your edits. When you modify AI-generated text and save, corrections are stored here and applied to future notes. Latest 20 per section.
{corrections.length === 0 ? (
No corrections yet. Edit AI-generated notes and save to start learning.
) : (
corrections.map((m) => {
const isOpen = expanded[m.id];
const parts = parseCorrection(m.content || '');
const date = m.created_at ? new Date(m.created_at).toLocaleDateString() : '';
return (
{isOpen && (
Original (AI generated):
{parts.original}
{parts.corrected && (
Corrected to:
{parts.corrected}
)}
)}
);
})
)}
{
if (deleteTarget) del.mutate(deleteTarget.id);
setDeleteTarget(null);
}}
onCancel={() => setDeleteTarget(null)}
/>
);
}
// ── Audio Backups (server-stored, 24h TTL) ──────────────────
function AudioBackupsCard() {
const qc = useQueryClient();
const [msg, setMsg] = useState(null);
const [deleteTarget, setDeleteTarget] = useState(null);
const { data, isLoading } = useQuery({
queryKey: ['audio-backups'],
queryFn: () => api.get('/api/audio-backups'),
});
const del = useMutation({
mutationFn: (id: number) => api.delete<{ success: true }>('/api/audio-backups/' + id),
onSuccess: () => {
setMsg({ text: 'Backup deleted', kind: 'info' });
qc.invalidateQueries({ queryKey: ['audio-backups'] });
},
onError: (e: Error) => setMsg({ text: e.message, kind: 'err' }),
});
function playBackup(id: number) {
// Server streams the decompressed audio. Open in a new tab; browser
// picks the default audio player.
window.open('/api/audio-backups/' + id + '/audio', '_blank', 'noopener,noreferrer');
}
return (
Audio Backups
Recordings are automatically backed up on the server and kept for 24 hours. Retry flow ports with the recording components.
{
if (deleteTarget) del.mutate(deleteTarget.id);
setDeleteTarget(null);
}}
onCancel={() => setDeleteTarget(null)}
/>
);
}
// ── Compliance (static info card) ───────────────────────────
function ComplianceCard() {
return (
Compliance & Usage
AWS Bedrock is available with a Business Associate Agreement (BAA) for HIPAA-eligible workloads.
All connections use HTTPS/TLS encryption
Authentication with optional 2FA
No patient data stored on server beyond session
AWS Bedrock supports BAA for HIPAA compliance
Azure OpenAI supports BAA for HIPAA compliance
Important: Check with your institution's guidelines and policies before use. This tool is not intended for production clinical use without proper organizational authorization and provider BAAs in place. Use with caution.
);
}
// ── Page shell ───────────────────────────────────────────────
export default function Settings() {
const { data: me, isLoading, error } = useQuery({
queryKey: ['auth-me'],
queryFn: () => api.get('/api/auth/me'),
});
// Default to showing local-auth UIs unless the server explicitly says
// canLocalAuth === false. Matches vanilla behavior: if the flag is
// absent (older server, transient hiccup), we still render the section.
const canLocal = me?.user.canLocalAuth !== false;
return (
Settings
Account security, integrations, and personal templates. More sub-sections port over in follow-up commits.
{isLoading &&
Loading…
}
{error && (
Could not load your account: {(error as Error).message}