feat(notes): restore Refine / Shorter / Read / Nextcloud export on every note page

Port of the vanilla refine-bar + output-actions block that every note
page had (see public/components/{encounter,dictation,soap,sickvisit,
wellvisit,hospital,chart}.html at commit be14578).

One shared OutputActions component renders under every generated
note's body with:

  📋 Copy     → navigator.clipboard
  🔊 Read     → POST /api/text-to-speech  (audio/mpeg → Audio playback
                with stop-on-second-click)
  ☁️ Export   → POST /api/nextcloud/export  (uses each page's exportLabel
                as the filename prefix, exportType in the type field)
  ✏️ Refine   → POST /api/refine — free-text instructions textarea,
                sourceContext carries the original transcript so the
                model can reference the source when following orders
  📏 Shorter  → POST /api/shorten

Busy/success/error state shown inline per-component — no native alerts.
Refine result replaces the existing output via onUpdate, matching
vanilla setOutputText() behavior.

Wired into all 7 note pages (Encounter, Dictation, SOAP, Sick Visit,
Well Visit, Hospital Course, Chart Review) with per-page exportLabel
/ exportType matching the vanilla data-label values.
This commit is contained in:
Daniel 2026-04-24 03:50:57 +02:00
parent cd1f762f34
commit 8d815ba09e
8 changed files with 253 additions and 21 deletions

View file

@ -0,0 +1,176 @@
// ============================================================
// OutputActions — Copy / Read / Export / Refine / Shorter bar
// that sits under every generated note. Port of the vanilla
// `output-actions` + `refine-bar` blocks (same buttons on every
// note page component).
//
// Copy → navigator.clipboard
// Read → POST /api/text-to-speech (MPEG bytes → Audio)
// Export → POST /api/nextcloud/export
// Refine → POST /api/refine (instructions + sourceContext)
// Shorter→ POST /api/shorten
//
// Each button is independent — if Nextcloud is unconfigured, only
// that one errors; the rest keep working.
// ============================================================
import { useRef, useState } from 'react';
import { api, ApiError } from '@/lib/api';
import type { RefineOk, ShortenOk } from '@/shared/types';
interface Props {
text: string;
// Applied after Refine / Shorter — parent updates its result state.
onUpdate: (newText: string) => void;
// Optional — passed to /api/refine as sourceContext so the model
// can reference the original transcript when following instructions.
sourceContext?: string;
// Nextcloud filename prefix (e.g. 'hpi-encounter', 'soap-note').
exportLabel: string;
// Nextcloud docType (matches vanilla's data-label / type field).
exportType: string;
}
export default function OutputActions({
text, onUpdate, sourceContext, exportLabel, exportType,
}: Props) {
const [instructions, setInstructions] = useState('');
const [busy, setBusy] = useState<null | 'refine' | 'shorten' | 'tts' | 'export'>(null);
const [msg, setMsg] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
const audioUrlRef = useRef<string | null>(null);
async function copy() {
try {
await navigator.clipboard.writeText(text);
setMsg({ kind: 'ok', text: 'Copied' });
} catch {
setMsg({ kind: 'err', text: 'Copy failed' });
}
}
async function read() {
if (!text.trim()) { setMsg({ kind: 'err', text: 'Nothing to read' }); return; }
// If already playing, stop.
if (audioRef.current && !audioRef.current.paused) {
audioRef.current.pause();
audioRef.current = null;
if (audioUrlRef.current) { URL.revokeObjectURL(audioUrlRef.current); audioUrlRef.current = null; }
return;
}
setBusy('tts'); setMsg(null);
try {
const resp = await fetch('/api/text-to-speech', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text }),
});
if (!resp.ok) {
const ct = resp.headers.get('content-type') || '';
const errMsg = ct.includes('json') ? ((await resp.json()).error || 'TTS failed') : 'TTS failed';
throw new Error(errMsg);
}
const blob = await resp.blob();
const url = URL.createObjectURL(blob);
audioUrlRef.current = url;
const audio = new Audio(url);
audioRef.current = audio;
audio.onended = () => {
URL.revokeObjectURL(url);
if (audioUrlRef.current === url) audioUrlRef.current = null;
if (audioRef.current === audio) audioRef.current = null;
};
await audio.play();
} catch (e) {
setMsg({ kind: 'err', text: (e as Error).message });
} finally { setBusy(null); }
}
async function exportToNextcloud() {
if (!text.trim()) { setMsg({ kind: 'err', text: 'Nothing to export' }); return; }
setBusy('export'); setMsg(null);
try {
const filename = exportLabel + '-' + Date.now();
const data = await api.post<{ success: true; message: string }>('/api/nextcloud/export', {
content: text, filename, type: exportType,
});
setMsg({ kind: 'ok', text: data.message || 'Exported' });
} catch (e) {
setMsg({ kind: 'err', text: (e as ApiError).message || 'Nextcloud not connected' });
} finally { setBusy(null); }
}
async function refine() {
if (!text.trim()) { setMsg({ kind: 'err', text: 'No document to refine' }); return; }
if (!instructions.trim()) { setMsg({ kind: 'err', text: 'Enter instructions' }); return; }
setBusy('refine'); setMsg(null);
try {
const data = await api.post<RefineOk>('/api/refine', {
currentDocument: text,
instructions: instructions.trim(),
sourceContext: sourceContext || undefined,
});
onUpdate(data.refined);
setInstructions('');
setMsg({ kind: 'ok', text: 'Refined' });
} catch (e) {
setMsg({ kind: 'err', text: (e as ApiError).message || 'Refine failed' });
} finally { setBusy(null); }
}
async function shorten() {
if (!text.trim()) { setMsg({ kind: 'err', text: 'Nothing to shorten' }); return; }
setBusy('shorten'); setMsg(null);
try {
const data = await api.post<ShortenOk>('/api/shorten', { document: text });
onUpdate(data.shortened);
setMsg({ kind: 'ok', text: 'Shortened' });
} catch (e) {
setMsg({ kind: 'err', text: (e as ApiError).message || 'Shorten failed' });
} finally { setBusy(null); }
}
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';
return (
<div className="space-y-2" data-testid={'output-actions-' + exportType}>
<div className="flex flex-wrap items-center gap-2">
<button type="button" onClick={copy} className={btnPrimary} data-testid="out-copy">
📋 Copy
</button>
<button type="button" onClick={read} disabled={busy === 'tts'} className={btn} data-testid="out-read">
{busy === 'tts' ? '⌛ Loading…' : (audioRef.current && !audioRef.current.paused ? '⏹ Stop' : '🔊 Read')}
</button>
<button type="button" onClick={exportToNextcloud} disabled={busy === 'export'} className={btn} data-testid="out-export">
{busy === 'export' ? '⌛ Exporting…' : '☁️ Export to Nextcloud'}
</button>
</div>
<div className="flex flex-col sm:flex-row gap-2">
<textarea
className="flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm min-h-[60px]"
placeholder="Tell AI how to modify (e.g., 'make it shorter', 'add that patient has asthma history')"
value={instructions}
onChange={(e) => setInstructions(e.target.value)}
data-testid="out-refine-input"
/>
<div className="flex sm:flex-col gap-2">
<button type="button" onClick={refine} disabled={busy === 'refine' || !instructions.trim()} className={btnPrimary} data-testid="out-refine">
{busy === 'refine' ? '⌛ Refining…' : '✏️ Refine'}
</button>
<button type="button" onClick={shorten} disabled={busy === 'shorten'} className={btn} data-testid="out-shorten">
{busy === 'shorten' ? '⌛ Shortening…' : '📏 Shorter'}
</button>
</div>
</div>
{msg && (
<div className={'text-xs ' + (msg.kind === 'ok' ? 'text-green-600' : 'text-destructive')}>
{msg.text}
</div>
)}
</div>
);
}

View file

