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:
parent
cd1f762f34
commit
8d815ba09e
8 changed files with 253 additions and 21 deletions
176
client/src/components/OutputActions.tsx
Normal file
176
client/src/components/OutputActions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue