40234-vm/frontend/src/pages/session-memory.tsx
2026-06-09 17:13:18 +00:00

760 lines
26 KiB
TypeScript

import {
mdiCheckCircleOutline,
mdiCloudUploadOutline,
mdiContentCopy,
mdiFileDocumentEditOutline,
mdiLightbulbOnOutline,
mdiMicrophoneOutline,
mdiStopCircleOutline,
mdiSendOutline,
} from '@mdi/js';
import axios from 'axios';
import Head from 'next/head';
import React from 'react';
import type { ReactElement } from 'react';
import BaseButton from '../components/BaseButton';
import BaseIcon from '../components/BaseIcon';
import SectionMain from '../components/SectionMain';
import { getPageTitle } from '../config';
import LayoutAuthenticated from '../layouts/Authenticated';
type Client = {
id: string;
name: string;
};
type MemoryDraft = {
title?: string;
ai_summary?: string;
key_topics?: string;
goals_discussed?: string;
blockers?: string;
commitments?: string;
homework?: string;
emotional_themes?: string;
important_quotes?: string;
follow_up_email?: string;
next_session_prep?: string;
private_coach_notes?: string;
shared_client_notes?: string;
};
type Session = MemoryDraft & {
id: string;
status?: string;
client?: Client;
};
function Panel({
children,
className = '',
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<section
className={`rounded-none border border-[#19192d]/10 bg-white ${className}`}
>
{children}
</section>
);
}
function TextField({
label,
value,
onChange,
rows = 3,
}: {
label: string;
value: string;
onChange: (value: string) => void;
rows?: number;
}) {
return (
<label className='block'>
<span className='text-xs font-semibold uppercase tracking-[0.18em] text-[#35b7a5]'>
{label}
</span>
<textarea
value={value}
rows={rows}
onChange={(event) => onChange(event.target.value)}
className='mt-2 w-full rounded-none border border-[#19192d]/10 bg-[#fffdf9] px-4 py-3 text-sm leading-6 text-[#19192d] outline-none focus:border-[#35b7a5] focus:ring-2 focus:ring-[#35b7a5]/15'
/>
</label>
);
}
function StatusPill({ status }: { status?: string }) {
const isShared = status === 'shared';
return (
<span
className={`rounded-none px-3 py-1 text-xs font-semibold ${
isShared ? 'bg-[#fffdf9] text-[#35b7a5]' : 'bg-[#fffdf9] text-[#35b7a5]'
}`}
>
{isShared ? 'Shared' : 'Coach draft'}
</span>
);
}
function requestErrorMessage(error: unknown, fallback: string) {
if (!axios.isAxiosError(error)) {
return fallback;
}
const data = error.response?.data;
if (typeof data?.message === 'string') {
return data.message;
}
if (typeof data?.error === 'string') {
return data.error;
}
if (typeof data?.error?.message === 'string') {
return data.error.message;
}
return fallback;
}
const emptyDraft: MemoryDraft = {
title: '',
ai_summary: '',
key_topics: '',
goals_discussed: '',
blockers: '',
commitments: '',
homework: '',
emotional_themes: '',
important_quotes: '',
follow_up_email: '',
next_session_prep: '',
private_coach_notes: '',
shared_client_notes: '',
};
const SessionMemory = () => {
const [clients, setClients] = React.useState<Client[]>([]);
const [sessions, setSessions] = React.useState<Session[]>([]);
const [clientId, setClientId] = React.useState('');
const [transcript, setTranscript] = React.useState('');
const [draft, setDraft] = React.useState<MemoryDraft>(emptyDraft);
const [audioFile, setAudioFile] = React.useState<File | null>(null);
const [isRecording, setIsRecording] = React.useState(false);
const [recordingSeconds, setRecordingSeconds] = React.useState(0);
const [isGenerating, setIsGenerating] = React.useState(false);
const [isTranscribing, setIsTranscribing] = React.useState(false);
const [isSaving, setIsSaving] = React.useState(false);
const [notice, setNotice] = React.useState('');
const [lastSavedSession, setLastSavedSession] =
React.useState<Session | null>(null);
const mediaRecorderRef = React.useRef<MediaRecorder | null>(null);
const mediaStreamRef = React.useRef<MediaStream | null>(null);
const audioChunksRef = React.useRef<Blob[]>([]);
async function loadData() {
const [clientsResponse, sessionsResponse] = await Promise.all([
axios.get('/coaching/clients'),
axios.get('/coaching/session-memory'),
]);
setClients(clientsResponse.data);
setSessions(sessionsResponse.data);
if (!clientId && clientsResponse.data.length > 0) {
setClientId(clientsResponse.data[0].id);
}
}
React.useEffect(() => {
loadData();
}, []);
React.useEffect(() => {
if (!isRecording) {
return;
}
const intervalId = window.setInterval(() => {
setRecordingSeconds((current) => current + 1);
}, 1000);
return () => {
window.clearInterval(intervalId);
};
}, [isRecording]);
React.useEffect(() => {
return () => {
stopAudioTracks();
};
}, []);
function updateDraft(field: keyof MemoryDraft, value: string) {
setDraft((current) => {
return {
...current,
[field]: value,
};
});
setNotice('');
setLastSavedSession(null);
}
async function generateMemory() {
setIsGenerating(true);
try {
const response = await axios.post('/coaching/session-memory/generate', {
clientId,
transcript,
});
setDraft({
...emptyDraft,
...response.data,
});
setNotice('Draft generated. Review and edit before saving.');
setLastSavedSession(null);
} catch (error) {
setNotice(requestErrorMessage(error, 'Memory generation failed.'));
} finally {
setIsGenerating(false);
}
}
async function transcribeAudio() {
if (!audioFile) {
setNotice('Choose an audio file first.');
return;
}
const formData = new FormData();
formData.append('audio', audioFile);
setIsTranscribing(true);
try {
const response = await axios.post(
'/coaching/session-memory/transcribe',
formData,
{
headers: { 'Content-Type': 'multipart/form-data' },
},
);
setTranscript(response.data.text || '');
setNotice(
'Audio transcribed. Review the transcript before generating memory.',
);
} catch (error) {
setNotice(requestErrorMessage(error, 'Audio transcription failed.'));
} finally {
setIsTranscribing(false);
}
}
function stopAudioTracks() {
if (!mediaStreamRef.current) {
return;
}
mediaStreamRef.current.getTracks().forEach((track) => {
track.stop();
});
mediaStreamRef.current = null;
}
function recordedAudioType() {
if (MediaRecorder.isTypeSupported('audio/webm;codecs=opus')) {
return 'audio/webm;codecs=opus';
}
if (MediaRecorder.isTypeSupported('audio/webm')) {
return 'audio/webm';
}
return '';
}
async function startRecording() {
if (typeof MediaRecorder === 'undefined') {
setNotice('Audio recording is not supported in this browser.');
return;
}
if (!navigator.mediaDevices?.getUserMedia) {
setNotice('Audio recording is not supported in this browser.');
return;
}
let stream: MediaStream;
try {
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
} catch (error) {
setNotice('Microphone access was blocked or failed.');
return;
}
const mimeType = recordedAudioType();
const options = mimeType ? { mimeType } : undefined;
const recorder = new MediaRecorder(stream, options);
audioChunksRef.current = [];
mediaStreamRef.current = stream;
mediaRecorderRef.current = recorder;
recorder.ondataavailable = (event) => {
if (event.data.size > 0) {
audioChunksRef.current.push(event.data);
}
};
recorder.onstop = () => {
const type = recorder.mimeType || 'audio/webm';
const blob = new Blob(audioChunksRef.current, { type });
const file = new File([blob], `session-recording-${Date.now()}.webm`, {
type,
});
setAudioFile(file);
setNotice('Recording saved. Transcribe it when you are ready.');
setIsRecording(false);
stopAudioTracks();
};
setRecordingSeconds(0);
setAudioFile(null);
setNotice('');
setIsRecording(true);
recorder.start();
}
function stopRecording() {
if (!mediaRecorderRef.current) {
return;
}
if (mediaRecorderRef.current.state !== 'inactive') {
mediaRecorderRef.current.stop();
}
}
function recordingDuration() {
const minutes = Math.floor(recordingSeconds / 60)
.toString()
.padStart(2, '0');
const seconds = (recordingSeconds % 60).toString().padStart(2, '0');
return `${minutes}:${seconds}`;
}
async function saveMemory(shareWithClient: boolean) {
setIsSaving(true);
const response = await axios.post('/coaching/session-memory/save', {
...draft,
clientId,
transcript_notes: transcript,
shareWithClient,
});
setSessions((current) => [response.data, ...current]);
setLastSavedSession(response.data);
setDraft(emptyDraft);
setTranscript('');
setNotice(
shareWithClient
? 'Session saved and shared with the client.'
: 'Session saved as a coach draft.',
);
setIsSaving(false);
}
async function shareSession(session: Session) {
const response = await axios.patch(
`/coaching/sessions/${session.id}/share`,
{
shared_client_notes: session.shared_client_notes,
},
);
setSessions((current) =>
current.map((currentSession) => {
if (currentSession.id === session.id) {
return response.data;
}
return currentSession;
}),
);
setNotice('Session shared with the client.');
}
async function copyFollowUp() {
await navigator.clipboard.writeText(draft.follow_up_email || '');
setNotice('Follow-up copied.');
}
const hasDraft = Boolean(
draft.title ||
draft.ai_summary ||
draft.follow_up_email ||
draft.shared_client_notes,
);
const selectedClient = clients.find((client) => client.id === clientId);
return (
<>
<Head>
<title>{getPageTitle('Session Memory')}</title>
</Head>
<SectionMain>
<div className='mx-auto max-w-7xl'>
<div className='mb-4 rounded-none bg-[#19192d] p-7 text-white'>
<div className='flex items-center gap-3 text-[#35b7a5]'>
<BaseIcon path={mdiFileDocumentEditOutline} size={18} />
<span className='text-xs font-semibold uppercase tracking-[0.22em]'>
Session Memory
</span>
</div>
<h1 className='mt-3 max-w-3xl text-xl font-semibold'>
Turn rough notes into follow-up, commitments, and next-session
prep.
</h1>
<p className='mt-2 max-w-3xl text-sm leading-6 text-[#fffdf9]'>
Generate a structured draft, edit it as the coach, save it as a
private draft, or share approved notes with the client portal.
</p>
</div>
{notice && (
<div className='mb-4 rounded-none border border-[#35b7a5]/20 bg-[#fffdf9] px-4 py-3 text-sm font-semibold text-[#35b7a5]'>
{notice}
</div>
)}
<div className='grid gap-6 xl:grid-cols-[0.8fr_1.2fr]'>
<Panel className='p-4'>
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-[#35b7a5]'>
Raw session input
</p>
<h2 className='mt-2 text-lg font-semibold text-[#19192d]'>
Extract a session
</h2>
<label className='mb-2 mt-6 block text-sm font-semibold text-[#72798a]'>
Client
</label>
<select
value={clientId}
onChange={(event) => setClientId(event.target.value)}
className='w-full rounded-none border border-[#19192d]/10 bg-white px-4 py-3 text-[#19192d] outline-none focus:border-[#35b7a5] focus:ring-2 focus:ring-[#35b7a5]/15'
>
{clients.map((client) => (
<option key={client.id} value={client.id}>
{client.name}
</option>
))}
</select>
<div className='mt-5 rounded-none border border-[#19192d]/10 bg-[#fffdf9] p-3'>
<div className='flex items-center gap-2 text-sm font-semibold text-[#19192d]'>
<BaseIcon path={mdiMicrophoneOutline} size={18} />
Audio transcription
</div>
<div className='mt-4 border border-[#19192d]/10 bg-white p-4'>
<div className='flex flex-col gap-4 md:flex-row md:items-center md:justify-between'>
<div>
<p className='text-sm font-semibold text-[#19192d]'>
Record audio
</p>
<p className='mt-1 text-sm text-[#72798a]'>
{isRecording
? `Recording ${recordingDuration()}`
: 'Use your microphone and transcribe the recording below.'}
</p>
</div>
{isRecording ? (
<button
type='button'
className='inline-flex items-center justify-center gap-2 rounded-none bg-[#19192d] px-4 py-2 text-sm font-semibold text-white'
onClick={stopRecording}
>
<BaseIcon path={mdiStopCircleOutline} size={18} />
Stop recording
</button>
) : (
<button
type='button'
className='inline-flex items-center justify-center gap-2 rounded-none bg-[#35b7a5] px-4 py-2 text-sm font-semibold text-white disabled:opacity-50'
disabled={isTranscribing}
onClick={startRecording}
>
<BaseIcon path={mdiMicrophoneOutline} size={18} />
Start recording
</button>
)}
</div>
</div>
<label className='mt-3 flex cursor-pointer items-center justify-between gap-3 rounded-none border border-dashed border-[#19192d]/15 bg-white px-4 py-4 text-sm text-[#72798a]'>
<span className='truncate'>
{audioFile ? audioFile.name : 'Choose an audio file'}
</span>
<span className='inline-flex items-center gap-2 rounded-none bg-[#fffdf9] px-3 py-1 text-xs font-semibold text-[#35b7a5]'>
<BaseIcon path={mdiCloudUploadOutline} size={16} />
Upload
</span>
<input
type='file'
accept='audio/*'
className='hidden'
onChange={(event) => {
const file = event.target.files?.[0] || null;
setAudioFile(file);
setNotice('');
}}
/>
</label>
<button
type='button'
className='mt-3 inline-flex items-center gap-2 rounded-none bg-[#35b7a5] px-4 py-2 text-sm font-semibold text-white disabled:opacity-50'
disabled={isTranscribing || !audioFile}
onClick={transcribeAudio}
>
<BaseIcon path={mdiMicrophoneOutline} size={18} />
{isTranscribing ? 'Transcribing...' : 'Transcribe audio'}
</button>
</div>
<label className='mb-2 mt-5 block text-sm font-semibold text-[#72798a]'>
Transcript or raw notes
</label>
<textarea
value={transcript}
onChange={(event) => setTranscript(event.target.value)}
className='min-h-[220px] w-full rounded-none border border-[#19192d]/10 bg-[#fffdf9] px-4 py-3 leading-6 text-[#19192d] outline-none focus:border-[#35b7a5] focus:ring-2 focus:ring-[#35b7a5]/15'
placeholder='Paste session transcript, coach notes, commitments, blockers, or a rough debrief...'
/>
<div className='mt-5'>
<BaseButton
label={isGenerating ? 'Generating...' : 'Generate memory'}
color='info'
disabled={isGenerating || !clientId || !transcript.trim()}
onClick={generateMemory}
/>
</div>
</Panel>
<div className='space-y-4'>
<Panel className='p-4'>
<div className='flex items-start justify-between gap-6'>
<div>
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-[#35b7a5]'>
Coach review
</p>
<h2 className='mt-2 text-lg font-semibold text-[#19192d]'>
Edit the final memory
</h2>
</div>
{hasDraft && (
<span className='rounded-none bg-[#fffdf9] px-3 py-1 text-xs font-semibold text-[#35b7a5]'>
Draft not shared yet
</span>
)}
{!hasDraft && lastSavedSession && (
<StatusPill status={lastSavedSession.status} />
)}
</div>
{hasDraft ? (
<div className='mt-4 grid gap-6'>
<label className='block'>
<span className='text-xs font-semibold uppercase tracking-[0.18em] text-[#35b7a5]'>
Title
</span>
<input
value={draft.title || ''}
onChange={(event) =>
updateDraft('title', event.target.value)
}
className='mt-2 w-full rounded-none border border-[#19192d]/10 bg-[#fffdf9] px-4 py-3 text-sm text-[#19192d] outline-none focus:border-[#35b7a5] focus:ring-2 focus:ring-[#35b7a5]/15'
/>
</label>
<TextField
label='Summary'
value={draft.ai_summary || ''}
onChange={(value) => updateDraft('ai_summary', value)}
rows={4}
/>
<TextField
label='Commitments'
value={draft.commitments || ''}
onChange={(value) => updateDraft('commitments', value)}
/>
<TextField
label='Homework'
value={draft.homework || ''}
onChange={(value) => updateDraft('homework', value)}
/>
<TextField
label='Shared client notes'
value={draft.shared_client_notes || ''}
onChange={(value) =>
updateDraft('shared_client_notes', value)
}
rows={4}
/>
<TextField
label='Follow-up email'
value={draft.follow_up_email || ''}
onChange={(value) =>
updateDraft('follow_up_email', value)
}
rows={4}
/>
<TextField
label='Next-session prep'
value={draft.next_session_prep || ''}
onChange={(value) =>
updateDraft('next_session_prep', value)
}
/>
<TextField
label='Private coach notes'
value={draft.private_coach_notes || ''}
onChange={(value) =>
updateDraft('private_coach_notes', value)
}
/>
<div className='flex flex-wrap gap-3'>
<button
type='button'
className='inline-flex items-center gap-2 rounded-none bg-[#19192d] px-4 py-2 text-sm font-semibold text-white disabled:opacity-50'
disabled={isSaving}
onClick={() => saveMemory(false)}
>
<BaseIcon path={mdiCheckCircleOutline} size={18} />
Save coach draft
</button>
<button
type='button'
className='inline-flex items-center gap-2 rounded-none bg-[#35b7a5] px-4 py-2 text-sm font-semibold text-white disabled:opacity-50'
disabled={isSaving}
onClick={() => saveMemory(true)}
>
<BaseIcon path={mdiSendOutline} size={18} />
Save and share
</button>
<button
type='button'
className='inline-flex items-center gap-2 rounded-none border border-[#19192d]/10 bg-white px-4 py-2 text-sm font-semibold text-[#19192d]'
onClick={copyFollowUp}
>
<BaseIcon path={mdiContentCopy} size={18} />
Copy follow-up
</button>
</div>
</div>
) : lastSavedSession ? (
<div className='mt-4 rounded-none border border-[#35b7a5]/20 bg-[#fffdf9] p-6'>
<BaseIcon
path={
lastSavedSession.status === 'shared'
? mdiSendOutline
: mdiCheckCircleOutline
}
size={22}
className='text-[#35b7a5]'
/>
<p className='mt-4 font-semibold text-[#19192d]'>
{lastSavedSession.status === 'shared'
? 'Shared with client'
: 'Saved as coach draft'}
</p>
<p className='mt-2 leading-6 text-[#72798a]'>
{lastSavedSession.title} was saved for{' '}
{lastSavedSession.client?.name ||
selectedClient?.name ||
'the selected client'}
.
</p>
<p className='mt-3 text-sm font-semibold text-[#35b7a5]'>
You can find it in Recent memories below.
</p>
</div>
) : (
<div className='mt-4 rounded-none border border-dashed border-[#19192d]/10 bg-[#fffdf9] p-6'>
<BaseIcon
path={mdiLightbulbOnOutline}
size={22}
className='text-[#35b7a5]'
/>
<p className='mt-4 leading-6 text-[#72798a]'>
Generate memory from raw notes. The editable draft will
appear here before anything is saved or shared.
</p>
</div>
)}
</Panel>
<Panel>
<div className='border-b border-[#19192d]/10 p-7'>
<h2 className='text-lg font-semibold text-[#19192d]'>
Recent memories
</h2>
</div>
<div className='divide-y divide-[#19192d]/10'>
{sessions.map((session) => (
<div key={session.id} className='p-5'>
<div className='flex items-start justify-between gap-6'>
<div>
<div className='flex flex-wrap items-center gap-2'>
<p className='font-semibold text-[#19192d]'>
{session.title}
</p>
<StatusPill status={session.status} />
</div>
<p className='mt-1 text-sm text-[#72798a]'>
{session.client?.name}
</p>
</div>
{session.status !== 'shared' && (
<button
type='button'
className='inline-flex items-center gap-2 rounded-none bg-[#35b7a5] px-4 py-2 text-sm font-semibold text-white'
onClick={() => shareSession(session)}
>
<BaseIcon path={mdiSendOutline} size={18} />
Share
</button>
)}
</div>
<p className='mt-3 leading-6 text-[#72798a]'>
{session.ai_summary}
</p>
<p className='mt-3 text-sm font-semibold text-[#35b7a5]'>
{session.key_topics}
</p>
</div>
))}
</div>
</Panel>
</div>
</div>
</div>
</SectionMain>
</>
);
};
SessionMemory.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
};
export default SessionMemory;