feat(client): port Settings — Voice + Content (8 sub-sections)
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.
This commit is contained in:
parent
25fa628b83
commit
719d0cb7f7
9 changed files with 1088 additions and 55 deletions
|
|
@ -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<Msg>(null);
|
||||
const [sttModel, setSttModel] = useState('');
|
||||
const [ttsVoice, setTtsVoice] = useState('');
|
||||
const [previewing, setPreviewing] = useState(false);
|
||||
const [hydrated, setHydrated] = useState(false);
|
||||
|
||||
const { data: options } = useQuery<PreferencesOptionsOk>({
|
||||
queryKey: ['voice-options'],
|
||||
queryFn: () => api.get<PreferencesOptionsOk>('/api/user/preferences/options'),
|
||||
});
|
||||
const { data: prefs } = useQuery<UserPreferencesOk>({
|
||||
queryKey: ['voice-prefs'],
|
||||
queryFn: () => api.get<UserPreferencesOk>('/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 (
|
||||
<section className={card} data-testid="voice-preferences-section">
|
||||
<h3 className="text-base font-semibold">Voice Preferences</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Customize your speech-to-text model and text-to-speech voice. These settings apply to all recording and read-aloud features.
|
||||
</p>
|
||||
|
||||
<div className="space-y-2 max-w-md">
|
||||
<label className="block text-sm">
|
||||
<span className="block text-xs font-semibold text-muted-foreground mb-1">
|
||||
Speech-to-Text Model (Transcription)
|
||||
</span>
|
||||
<select
|
||||
className={input}
|
||||
value={sttModel}
|
||||
onChange={(e) => setSttModel(e.target.value)}
|
||||
data-testid="stt-model-select"
|
||||
>
|
||||
<option value="">Server default{options ? ' (' + options.sttProvider + ')' : ''}</option>
|
||||
{options?.sttModels.map((m) => (
|
||||
<option key={m.value} value={m.value}>{m.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="block text-sm">
|
||||
<span className="block text-xs font-semibold text-muted-foreground mb-1">
|
||||
Text-to-Speech Voice (Read Aloud)
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
className={input}
|
||||
value={ttsVoice}
|
||||
onChange={(e) => setTtsVoice(e.target.value)}
|
||||
data-testid="tts-voice-select"
|
||||
>
|
||||
<option value="">Server default{options ? ' (' + options.ttsProvider + ')' : ''}</option>
|
||||
{options?.ttsVoices.map((v) => (
|
||||
<option key={v.value} value={v.value}>{v.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
className={btnGhost}
|
||||
disabled={previewing}
|
||||
onClick={previewVoice}
|
||||
data-testid="btn-preview-voice"
|
||||
>
|
||||
{previewing ? 'Loading…' : 'Preview'}
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={btnPrimary}
|
||||
disabled={save.isPending}
|
||||
onClick={() =>
|
||||
save.mutate({
|
||||
stt_model: sttModel || null,
|
||||
tts_voice: ttsVoice || null,
|
||||
})
|
||||
}
|
||||
data-testid="btn-save-voice-prefs"
|
||||
>
|
||||
{save.isPending ? 'Saving…' : 'Save Voice Preferences'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<StatusLine msg={msg} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<section className={card} data-testid="browser-whisper-section">
|
||||
<h3 className="text-base font-semibold">Browser Transcription (Local Whisper)</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<label className="text-sm font-medium">Enable browser transcription:</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-primary size-4"
|
||||
checked={enabled}
|
||||
onChange={(e) => toggle(e.target.checked)}
|
||||
data-testid="browser-whisper-enabled"
|
||||
/>
|
||||
<span className="text-sm" data-testid="browser-whisper-status">
|
||||
{enabled ? 'On — audio stays on device' : 'Off'}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<label className="text-sm font-medium">Model:</label>
|
||||
<select
|
||||
className={input + ' max-w-md'}
|
||||
value={model}
|
||||
onChange={(e) => changeModel(e.target.value)}
|
||||
data-testid="browser-whisper-model"
|
||||
>
|
||||
{BW_MODELS.map((m) => (
|
||||
<option key={m.value} value={m.value}>{m.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<section className={card + ' border-l-4 border-l-orange-500'} data-testid="web-speech-section">
|
||||
<h3 className="text-base font-semibold">Real-Time Streaming Transcription</h3>
|
||||
<div className="bg-orange-50 dark:bg-orange-950/30 p-3 rounded-md text-sm text-orange-900 dark:text-orange-100">
|
||||
<strong>Privacy Warning:</strong> Uses your browser's built-in speech recognition, which <strong>may send audio to cloud servers</strong> (Chrome/Edge send to Google). Only enable if you accept this trade-off for real-time transcription.
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
See words appear as you speak (streaming). Overrides browser and server transcription when enabled. <strong>Not HIPAA-compliant</strong> in most browsers.
|
||||
</p>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<label className="text-sm font-medium">Enable real-time streaming:</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-orange-500 size-4"
|
||||
checked={enabled}
|
||||
disabled={!supported}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) setConfirmOpen(true);
|
||||
else disable();
|
||||
}}
|
||||
data-testid="web-speech-enabled"
|
||||
/>
|
||||
<span className="text-sm" data-testid="web-speech-status">
|
||||
{!supported ? 'Not supported in this browser' : enabled ? 'On — real-time streaming' : 'Off'}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
{supported && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Streaming integration lights up when the recording components port to React.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<ConfirmModal
|
||||
open={confirmOpen}
|
||||
title="Enable real-time streaming transcription?"
|
||||
body="Your browser's speech recognition may send audio to cloud servers (e.g. Google). This is NOT HIPAA-compliant. Only enable if you understand and accept this privacy trade-off."
|
||||
confirmText="Enable"
|
||||
danger
|
||||
onConfirm={() => {
|
||||
enable();
|
||||
setConfirmOpen(false);
|
||||
}}
|
||||
onCancel={() => setConfirmOpen(false)}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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<string, string> = Object.fromEntries(
|
||||
TEMPLATE_CATEGORIES.map((c) => [c.value, c.label.replace(/ Template$| Format$/, '')])
|
||||
);
|
||||
|
||||
function TemplatesCard() {
|
||||
const qc = useQueryClient();
|
||||
const [msg, setMsg] = useState<Msg>(null);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [category, setCategory] = useState(TEMPLATE_CATEGORIES[0].value);
|
||||
const [name, setName] = useState('');
|
||||
const [content, setContent] = useState('');
|
||||
const [deleteTarget, setDeleteTarget] = useState<MemoryRow | null>(null);
|
||||
|
||||
const { data } = useQuery<MemoriesOk>({
|
||||
queryKey: ['memories'],
|
||||
queryFn: () => api.get<MemoriesOk>('/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 (
|
||||
<section className={card} data-testid="templates-section">
|
||||
<h3 className="text-base font-semibold">My Templates</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Save reusable templates for physical exam, ROS, encounter format, etc. The AI will use these when generating notes.
|
||||
</p>
|
||||
|
||||
<form onSubmit={submit} className="space-y-2">
|
||||
<div className="flex gap-2 flex-wrap items-center">
|
||||
<select
|
||||
className={input + ' max-w-xs'}
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
data-testid="mem-category"
|
||||
>
|
||||
{TEMPLATE_CATEGORIES.map((c) => (
|
||||
<option key={c.value} value={c.value}>{c.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
className={input + ' flex-1 min-w-[150px]'}
|
||||
placeholder="Template name (e.g. Normal PE)"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
data-testid="mem-name"
|
||||
/>
|
||||
</div>
|
||||
<textarea
|
||||
rows={5}
|
||||
className={input + ' resize-y'}
|
||||
placeholder="Paste your template here. Example: HEENT: Normocephalic, atraumatic. Eyes: PERRL…"
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
data-testid="mem-content"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
className={btnPrimary}
|
||||
disabled={save.isPending}
|
||||
data-testid="btn-mem-save"
|
||||
>
|
||||
{save.isPending ? 'Saving…' : editingId ? 'Update Template' : 'Add Template'}
|
||||
</button>
|
||||
{editingId !== null && (
|
||||
<button type="button" className={btnGhost} onClick={cancelEdit}>Cancel</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="space-y-2">
|
||||
{templates.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">No templates saved yet. Add one above.</div>
|
||||
) : (
|
||||
templates.map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-md bg-muted/40 border border-border"
|
||||
data-testid={'mem-row-' + m.id}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="font-medium">{m.name}</span>
|
||||
<span className="text-xs text-muted-foreground uppercase tracking-wide">
|
||||
{CATEGORY_LABEL[m.category] || m.category}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{(m.content || '').slice(0, 100).replace(/\n/g, ' ')}
|
||||
{m.content && m.content.length > 100 ? '…' : ''}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={btnGhost + ' text-xs'}
|
||||
onClick={() => beginEdit(m)}
|
||||
data-testid={'btn-mem-edit-' + m.id}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={btnGhost + ' text-destructive text-xs'}
|
||||
onClick={() => setDeleteTarget(m)}
|
||||
data-testid={'btn-mem-delete-' + m.id}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<StatusLine msg={msg} />
|
||||
|
||||
<ConfirmModal
|
||||
open={!!deleteTarget}
|
||||
title={'Delete template "' + (deleteTarget?.name || '') + '"?'}
|
||||
body="This cannot be undone."
|
||||
confirmText="Delete"
|
||||
danger
|
||||
busy={del.isPending}
|
||||
onConfirm={() => {
|
||||
if (deleteTarget) del.mutate(deleteTarget.id);
|
||||
setDeleteTarget(null);
|
||||
}}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── AI Corrections (read-only, expandable) ──────────────────
|
||||
const CORRECTION_LABEL: Record<string, string> = {
|
||||
correction_soap: 'SOAP Correction',
|
||||
correction_hpi: 'HPI Correction',
|
||||
correction_encounter: 'Encounter Correction',
|
||||
correction_wellvisit: 'Well Visit Correction',
|
||||
correction_sickvisit: 'Sick Visit Correction',
|
||||
};
|
||||
function parseCorrection(content: string): { original: string; corrected: string } {
|
||||
const idx = content.indexOf('\nCORRECTED TO: ');
|
||||
if (idx === -1) return { original: content.trim(), corrected: '' };
|
||||
return {
|
||||
original: content.substring(0, idx).replace(/^ORIGINAL:\s*/i, '').trim(),
|
||||
corrected: content.substring(idx + '\nCORRECTED TO: '.length).trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function CorrectionsCard() {
|
||||
const qc = useQueryClient();
|
||||
const [expanded, setExpanded] = useState<Record<number, boolean>>({});
|
||||
const [msg, setMsg] = useState<Msg>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<MemoryRow | null>(null);
|
||||
|
||||
const { data } = useQuery<MemoriesOk>({
|
||||
queryKey: ['memories'],
|
||||
queryFn: () => api.get<MemoriesOk>('/api/memories'),
|
||||
});
|
||||
const corrections = (data?.memories || []).filter((m) => m.category.startsWith('correction_'));
|
||||
|
||||
const del = useMutation({
|
||||
mutationFn: (id: number) => api.delete<{ success: true }>('/api/memories/' + id),
|
||||
onSuccess: () => {
|
||||
setMsg({ text: 'Correction deleted', kind: 'info' });
|
||||
qc.invalidateQueries({ queryKey: ['memories'] });
|
||||
},
|
||||
onError: (e: Error) => setMsg({ text: e.message || 'Delete failed', kind: 'err' }),
|
||||
});
|
||||
|
||||
return (
|
||||
<section className={card} data-testid="corrections-section">
|
||||
<h3 className="text-base font-semibold">AI Learning (Corrections)</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
{corrections.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
No corrections yet. Edit AI-generated notes and save to start learning.
|
||||
</div>
|
||||
) : (
|
||||
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 (
|
||||
<div
|
||||
key={m.id}
|
||||
className="rounded-md bg-muted/40 border border-border overflow-hidden"
|
||||
data-testid={'corr-row-' + m.id}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-left"
|
||||
onClick={() => setExpanded({ ...expanded, [m.id]: !isOpen })}
|
||||
>
|
||||
<span className="text-xs text-muted-foreground">{isOpen ? '▾' : '▸'}</span>
|
||||
<span className="text-xs text-muted-foreground uppercase tracking-wide">
|
||||
{CORRECTION_LABEL[m.category] || m.category}
|
||||
</span>
|
||||
<span className="flex-1 text-sm truncate">{m.name}</span>
|
||||
<span className="text-xs text-muted-foreground">{date}</span>
|
||||
<span
|
||||
role="button"
|
||||
className="text-destructive text-xs px-2"
|
||||
onClick={(e) => { e.stopPropagation(); setDeleteTarget(m); }}
|
||||
data-testid={'btn-corr-delete-' + m.id}
|
||||
>
|
||||
Delete
|
||||
</span>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="px-3 py-2 text-sm space-y-2 border-t border-border">
|
||||
<div>
|
||||
<div className="text-xs font-semibold uppercase text-destructive mb-1">Original (AI generated):</div>
|
||||
<div className="whitespace-pre-wrap text-muted-foreground">{parts.original}</div>
|
||||
</div>
|
||||
{parts.corrected && (
|
||||
<div>
|
||||
<div className="text-xs font-semibold uppercase text-green-600 mb-1">Corrected to:</div>
|
||||
<div className="whitespace-pre-wrap">{parts.corrected}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
<StatusLine msg={msg} />
|
||||
|
||||
<ConfirmModal
|
||||
open={!!deleteTarget}
|
||||
title="Delete this correction?"
|
||||
body="The AI will no longer apply this edit in future notes."
|
||||
confirmText="Delete"
|
||||
danger
|
||||
busy={del.isPending}
|
||||
onConfirm={() => {
|
||||
if (deleteTarget) del.mutate(deleteTarget.id);
|
||||
setDeleteTarget(null);
|
||||
}}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Audio Backups (server-stored, 24h TTL) ──────────────────
|
||||
function AudioBackupsCard() {
|
||||
const qc = useQueryClient();
|
||||
const [msg, setMsg] = useState<Msg>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<AudioBackupRow | null>(null);
|
||||
|
||||
const { data, isLoading } = useQuery<AudioBackupsListOk>({
|
||||
queryKey: ['audio-backups'],
|
||||
queryFn: () => api.get<AudioBackupsListOk>('/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 (
|
||||
<section className={card} data-testid="audio-backups-section">
|
||||
<h3 className="text-base font-semibold">Audio Backups</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Recordings are automatically backed up on the server and kept for 24 hours. Retry flow ports with the recording components.
|
||||
</p>
|
||||
{isLoading && <div className="text-sm text-muted-foreground">Loading…</div>}
|
||||
<div className="space-y-2">
|
||||
{data && data.backups.length === 0 && (
|
||||
<div className="text-sm text-muted-foreground">No audio backups.</div>
|
||||
)}
|
||||
{data?.backups.map((b) => {
|
||||
const sizeKb = Math.round(b.size_bytes / 1024);
|
||||
const comp = b.compressed_bytes ? ' (' + Math.round(b.compressed_bytes / 1024) + ' KB compressed)' : '';
|
||||
return (
|
||||
<div
|
||||
key={b.id}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-md bg-muted/40 border border-border"
|
||||
data-testid={'audio-backup-row-' + b.id}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium">{b.module} recording</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{new Date(b.created_at).toLocaleString()} · {sizeKb} KB{comp} · {timeAgo(b.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={btnPrimary + ' text-xs'}
|
||||
onClick={() => playBackup(b.id)}
|
||||
>
|
||||
Play
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={btnGhost + ' text-destructive text-xs'}
|
||||
onClick={() => setDeleteTarget(b)}
|
||||
data-testid={'btn-audio-delete-' + b.id}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<StatusLine msg={msg} />
|
||||
|
||||
<ConfirmModal
|
||||
open={!!deleteTarget}
|
||||
title="Delete this audio backup?"
|
||||
body="This cannot be undone."
|
||||
confirmText="Delete"
|
||||
danger
|
||||
busy={del.isPending}
|
||||
onConfirm={() => {
|
||||
if (deleteTarget) del.mutate(deleteTarget.id);
|
||||
setDeleteTarget(null);
|
||||
}}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Saved Encounters (auto-deleted after 7 days) ────────────
|
||||
function SavedEncountersCard() {
|
||||
const qc = useQueryClient();
|
||||
const [msg, setMsg] = useState<Msg>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<SavedEncounterRow | null>(null);
|
||||
|
||||
const { data, isLoading } = useQuery<SavedEncountersListOk>({
|
||||
queryKey: ['saved-encounters'],
|
||||
queryFn: () => api.get<SavedEncountersListOk>('/api/encounters/saved'),
|
||||
});
|
||||
|
||||
const del = useMutation({
|
||||
mutationFn: (id: number) => api.delete<{ success: true }>('/api/encounters/saved/' + id),
|
||||
onSuccess: () => {
|
||||
setMsg({ text: 'Deleted', kind: 'info' });
|
||||
qc.invalidateQueries({ queryKey: ['saved-encounters'] });
|
||||
},
|
||||
onError: (e: Error) => setMsg({ text: e.message, kind: 'err' }),
|
||||
});
|
||||
|
||||
return (
|
||||
<section className={card} data-testid="saved-encounters-section">
|
||||
<h3 className="text-base font-semibold">Saved Encounters</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Encounters are automatically deleted after 7 days per site policy. Resume action ports with the encounter components.
|
||||
</p>
|
||||
{isLoading && <div className="text-sm text-muted-foreground">Loading…</div>}
|
||||
<div className="space-y-2">
|
||||
{data && data.encounters.length === 0 && (
|
||||
<div className="text-sm text-muted-foreground">No saved encounters.</div>
|
||||
)}
|
||||
{data?.encounters.map((enc) => {
|
||||
const date = new Date(enc.updated_at).toLocaleDateString();
|
||||
const expires = new Date(enc.expires_at).toLocaleDateString();
|
||||
const preview = (enc.transcript_preview || '').slice(0, 80);
|
||||
return (
|
||||
<div
|
||||
key={enc.id}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-md bg-muted/40 border border-border"
|
||||
data-testid={'enc-row-' + enc.id}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="font-medium truncate">{enc.label || 'Untitled'}</span>
|
||||
<span className="text-xs text-muted-foreground uppercase tracking-wide">
|
||||
{enc.enc_type}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{date} · expires {expires}{preview ? ' · ' + preview + '…' : ''}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={btnGhost + ' text-destructive text-xs'}
|
||||
onClick={() => setDeleteTarget(enc)}
|
||||
data-testid={'btn-enc-delete-' + enc.id}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<StatusLine msg={msg} />
|
||||
|
||||
<ConfirmModal
|
||||
open={!!deleteTarget}
|
||||
title="Delete this saved encounter?"
|
||||
body={deleteTarget?.label || undefined}
|
||||
confirmText="Delete"
|
||||
danger
|
||||
busy={del.isPending}
|
||||
onConfirm={() => {
|
||||
if (deleteTarget) del.mutate(deleteTarget.id);
|
||||
setDeleteTarget(null);
|
||||
}}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Compliance (static info card) ───────────────────────────
|
||||
function ComplianceCard() {
|
||||
return (
|
||||
<section className={card} data-testid="compliance-section">
|
||||
<h3 className="text-base font-semibold">Compliance & Usage</h3>
|
||||
<div className="text-sm space-y-2">
|
||||
<p>
|
||||
<strong>AWS Bedrock</strong> is available with a Business Associate Agreement (BAA) for HIPAA-eligible workloads.
|
||||
</p>
|
||||
<ul className="list-disc pl-5 space-y-1 text-muted-foreground">
|
||||
<li>All connections use HTTPS/TLS encryption</li>
|
||||
<li>Authentication with optional 2FA</li>
|
||||
<li>No patient data stored on server beyond session</li>
|
||||
<li>AWS Bedrock supports BAA for HIPAA compliance</li>
|
||||
<li>Azure OpenAI supports BAA for HIPAA compliance</li>
|
||||
</ul>
|
||||
<p>
|
||||
<strong>Important:</strong> 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.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Page shell ───────────────────────────────────────────────
|
||||
export default function Settings() {
|
||||
const { data: me, isLoading, error } = useQuery<MeOk>({
|
||||
|
|
@ -965,6 +1771,19 @@ export default function Settings() {
|
|||
{/* Integrations — shown to all users, not gated by canLocalAuth. */}
|
||||
{me && <NextcloudCard user={me.user} />}
|
||||
{me && <DocumentsCard />}
|
||||
|
||||
{/* Voice + transcription */}
|
||||
{me && <VoicePreferencesCard />}
|
||||
{me && <BrowserWhisperCard />}
|
||||
{me && <WebSpeechCard />}
|
||||
|
||||
{/* Personal content */}
|
||||
{me && <TemplatesCard />}
|
||||
{me && <CorrectionsCard />}
|
||||
{me && <AudioBackupsCard />}
|
||||
{me && <SavedEncountersCard />}
|
||||
|
||||
<ComplianceCard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -250,6 +250,70 @@ export interface DocumentDownloadOk {
|
|||
url: string;
|
||||
}
|
||||
|
||||
// ── Voice prefs + transcription settings ─────────────────────
|
||||
// /api/user/preferences
|
||||
export interface UserPreferencesOk {
|
||||
stt_model: string | null;
|
||||
tts_voice: string | null;
|
||||
}
|
||||
// /api/user/preferences/options — the provider-scoped lists of models/voices
|
||||
export interface VoiceOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
export interface PreferencesOptionsOk {
|
||||
sttProvider: string;
|
||||
sttModels: VoiceOption[];
|
||||
ttsProvider: string;
|
||||
ttsVoices: VoiceOption[];
|
||||
}
|
||||
|
||||
// ── Saved encounters list (Settings view) ────────────────────
|
||||
// Note: /api/encounters/saved returns a richer row than the sidebar
|
||||
// EncounterSummary. The Settings list only needs these fields.
|
||||
export interface SavedEncounterRow {
|
||||
id: number;
|
||||
label: string;
|
||||
enc_type: string;
|
||||
status?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
expires_at: string;
|
||||
transcript_preview?: string;
|
||||
note_preview?: string;
|
||||
}
|
||||
export interface SavedEncountersListOk {
|
||||
encounters: SavedEncounterRow[];
|
||||
}
|
||||
|
||||
// ── Audio backups (server-stored recordings, 24h TTL) ────────
|
||||
export interface AudioBackupRow {
|
||||
id: number;
|
||||
module: string;
|
||||
mime_type: string;
|
||||
size_bytes: number;
|
||||
compressed_bytes?: number;
|
||||
created_at: string;
|
||||
expires_at: string;
|
||||
}
|
||||
export interface AudioBackupsListOk {
|
||||
backups: AudioBackupRow[];
|
||||
}
|
||||
|
||||
// ── Memories (templates + corrections share this shape) ──────
|
||||
// Extends the minimal Memory type with fields needed by the Settings
|
||||
// list view (corrections need created_at to show dates).
|
||||
export interface MemoryRow {
|
||||
id: number;
|
||||
category: string;
|
||||
name: string;
|
||||
content: string;
|
||||
created_at?: string;
|
||||
}
|
||||
export interface MemoriesOk {
|
||||
memories: MemoryRow[];
|
||||
}
|
||||
|
||||
// ── Extensions (pagers/directory) ────────────────────────────
|
||||
export interface Extension {
|
||||
id: number;
|
||||
|
|
|
|||
85
e2e/tests/settings-react-voice-content.spec.js
Normal file
85
e2e/tests/settings-react-voice-content.spec.js
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
// ============================================================
|
||||
// SETTINGS (React port) — Voice + Content sub-sections.
|
||||
//
|
||||
// Commit 3 of the Settings port. Covers the eight remaining
|
||||
// sub-sections so the React Settings page matches settings.html
|
||||
// feature-for-feature:
|
||||
// • Voice Preferences (STT / TTS selectors + Save + Preview)
|
||||
// • Browser Whisper (WASM stub — localStorage-only)
|
||||
// • Web Speech Recognition (localStorage-only + privacy modal)
|
||||
// • My Templates (Memories CRUD)
|
||||
// • AI Corrections (read-only list)
|
||||
// • Audio Backups (list + delete)
|
||||
// • Saved Encounters (list + delete)
|
||||
// • Compliance (static info card)
|
||||
// ============================================================
|
||||
|
||||
const { test, expect, E2E_BASE } = require('../fixtures');
|
||||
|
||||
async function openReactSettings(page) {
|
||||
await page.goto(E2E_BASE + '/app/settings');
|
||||
await page.waitForSelector(
|
||||
'[data-testid="voice-preferences-section"], [data-testid="templates-section"], [data-testid="compliance-section"]',
|
||||
{ timeout: 15000 }
|
||||
);
|
||||
}
|
||||
|
||||
test.describe('React Settings — Voice + Content sections render', () => {
|
||||
|
||||
test('Voice Preferences: STT / TTS selectors + Save + Preview', async ({ authedPage: _, page }) => {
|
||||
await openReactSettings(page);
|
||||
await expect(page.locator('[data-testid="stt-model-select"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="tts-voice-select"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="btn-save-voice-prefs"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="btn-preview-voice"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Browser Whisper card: enable toggle + model select', async ({ authedPage: _, page }) => {
|
||||
await openReactSettings(page);
|
||||
await expect(page.locator('[data-testid="browser-whisper-enabled"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="browser-whisper-model"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="browser-whisper-status"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Web Speech: checkbox shows privacy confirm modal before enabling', async ({ authedPage: _, page }) => {
|
||||
let nativeDialogFired = false;
|
||||
page.on('dialog', async (d) => { nativeDialogFired = true; await d.dismiss(); });
|
||||
|
||||
await openReactSettings(page);
|
||||
const checkbox = page.locator('[data-testid="web-speech-enabled"]');
|
||||
await expect(checkbox).toBeVisible();
|
||||
const isDisabled = await checkbox.isDisabled();
|
||||
if (!isDisabled) {
|
||||
await checkbox.check();
|
||||
await expect(page.locator('[data-testid="confirm-modal-cancel"]')).toBeVisible();
|
||||
await page.click('[data-testid="confirm-modal-cancel"]');
|
||||
}
|
||||
expect(nativeDialogFired).toBe(false);
|
||||
});
|
||||
|
||||
test('Templates: inputs + save button present', async ({ authedPage: _, page }) => {
|
||||
await openReactSettings(page);
|
||||
await expect(page.locator('[data-testid="mem-category"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="mem-name"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="mem-content"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="btn-mem-save"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Templates: saving without name shows inline error', async ({ authedPage: _, page }) => {
|
||||
await openReactSettings(page);
|
||||
await page.click('[data-testid="btn-mem-save"]');
|
||||
await expect(page.getByText('Enter a template name')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Corrections card renders (list may be empty)', async ({ authedPage: _, page }) => {
|
||||
await openReactSettings(page);
|
||||
await expect(page.locator('[data-testid="corrections-section"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Audio Backups + Saved Encounters + Compliance render', async ({ authedPage: _, page }) => {
|
||||
await openReactSettings(page);
|
||||
await expect(page.locator('[data-testid="audio-backups-section"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="saved-encounters-section"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="compliance-section"]')).toBeVisible();
|
||||
});
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
52
public/app/assets/index-DG4q33Hv.js
Normal file
52
public/app/assets/index-DG4q33Hv.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
2
public/app/assets/index-DiAauNJX.css
Normal file
2
public/app/assets/index-DiAauNJX.css
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -5,8 +5,8 @@
|
|||
<link rel="icon" type="image/svg+xml" href="/app/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>client</title>
|
||||
<script type="module" crossorigin src="/app/assets/index-DdnCNlZ0.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/app/assets/index-BT9JgnfU.css">
|
||||
<script type="module" crossorigin src="/app/assets/index-DG4q33Hv.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/app/assets/index-DiAauNJX.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -250,6 +250,70 @@ export interface DocumentDownloadOk {
|
|||
url: string;
|
||||
}
|
||||
|
||||
// ── Voice prefs + transcription settings ─────────────────────
|
||||
// /api/user/preferences
|
||||
export interface UserPreferencesOk {
|
||||
stt_model: string | null;
|
||||
tts_voice: string | null;
|
||||
}
|
||||
// /api/user/preferences/options — the provider-scoped lists of models/voices
|
||||
export interface VoiceOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
export interface PreferencesOptionsOk {
|
||||
sttProvider: string;
|
||||
sttModels: VoiceOption[];
|
||||
ttsProvider: string;
|
||||
ttsVoices: VoiceOption[];
|
||||
}
|
||||
|
||||
// ── Saved encounters list (Settings view) ────────────────────
|
||||
// Note: /api/encounters/saved returns a richer row than the sidebar
|
||||
// EncounterSummary. The Settings list only needs these fields.
|
||||
export interface SavedEncounterRow {
|
||||
id: number;
|
||||
label: string;
|
||||
enc_type: string;
|
||||
status?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
expires_at: string;
|
||||
transcript_preview?: string;
|
||||
note_preview?: string;
|
||||
}
|
||||
export interface SavedEncountersListOk {
|
||||
encounters: SavedEncounterRow[];
|
||||
}
|
||||
|
||||
// ── Audio backups (server-stored recordings, 24h TTL) ────────
|
||||
export interface AudioBackupRow {
|
||||
id: number;
|
||||
module: string;
|
||||
mime_type: string;
|
||||
size_bytes: number;
|
||||
compressed_bytes?: number;
|
||||
created_at: string;
|
||||
expires_at: string;
|
||||
}
|
||||
export interface AudioBackupsListOk {
|
||||
backups: AudioBackupRow[];
|
||||
}
|
||||
|
||||
// ── Memories (templates + corrections share this shape) ──────
|
||||
// Extends the minimal Memory type with fields needed by the Settings
|
||||
// list view (corrections need created_at to show dates).
|
||||
export interface MemoryRow {
|
||||
id: number;
|
||||
category: string;
|
||||
name: string;
|
||||
content: string;
|
||||
created_at?: string;
|
||||
}
|
||||
export interface MemoriesOk {
|
||||
memories: MemoryRow[];
|
||||
}
|
||||
|
||||
// ── Extensions (pagers/directory) ────────────────────────────
|
||||
export interface Extension {
|
||||
id: number;
|
||||
|
|
|
|||
Loading…
Reference in a new issue