@ -7,6 +7,7 @@ import { useMutation } from '@tanstack/react-query';
import { api, ApiError } from '@/lib/api';
import type { ChartReviewOk } from '@/shared/types';
import EncounterToolbar from '@/components/EncounterToolbar';
import OutputActions from '@/components/OutputActions';
type ReviewType = 'outpatient' | 'subspecialty' | 'ed';
const TYPE = 'chart' as const;
@ -182,11 +183,19 @@ export default function ChartReview() {
{result && (
<section className="rounded-lg border border-border bg-card">
<header className="px-4 py-2 border-b border-border flex items-center justify-between bg-muted/40">
<header className="px-4 py-2 border-b border-border bg-muted/40">
<h2 className="text-sm font-semibold">Chart Review</h2>
<button onClick={() => navigator.clipboard.writeText(result)} className="text-xs text-muted-foreground underline">Copy</button>
</header>
<div className="p-4 whitespace-pre-wrap text-sm">{result}</div>
<div className="px-4 pb-4">
<OutputActions
text={result}
onUpdate={setResult}
sourceContext={visits.map((v) => v.content).filter(Boolean).join('\n\n')}
exportLabel="chart-review"
exportType="chart-review"
/>
</div>
</section>
)}
</div>

View file

@ -15,6 +15,7 @@ import type { HpiOk } from '@/shared/types';
import { HpiEncounterRequestSchema, type HpiEncounterRequest } from '@/shared/schemas';
import Recorder from '@/components/Recorder';
import EncounterToolbar from '@/components/EncounterToolbar';
import OutputActions from '@/components/OutputActions';
type Setting = 'outpatient' | 'inpatient';
const TYPE = 'dictation' as const;
@ -156,16 +157,19 @@ export default function Dictation() {
{result && (
<section className="rounded-lg border border-border bg-card">
<header className="px-4 py-2 border-b border-border flex items-center justify-between bg-muted/40">
<header className="px-4 py-2 border-b border-border bg-muted/40">
<h2 className="text-sm font-semibold">Generated HPI</h2>
<button
onClick={() => navigator.clipboard.writeText(result)}
className="text-xs text-muted-foreground underline"
>
Copy
</button>
</header>
<div className="p-4 whitespace-pre-wrap text-sm">{result}</div>
<div className="px-4 pb-4">
<OutputActions
text={result}
onUpdate={setResult}
sourceContext={displayedTranscript}
exportLabel="hpi-dictation"
exportType="hpi"
/>
</div>
</section>
)}
</div>

View file

@ -12,6 +12,7 @@ import type { HpiOk } from '@/shared/types';
import { HpiEncounterRequestSchema, type HpiEncounterRequest } from '@/shared/schemas';
import Recorder from '@/components/Recorder';
import EncounterToolbar from '@/components/EncounterToolbar';
import OutputActions from '@/components/OutputActions';
type Setting = 'outpatient' | 'inpatient';
const TYPE = 'encounter' as const;
@ -141,13 +142,19 @@ export default function Encounter() {
{result && (
<section className="rounded-lg border border-border bg-card">
<header className="px-4 py-2 border-b border-border flex items-center justify-between bg-muted/40">
<header className="px-4 py-2 border-b border-border bg-muted/40">
<h2 className="text-sm font-semibold">Generated HPI</h2>
<button onClick={() => navigator.clipboard.writeText(result)} className="text-xs text-muted-foreground underline">
Copy
</button>
</header>
<div className="p-4 whitespace-pre-wrap text-sm">{result}</div>
<div className="px-4 pb-4">
<OutputActions
text={result}
onUpdate={setResult}
sourceContext={displayedTranscript}
exportLabel="hpi-encounter"
exportType="hpi"
/>
</div>
</section>
)}
</div>

View file

@ -8,6 +8,7 @@ import { api, ApiError } from '@/lib/api';
import type { HospitalCourseOk } from '@/shared/types';
import Recorder from '@/components/Recorder';
import EncounterToolbar from '@/components/EncounterToolbar';
import OutputActions from '@/components/OutputActions';
type SettingKind = 'floor' | 'picu' | 'nicu' | 'psych';
type FormatKind = 'auto' | 'prose' | 'dayByDay' | 'organSystem';
@ -183,13 +184,21 @@ export default function HospitalCourse() {
{result && (
<section className="rounded-lg border border-border bg-card">
<header className="px-4 py-2 border-b border-border flex items-center justify-between bg-muted/40">
<header className="px-4 py-2 border-b border-border bg-muted/40">
<h2 className="text-sm font-semibold">
Hospital Course <span className="text-xs font-normal text-muted-foreground">({result.format})</span>
</h2>
<button onClick={() => navigator.clipboard.writeText(result.hospitalCourse)} className="text-xs text-muted-foreground underline">Copy</button>
</header>
<div className="p-4 whitespace-pre-wrap text-sm">{result.hospitalCourse}</div>
<div className="px-4 pb-4">
<OutputActions
text={result.hospitalCourse}
onUpdate={(t) => setResult({ hospitalCourse: t, format: result.format })}
sourceContext={notesText}
exportLabel="hospital-course"
exportType="hospital-course"
/>
</div>
</section>
)}
</div>

View file

@ -9,6 +9,7 @@ import type { VisitNoteOk } from '@/shared/types';
import { SickVisitRequestSchema, type SickVisitRequest } from '@/shared/schemas';
import Recorder from '@/components/Recorder';
import EncounterToolbar from '@/components/EncounterToolbar';
import OutputActions from '@/components/OutputActions';
const TYPE = 'sickvisit' as const;
@ -132,11 +133,19 @@ export default function SickVisit() {
{result && (
<section className="rounded-lg border border-border bg-card">
<header className="px-4 py-2 border-b border-border flex items-center justify-between bg-muted/40">
<header className="px-4 py-2 border-b border-border bg-muted/40">
<h2 className="text-sm font-semibold">Sick Visit Note</h2>
<button onClick={() => navigator.clipboard.writeText(result)} className="text-xs text-muted-foreground underline">Copy</button>
</header>
<div className="p-4 whitespace-pre-wrap text-sm">{result}</div>
<div className="px-4 pb-4">
<OutputActions
text={result}
onUpdate={setResult}
sourceContext={displayedTranscript}
exportLabel="sick-visit-note"
exportType="sick-visit"
/>
</div>
</section>
)}
</div>

View file

@ -9,6 +9,7 @@ import type { SoapOk } from '@/shared/types';
import { SoapRequestSchema, type SoapRequest } from '@/shared/schemas';
import Recorder from '@/components/Recorder';
import EncounterToolbar from '@/components/EncounterToolbar';
import OutputActions from '@/components/OutputActions';
type SoapType = 'full' | 'subjective';
const TYPE = 'soap' as const;
@ -150,11 +151,19 @@ export default function Soap() {
{result && (
<section className="rounded-lg border border-border bg-card">
<header className="px-4 py-2 border-b border-border flex items-center justify-between bg-muted/40">
<header className="px-4 py-2 border-b border-border bg-muted/40">
<h2 className="text-sm font-semibold">Generated SOAP</h2>
<button onClick={() => navigator.clipboard.writeText(result)} className="text-xs text-muted-foreground underline">Copy</button>
</header>
<div className="p-4 whitespace-pre-wrap text-sm">{result}</div>
<div className="px-4 pb-4">
<OutputActions
text={result}
onUpdate={setResult}
sourceContext={displayedTranscript}
exportLabel="soap-note"
exportType="soap"
/>
</div>
</section>
)}
</div>

View file

@ -10,6 +10,7 @@ import { useMutation } from '@tanstack/react-query';
import { api, ApiError } from '@/lib/api';
import type { VisitNoteOk } from '@/shared/types';
import EncounterToolbar from '@/components/EncounterToolbar';
import OutputActions from '@/components/OutputActions';
const TYPE = 'wellvisit' as const;
@ -155,11 +156,19 @@ export default function WellVisit() {
{result && (
<section className="rounded-lg border border-border bg-card">
<header className="px-4 py-2 border-b border-border flex items-center justify-between bg-muted/40">
<header className="px-4 py-2 border-b border-border bg-muted/40">
<h2 className="text-sm font-semibold">Well Visit Note</h2>
<button onClick={() => navigator.clipboard.writeText(result)} className="text-xs text-muted-foreground underline">Copy</button>
</header>
<div className="p-4 whitespace-pre-wrap text-sm">{result}</div>
<div className="px-4 pb-4">
<OutputActions
text={result}
onUpdate={setResult}
sourceContext={transcript}
exportLabel="well-visit-note"
exportType="well-visit"
/>
</div>
</section>
)}
</div>