822 lines
36 KiB
TypeScript
822 lines
36 KiB
TypeScript
/**
|
||
* CommCoach Dossier View (Main View)
|
||
*
|
||
* Unified view per context: Coaching session, Tasks, Sessions history, Scores, Documents.
|
||
* Voice first, always with text fallback.
|
||
*/
|
||
|
||
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
||
import { useCommcoach } from '../../../hooks/useCommcoach';
|
||
import { useApiRequest } from '../../../hooks/useApi';
|
||
import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
||
import api from '../../../api';
|
||
import {
|
||
getDossierExportUrl, getSessionExportUrl,
|
||
getDocumentsApi, uploadDocumentApi, deleteDocumentApi,
|
||
getScoreHistoryApi, getPersonasApi,
|
||
type CoachingDocument, type CoachingPersona,
|
||
} from '../../../api/commcoachApi';
|
||
import AutoScroll from '../../../components/UiComponents/AutoScroll/AutoScroll';
|
||
import ReactMarkdown from 'react-markdown';
|
||
import remarkGfm from 'remark-gfm';
|
||
import styles from './CommcoachDossierView.module.css';
|
||
|
||
type TabKey = 'coaching' | 'tasks' | 'sessions' | 'scores' | 'documents';
|
||
|
||
export const CommcoachDossierView: React.FC = () => {
|
||
const coach = useCommcoach();
|
||
const { request } = useApiRequest();
|
||
const instanceId = useInstanceId();
|
||
|
||
const [activeTab, setActiveTab] = useState<TabKey>('coaching');
|
||
const [showNewContext, setShowNewContext] = useState(false);
|
||
const [newTitle, setNewTitle] = useState('');
|
||
const [newDescription, setNewDescription] = useState('');
|
||
const [newCategory, setNewCategory] = useState('custom');
|
||
|
||
const [newTaskTitle, setNewTaskTitle] = useState('');
|
||
const [documents, setDocuments] = useState<CoachingDocument[]>([]);
|
||
const [uploading, setUploading] = useState(false);
|
||
const [scoreHistory, setScoreHistory] = useState<Record<string, Array<{ score: number; createdAt?: string }>>>({});
|
||
const [personas, setPersonas] = useState<CoachingPersona[]>([]);
|
||
const [selectedPersonaId, setSelectedPersonaId] = useState<string | undefined>(undefined);
|
||
|
||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||
const streamRef = useRef<MediaStream | null>(null);
|
||
const speechRecognitionRef = useRef<SpeechRecognition | null>(null);
|
||
const transcriptPartsRef = useRef<string[]>([]);
|
||
const processedResultIndexRef = useRef(0);
|
||
const silenceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
const [isListening, setIsListening] = useState(false);
|
||
const [isUserSpeaking, setIsUserSpeaking] = useState(false);
|
||
const [liveTranscript, setLiveTranscript] = useState('');
|
||
const [isTtsPlaying, setIsTtsPlaying] = useState(false);
|
||
|
||
// #region agent log
|
||
const debugLogsRef = useRef<string[]>([]);
|
||
const [debugVisible, setDebugVisible] = useState(false);
|
||
const [debugSnapshot, setDebugSnapshot] = useState<string[]>([]);
|
||
const _dlog = useCallback((tag: string, info?: string) => {
|
||
const t = new Date();
|
||
const ts = `${t.getMinutes()}:${String(t.getSeconds()).padStart(2,'0')}.${String(t.getMilliseconds()).padStart(3,'0')}`;
|
||
const entry = `[${ts}] ${tag}${info ? ' ' + info : ''}`;
|
||
debugLogsRef.current.push(entry);
|
||
if (debugLogsRef.current.length > 80) debugLogsRef.current.shift();
|
||
}, []);
|
||
useEffect(() => { (window as any).__dlog = _dlog; return () => { delete (window as any).__dlog; }; }, [_dlog]);
|
||
// #endregion
|
||
|
||
// Auto-select first context
|
||
useEffect(() => {
|
||
if (!coach.selectedContextId && coach.contexts.length > 0) {
|
||
coach.selectContext(coach.contexts[0].id, { skipSessionResume: true });
|
||
}
|
||
}, [coach.contexts, coach.selectedContextId, coach.selectContext]);
|
||
|
||
// Load documents, scores, personas when context changes
|
||
useEffect(() => {
|
||
if (!instanceId || !coach.selectedContextId) return;
|
||
getDocumentsApi(request, instanceId, coach.selectedContextId)
|
||
.then(d => setDocuments(d))
|
||
.catch(() => {});
|
||
getScoreHistoryApi(request, instanceId, coach.selectedContextId)
|
||
.then(h => setScoreHistory(h))
|
||
.catch(() => {});
|
||
}, [instanceId, request, coach.selectedContextId]);
|
||
|
||
useEffect(() => {
|
||
coach.onDocumentCreatedRef.current = (doc) => {
|
||
setDocuments(prev => {
|
||
if (prev.some(d => d.id === doc.id)) return prev;
|
||
return [doc, ...prev];
|
||
});
|
||
};
|
||
return () => { coach.onDocumentCreatedRef.current = null; };
|
||
}, [coach.onDocumentCreatedRef]);
|
||
|
||
useEffect(() => {
|
||
if (!instanceId) return;
|
||
getPersonasApi(request, instanceId)
|
||
.then(p => setPersonas(p))
|
||
.catch(() => {});
|
||
}, [instanceId, request]);
|
||
|
||
// TTS playing state sync
|
||
useEffect(() => {
|
||
if (!coach.session) return;
|
||
const interval = setInterval(() => {
|
||
setIsTtsPlaying(coach.isTtsPlayingRef.current);
|
||
}, 200);
|
||
return () => clearInterval(interval);
|
||
}, [coach.session, coach.isTtsPlayingRef]);
|
||
|
||
// Speech Recognition (only when coaching tab active + session running + not muted)
|
||
useEffect(() => {
|
||
if (activeTab !== 'coaching' || !coach.session || coach.isMuted) {
|
||
if (speechRecognitionRef.current) {
|
||
try { speechRecognitionRef.current.stop(); } catch { /* ignore */ }
|
||
speechRecognitionRef.current = null;
|
||
}
|
||
if (streamRef.current) {
|
||
streamRef.current.getTracks().forEach(t => t.stop());
|
||
streamRef.current = null;
|
||
}
|
||
setIsListening(false);
|
||
setIsUserSpeaking(false);
|
||
return;
|
||
}
|
||
|
||
const SpeechRecognitionApi = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
|
||
if (!SpeechRecognitionApi) return;
|
||
|
||
let cancelled = false;
|
||
const MIN_WORDS_TO_INTERRUPT = 4;
|
||
|
||
const init = async () => {
|
||
try {
|
||
const stream = await navigator.mediaDevices.getUserMedia({
|
||
audio: { echoCancellation: true, noiseSuppression: true },
|
||
});
|
||
if (cancelled) { stream.getTracks().forEach(t => t.stop()); return; }
|
||
streamRef.current = stream;
|
||
setIsListening(true);
|
||
|
||
const recognition = new SpeechRecognitionApi();
|
||
recognition.continuous = true;
|
||
recognition.interimResults = true;
|
||
recognition.lang = 'de-DE';
|
||
|
||
const SILENCE_TIMEOUT_MS = 1500;
|
||
|
||
const _sendAndClearTranscript = () => {
|
||
const fullTranscript = transcriptPartsRef.current.join(' ').trim();
|
||
// #region agent log
|
||
const wc = fullTranscript.split(/\s+/).filter(Boolean).length;
|
||
_dlog('SEND', `words=${wc} send=${wc>=MIN_WORDS_TO_INTERRUPT} "${fullTranscript.substring(0,60)}"`);
|
||
// #endregion
|
||
if (fullTranscript) {
|
||
const wordCount = fullTranscript.split(/\s+/).filter(Boolean).length;
|
||
if (wordCount >= MIN_WORDS_TO_INTERRUPT) coach.sendMessage(fullTranscript);
|
||
}
|
||
transcriptPartsRef.current = [];
|
||
processedResultIndexRef.current = 0;
|
||
setLiveTranscript('');
|
||
setIsUserSpeaking(false);
|
||
};
|
||
|
||
const _resetSilenceTimer = () => {
|
||
if (silenceTimerRef.current) clearTimeout(silenceTimerRef.current);
|
||
silenceTimerRef.current = setTimeout(() => {
|
||
if (cancelled) return;
|
||
_sendAndClearTranscript();
|
||
}, SILENCE_TIMEOUT_MS);
|
||
};
|
||
|
||
recognition.onspeechstart = () => {
|
||
// #region agent log
|
||
_dlog('SPCH-START', `tts=${coach.isTtsPlayingRef.current}`);
|
||
// #endregion
|
||
if (cancelled || coach.isTtsPlayingRef.current) return;
|
||
setIsUserSpeaking(true);
|
||
transcriptPartsRef.current = [];
|
||
processedResultIndexRef.current = 0;
|
||
setLiveTranscript('');
|
||
_resetSilenceTimer();
|
||
};
|
||
|
||
recognition.onresult = (event: SpeechRecognitionEvent) => {
|
||
if (cancelled) return;
|
||
const interimParts: string[] = [];
|
||
for (let i = processedResultIndexRef.current; i < event.results.length; i++) {
|
||
const r = event.results[i];
|
||
if (r.isFinal) {
|
||
const text = r[0].transcript.trim();
|
||
if (text) transcriptPartsRef.current.push(text);
|
||
processedResultIndexRef.current = i + 1;
|
||
} else {
|
||
if (coach.isTtsPlayingRef.current) continue;
|
||
const text = r[0].transcript.trim();
|
||
if (text) interimParts.push(text);
|
||
}
|
||
}
|
||
const currentInterim = interimParts.join(' ');
|
||
const preview = [...transcriptPartsRef.current, currentInterim].join(' ').trim();
|
||
setLiveTranscript(preview);
|
||
if (preview) _resetSilenceTimer();
|
||
const finalizedWords = transcriptPartsRef.current.join(' ').split(/\s+/).filter(Boolean).length;
|
||
if (coach.isTtsPlayingRef.current && finalizedWords >= MIN_WORDS_TO_INTERRUPT) {
|
||
coach.stopTts();
|
||
}
|
||
};
|
||
|
||
recognition.onspeechend = () => {
|
||
// #region agent log
|
||
_dlog('SPCH-END', `tts=${coach.isTtsPlayingRef.current} parts=${transcriptPartsRef.current.length}`);
|
||
// #endregion
|
||
if (cancelled) return;
|
||
if (silenceTimerRef.current) clearTimeout(silenceTimerRef.current);
|
||
if (coach.isTtsPlayingRef.current) {
|
||
transcriptPartsRef.current = [];
|
||
processedResultIndexRef.current = 0;
|
||
setLiveTranscript('');
|
||
setIsUserSpeaking(false);
|
||
return;
|
||
}
|
||
_sendAndClearTranscript();
|
||
};
|
||
|
||
recognition.onend = () => {
|
||
// #region agent log
|
||
_dlog('REC-END', `cancelled=${cancelled} sameRef=${speechRecognitionRef.current===recognition} tts=${coach.isTtsPlayingRef.current}`);
|
||
// #endregion
|
||
if (cancelled) return;
|
||
if (coach.isTtsPlayingRef.current) return;
|
||
if (speechRecognitionRef.current === recognition) {
|
||
try { recognition.start(); } catch { speechRecognitionRef.current = null; }
|
||
}
|
||
};
|
||
|
||
recognition.onerror = (event: any) => {
|
||
// #region agent log
|
||
_dlog('REC-ERR', event.error);
|
||
// #endregion
|
||
if (event.error === 'no-speech' || event.error === 'aborted') return;
|
||
console.warn('SpeechRecognition error:', event.error);
|
||
};
|
||
|
||
speechRecognitionRef.current = recognition;
|
||
recognition.start();
|
||
} catch (err) {
|
||
console.warn('Mic access failed:', err);
|
||
}
|
||
};
|
||
|
||
init();
|
||
return () => {
|
||
// #region agent log
|
||
_dlog('CLEANUP', `tab=${activeTab} sess=${coach.session?.id} muted=${coach.isMuted}`);
|
||
// #endregion
|
||
cancelled = true;
|
||
coach.stopTts();
|
||
if (silenceTimerRef.current) clearTimeout(silenceTimerRef.current);
|
||
if (speechRecognitionRef.current) {
|
||
try { speechRecognitionRef.current.stop(); } catch { /* ignore */ }
|
||
speechRecognitionRef.current = null;
|
||
}
|
||
if (streamRef.current) {
|
||
streamRef.current.getTracks().forEach(t => t.stop());
|
||
streamRef.current = null;
|
||
}
|
||
};
|
||
}, [activeTab, coach.session?.id, coach.isMuted]);
|
||
|
||
// On mobile, SpeechRecognition and Audio output conflict for the audio session.
|
||
// Pause recognition while TTS plays, resume when it stops.
|
||
useEffect(() => {
|
||
if (!speechRecognitionRef.current) return;
|
||
if (isTtsPlaying) {
|
||
// #region agent log
|
||
_dlog('REC-SUSPEND', 'tts started, stopping recognition');
|
||
// #endregion
|
||
try { speechRecognitionRef.current.stop(); } catch { /* ignore */ }
|
||
} else {
|
||
// #region agent log
|
||
_dlog('REC-RESUME', 'tts ended, restarting recognition');
|
||
// #endregion
|
||
try { speechRecognitionRef.current.start(); } catch { /* ignore */ }
|
||
}
|
||
}, [isTtsPlaying, _dlog]);
|
||
|
||
// Reset mute when session ends
|
||
useEffect(() => {
|
||
if (!coach.session) coach.setMuted(false);
|
||
}, [coach.session]);
|
||
|
||
// Focus input on session start
|
||
useEffect(() => {
|
||
if (coach.session && inputRef.current) inputRef.current.focus();
|
||
}, [coach.session]);
|
||
|
||
const handleSend = useCallback(async () => {
|
||
if (!coach.inputValue.trim() || coach.isStreaming) return;
|
||
await coach.sendMessage(coach.inputValue);
|
||
}, [coach]);
|
||
|
||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); }
|
||
}, [handleSend]);
|
||
|
||
const handleCreateContext = useCallback(async () => {
|
||
if (!newTitle.trim()) return;
|
||
await coach.createContext(newTitle, newDescription || undefined, newCategory);
|
||
setNewTitle('');
|
||
setNewDescription('');
|
||
setNewCategory('custom');
|
||
setShowNewContext(false);
|
||
}, [newTitle, newDescription, newCategory, coach]);
|
||
|
||
const handleSelectContext = useCallback((contextId: string) => {
|
||
coach.selectContext(contextId, { skipSessionResume: true });
|
||
}, [coach]);
|
||
|
||
const handleUpload = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const file = e.target.files?.[0];
|
||
if (!file || !instanceId || !coach.selectedContextId) return;
|
||
setUploading(true);
|
||
try {
|
||
const doc = await uploadDocumentApi(instanceId, coach.selectedContextId, file);
|
||
setDocuments(prev => [doc, ...prev]);
|
||
} catch { /* upload failed */ } finally {
|
||
setUploading(false);
|
||
e.target.value = '';
|
||
}
|
||
}, [instanceId, coach.selectedContextId]);
|
||
|
||
const handleDeleteDocument = useCallback(async (docId: string) => {
|
||
if (!instanceId) return;
|
||
try {
|
||
await deleteDocumentApi(request, instanceId, docId);
|
||
setDocuments(prev => prev.filter(d => d.id !== docId));
|
||
} catch { /* delete failed */ }
|
||
}, [instanceId, request]);
|
||
|
||
const handleDownloadDocument = useCallback(async (doc: CoachingDocument) => {
|
||
if (!doc.fileRef) return;
|
||
try {
|
||
const response = await api.get(`/api/files/${doc.fileRef}/download`, {
|
||
responseType: 'blob',
|
||
});
|
||
const url = window.URL.createObjectURL(response.data);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = doc.fileName;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
window.URL.revokeObjectURL(url);
|
||
} catch (err) {
|
||
console.error('Download failed:', err);
|
||
}
|
||
}, []);
|
||
|
||
const handleAddTask = useCallback(async () => {
|
||
if (!newTaskTitle.trim()) return;
|
||
await coach.addTask(newTaskTitle);
|
||
setNewTaskTitle('');
|
||
}, [newTaskTitle, coach]);
|
||
|
||
if (coach.loadingContexts) {
|
||
return <div className={styles.empty}><p>Lade...</p></div>;
|
||
}
|
||
|
||
return (
|
||
<div className={styles.dossier}>
|
||
{/* Context Selector */}
|
||
<div className={styles.contextSelector}>
|
||
{coach.contexts.map(ctx => (
|
||
<button
|
||
key={ctx.id}
|
||
className={`${styles.contextChip} ${ctx.id === coach.selectedContextId ? styles.contextChipActive : ''}`}
|
||
onClick={() => handleSelectContext(ctx.id)}
|
||
>
|
||
<span className={styles.contextChipIcon}>{_categoryIcon(ctx.category)}</span>
|
||
{ctx.title}
|
||
</button>
|
||
))}
|
||
<button
|
||
className={styles.contextChipNew}
|
||
onClick={() => setShowNewContext(!showNewContext)}
|
||
title="Neues Thema"
|
||
>
|
||
+
|
||
</button>
|
||
</div>
|
||
|
||
{/* New Context Form */}
|
||
{showNewContext && (
|
||
<div className={styles.newContextForm}>
|
||
<input
|
||
className={styles.newContextInput}
|
||
placeholder="Thema / Titel..."
|
||
value={newTitle}
|
||
onChange={e => setNewTitle(e.target.value)}
|
||
onKeyDown={e => e.key === 'Enter' && handleCreateContext()}
|
||
autoFocus
|
||
/>
|
||
<input
|
||
className={styles.newContextInput}
|
||
placeholder="Beschreibung (optional)"
|
||
value={newDescription}
|
||
onChange={e => setNewDescription(e.target.value)}
|
||
/>
|
||
<select className={styles.newContextInput} value={newCategory} onChange={e => setNewCategory(e.target.value)}>
|
||
<option value="custom">Individuell</option>
|
||
<option value="leadership">Führung</option>
|
||
<option value="conflict">Konflikt</option>
|
||
<option value="negotiation">Verhandlung</option>
|
||
<option value="presentation">Präsentation</option>
|
||
<option value="feedback">Feedback</option>
|
||
<option value="delegation">Delegation</option>
|
||
<option value="changeManagement">Change Management</option>
|
||
</select>
|
||
<div className={styles.newContextActions}>
|
||
<button className={styles.btnPrimary} onClick={handleCreateContext} disabled={!newTitle.trim() || !!coach.actionLoading}>
|
||
{coach.actionLoading === 'creating' ? 'Wird erstellt...' : 'Erstellen'}
|
||
</button>
|
||
<button className={styles.btnSecondary} onClick={() => setShowNewContext(false)}>Abbrechen</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* No context selected */}
|
||
{!coach.selectedContextId && !showNewContext && coach.contexts.length === 0 && (
|
||
<div className={styles.empty}>
|
||
<h3>Willkommen beim Kommunikations-Coach</h3>
|
||
<p>Erstelle ein Thema, um zu beginnen.</p>
|
||
<button className={styles.btnPrimary} onClick={() => setShowNewContext(true)}>Neues Thema erstellen</button>
|
||
</div>
|
||
)}
|
||
|
||
{coach.selectedContextId && (<>
|
||
{/* Context Header */}
|
||
<div className={styles.header}>
|
||
<div>
|
||
<h2 className={styles.title}>{coach.selectedContext?.title}</h2>
|
||
{coach.selectedContext?.description && (
|
||
<p className={styles.description}>{coach.selectedContext.description}</p>
|
||
)}
|
||
</div>
|
||
<div className={styles.headerActions}>
|
||
{instanceId && (
|
||
<>
|
||
<a className={styles.btnExport} href={getDossierExportUrl(instanceId, coach.selectedContextId, 'md')} target="_blank" rel="noopener noreferrer">Export MD</a>
|
||
<a className={styles.btnExport} href={getDossierExportUrl(instanceId, coach.selectedContextId, 'pdf')} target="_blank" rel="noopener noreferrer">Export PDF</a>
|
||
</>
|
||
)}
|
||
<button className={styles.btnArchive} onClick={() => coach.archiveContext(coach.selectedContextId!)} disabled={!!coach.actionLoading}>
|
||
{coach.actionLoading === 'archiving' ? 'Wird archiviert...' : 'Archivieren'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Tab Navigation */}
|
||
<div className={styles.tabs}>
|
||
{(['coaching', 'tasks', 'sessions', 'scores', 'documents'] as TabKey[]).map(tab => (
|
||
<button
|
||
key={tab}
|
||
className={`${styles.tab} ${activeTab === tab ? styles.tabActive : ''}`}
|
||
onClick={() => setActiveTab(tab)}
|
||
>
|
||
{_tabLabel(tab, coach, documents)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* ============================================================ */}
|
||
{/* COACHING TAB */}
|
||
{/* ============================================================ */}
|
||
{activeTab === 'coaching' && (
|
||
<div className={styles.coachingTab}>
|
||
{!coach.session ? (
|
||
<div className={styles.sessionStart}>
|
||
<p>Starte eine neue Coaching-Session zu diesem Thema.</p>
|
||
{personas.length > 0 && (
|
||
<div className={styles.personaSelector}>
|
||
<label className={styles.personaLabel}>Gesprächspartner wählen:</label>
|
||
<div className={styles.personaGrid}>
|
||
{personas.map(p => (
|
||
<button
|
||
key={p.id}
|
||
className={`${styles.personaChip} ${selectedPersonaId === p.id ? styles.personaChipActive : ''}`}
|
||
onClick={() => setSelectedPersonaId(selectedPersonaId === p.id ? undefined : p.id)}
|
||
title={p.description}
|
||
>
|
||
<span className={styles.personaGender}>{p.gender === 'f' ? '\u2640' : p.gender === 'm' ? '\u2642' : '\u25CB'}</span>
|
||
<span>{p.label}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
<button className={styles.btnPrimary} onClick={() => coach.startSession(selectedPersonaId)} disabled={!!coach.actionLoading}>
|
||
{coach.actionLoading === 'starting'
|
||
? 'Wird gestartet...'
|
||
: selectedPersonaId && personas.find(p => p.id === selectedPersonaId)
|
||
? `Session starten mit ${personas.find(p => p.id === selectedPersonaId)!.label}`
|
||
: 'Session starten'}
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<>
|
||
{/* Session Header */}
|
||
<div className={styles.sessionHeader}>
|
||
<span className={styles.sessionLabel}>Session aktiv</span>
|
||
<div className={styles.sessionActions}>
|
||
{isTtsPlaying && (
|
||
<button className={styles.btnSmallDanger} onClick={coach.stopTts}>Stop</button>
|
||
)}
|
||
{coach.wasInterrupted && !isTtsPlaying && (
|
||
<button className={styles.btnSmall} onClick={coach.resumeTts}>Weitersprechen</button>
|
||
)}
|
||
<button
|
||
className={`${styles.btnSmall} ${coach.isMuted ? styles.mutedActive : ''}`}
|
||
onClick={() => coach.setMuted(!coach.isMuted)}
|
||
title={coach.isMuted ? 'Stummschaltung aufheben' : 'Stummschalten'}
|
||
>
|
||
{coach.isMuted ? '\u{1F507} Stumm' : '\u{1F3A4} Ton an'}
|
||
</button>
|
||
<button className={styles.btnSmall} onClick={coach.completeSession} disabled={!!coach.actionLoading}>
|
||
{coach.actionLoading === 'completing' ? 'Wird abgeschlossen...' : 'Abschliessen'}
|
||
</button>
|
||
<button className={styles.btnSmallDanger} onClick={coach.cancelSession} disabled={!!coach.actionLoading}>
|
||
{coach.actionLoading === 'cancelling' ? 'Wird abgebrochen...' : 'Abbrechen'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Messages */}
|
||
<AutoScroll scrollDependency={coach.messages.length + (coach.isStreaming ? 1 : 0) + liveTranscript.length}>
|
||
<div className={styles.messages}>
|
||
{coach.messages.map(msg => (
|
||
<div key={msg.id} className={`${styles.message} ${msg.role === 'user' ? styles.messageUser : styles.messageAssistant}`}>
|
||
<div className={styles.messageBubble}>
|
||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{msg.content}</ReactMarkdown>
|
||
</div>
|
||
<div className={styles.messageTime}>
|
||
{msg.createdAt ? new Date(msg.createdAt).toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' }) : ''}
|
||
</div>
|
||
</div>
|
||
))}
|
||
{liveTranscript && (
|
||
<div className={`${styles.message} ${styles.messageUser}`}>
|
||
<div className={`${styles.messageBubble} ${styles.messageLive}`}>{liveTranscript}</div>
|
||
</div>
|
||
)}
|
||
{coach.isStreaming && (
|
||
<div className={`${styles.message} ${styles.messageAssistant}`}>
|
||
<div className={styles.messageBubble}>
|
||
{coach.streamingMessage ? (
|
||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{coach.streamingMessage}</ReactMarkdown>
|
||
) : (
|
||
<div className={styles.typing}>{coach.streamingStatus || 'Coach denkt nach'}<span className={styles.typingDots}>...</span></div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</AutoScroll>
|
||
|
||
{/* Input Area */}
|
||
<div className={styles.inputArea}>
|
||
<div className={styles.voiceStatus}>
|
||
<span className={`${styles.voiceIndicator} ${isListening && !coach.isMuted ? styles.voiceActive : ''}`}>
|
||
{coach.isMuted
|
||
? 'Stumm – Mikrofon aus'
|
||
: coach.isStreaming
|
||
? (coach.streamingStatus || 'Coach antwortet...')
|
||
: isUserSpeaking
|
||
? 'Spricht...'
|
||
: isListening
|
||
? 'Mikrofon an – bitte sprechen'
|
||
: 'Mikrofon wird gestartet...'}
|
||
</span>
|
||
</div>
|
||
<div className={styles.textInputRow}>
|
||
<textarea
|
||
ref={inputRef}
|
||
className={styles.textInput}
|
||
placeholder="Nachricht eingeben..."
|
||
value={coach.inputValue}
|
||
onChange={e => coach.setInputValue(e.target.value)}
|
||
onKeyDown={handleKeyDown}
|
||
rows={1}
|
||
disabled={coach.isStreaming}
|
||
/>
|
||
<button className={styles.sendBtn} onClick={handleSend} disabled={!coach.inputValue.trim() || coach.isStreaming}>
|
||
Senden
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
{coach.error && <div className={styles.errorBanner}>{coach.error}</div>}
|
||
</div>
|
||
)}
|
||
|
||
{/* ============================================================ */}
|
||
{/* TASKS TAB */}
|
||
{/* ============================================================ */}
|
||
{activeTab === 'tasks' && (
|
||
<div className={styles.tabContent}>
|
||
<div className={styles.addTaskRow}>
|
||
<input
|
||
className={styles.addTaskInput}
|
||
placeholder="Neue Aufgabe..."
|
||
value={newTaskTitle}
|
||
onChange={e => setNewTaskTitle(e.target.value)}
|
||
onKeyDown={e => e.key === 'Enter' && handleAddTask()}
|
||
/>
|
||
<button className={styles.addTaskBtn} onClick={handleAddTask} disabled={!newTaskTitle.trim() || !!coach.actionLoading}>
|
||
{coach.actionLoading === 'addingTask' ? 'Wird hinzugefügt...' : 'Hinzufügen'}
|
||
</button>
|
||
</div>
|
||
{coach.tasks.length === 0 ? (
|
||
<div className={styles.emptyTab}>Noch keine Aufgaben. Der Coach schlägt während Sessions Aufgaben vor.</div>
|
||
) : (
|
||
<div className={styles.taskList}>
|
||
{coach.tasks.map(task => (
|
||
<div key={task.id} className={`${styles.taskItem} ${task.status === 'done' ? styles.taskDone : ''}`}>
|
||
<button className={styles.taskCheck} onClick={() => coach.toggleTaskStatus(task.id, task.status)}>
|
||
{task.status === 'done' ? '\u2713' : '\u25CB'}
|
||
</button>
|
||
<div className={styles.taskContent}>
|
||
<div className={styles.taskTitle}>{task.title}</div>
|
||
{task.description && <div className={styles.taskDesc}>{task.description}</div>}
|
||
</div>
|
||
<div className={styles.taskMeta}>
|
||
<span className={`${styles.taskPriority} ${styles[`priority_${task.priority}`]}`}>{task.priority}</span>
|
||
</div>
|
||
<button className={styles.taskDelete} onClick={() => coach.removeTask(task.id)}>x</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* ============================================================ */}
|
||
{/* SESSIONS TAB */}
|
||
{/* ============================================================ */}
|
||
{activeTab === 'sessions' && (
|
||
<div className={styles.tabContent}>
|
||
{coach.sessions.length === 0 ? (
|
||
<div className={styles.emptyTab}>Noch keine abgeschlossenen Sessions.</div>
|
||
) : (
|
||
<div className={styles.sessionTimeline}>
|
||
{coach.sessions.map(s => (
|
||
<div key={s.id} className={styles.sessionItem}>
|
||
<div className={styles.sessionItemHeader}>
|
||
<span className={`${styles.sessionStatus} ${styles[`status_${s.status}`]}`}>
|
||
{s.status === 'completed' ? 'Abgeschlossen' : s.status === 'active' ? 'Aktiv' : 'Abgebrochen'}
|
||
</span>
|
||
<span className={styles.sessionDate}>{s.startedAt ? new Date(s.startedAt).toLocaleDateString('de-CH') : ''}</span>
|
||
{s.competenceScore != null && <span className={styles.sessionScore}>Score: {Math.round(s.competenceScore)}</span>}
|
||
</div>
|
||
{s.summary && (
|
||
<div className={styles.sessionSummary}><ReactMarkdown>{s.summary}</ReactMarkdown></div>
|
||
)}
|
||
<div className={styles.sessionMeta}>
|
||
{s.messageCount} Nachrichten | {Math.round(s.durationSeconds / 60)} Min.
|
||
{s.personaId && <span> | Persona</span>}
|
||
{instanceId && s.status === 'completed' && (
|
||
<a className={styles.sessionExport} href={getSessionExportUrl(instanceId, s.id, 'md')} target="_blank" rel="noopener noreferrer" onClick={e => e.stopPropagation()}>Export</a>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* ============================================================ */}
|
||
{/* SCORES TAB */}
|
||
{/* ============================================================ */}
|
||
{activeTab === 'scores' && (
|
||
<div className={styles.tabContent}>
|
||
{coach.scores.length === 0 ? (
|
||
<div className={styles.emptyTab}>Noch keine Bewertungen. Schliesse eine Session ab, um Scores zu erhalten.</div>
|
||
) : (
|
||
<div className={styles.scoreList}>
|
||
{_groupScoresByDimension(coach.scores).map(group => (
|
||
<div key={group.dimension} className={styles.scoreGroup}>
|
||
<div className={styles.scoreDimension}>
|
||
<span className={styles.scoreDimensionLabel}>{_dimensionLabel(group.dimension)}</span>
|
||
<span className={styles.scoreLatest}>{Math.round(group.latest.score)}/100</span>
|
||
<span className={`${styles.scoreTrend} ${styles[`trend_${group.latest.trend}`]}`}>
|
||
{group.latest.trend === 'improving' ? 'steigend' : group.latest.trend === 'declining' ? 'sinkend' : 'stabil'}
|
||
</span>
|
||
</div>
|
||
<div className={styles.scoreBar}><div className={styles.scoreBarFill} style={{ width: `${group.latest.score}%` }} /></div>
|
||
{group.latest.evidence && <div className={styles.scoreEvidence}>{group.latest.evidence}</div>}
|
||
{scoreHistory[group.dimension] && scoreHistory[group.dimension].length > 1 && (
|
||
<div className={styles.scoreHistory}>
|
||
<div className={styles.scoreHistoryLabel}>Verlauf:</div>
|
||
<div className={styles.scoreHistoryPoints}>
|
||
{scoreHistory[group.dimension].map((entry, i) => (
|
||
<span key={i} className={styles.scoreHistoryPoint} title={entry.createdAt || ''}>{Math.round(entry.score)}</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* ============================================================ */}
|
||
{/* DOCUMENTS TAB */}
|
||
{/* ============================================================ */}
|
||
{activeTab === 'documents' && (
|
||
<div className={styles.tabContent}>
|
||
<div className={styles.addTaskRow}>
|
||
<label className={styles.uploadLabel}>
|
||
{uploading ? 'Wird hochgeladen...' : 'Dokument hochladen'}
|
||
<input type="file" accept=".txt,.md,.pdf,.doc,.docx" onChange={handleUpload} disabled={uploading} style={{ display: 'none' }} />
|
||
</label>
|
||
</div>
|
||
{documents.length === 0 ? (
|
||
<div className={styles.emptyTab}>Keine Dokumente. Lade Dateien hoch oder bitte den Coach, eines zu erstellen.</div>
|
||
) : (
|
||
<div className={styles.documentList}>
|
||
{documents.map(doc => (
|
||
<div key={doc.id} className={styles.documentItem}>
|
||
<div className={styles.documentInfo}>
|
||
<div className={styles.documentName}>{doc.fileName}</div>
|
||
<div className={styles.documentMeta}>
|
||
{_formatFileSize(doc.fileSize)} | {doc.createdAt ? new Date(doc.createdAt).toLocaleDateString('de-CH') : ''}
|
||
</div>
|
||
{doc.summary && <div className={styles.documentSummary}>{doc.summary}</div>}
|
||
</div>
|
||
<div className={styles.documentActions}>
|
||
<button className={styles.btnExport} onClick={() => handleDownloadDocument(doc)} disabled={!doc.fileRef}>Download</button>
|
||
<button className={styles.taskDelete} onClick={() => handleDeleteDocument(doc.id)}>x</button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</>)}
|
||
{/* #region agent log */}
|
||
<div style={{position:'fixed',bottom:0,right:0,zIndex:9999}}>
|
||
<button
|
||
onClick={() => { setDebugSnapshot([...debugLogsRef.current]); setDebugVisible(v => !v); }}
|
||
style={{background:'#333',color:'#0f0',border:'none',padding:'4px 8px',fontSize:'10px',borderRadius:'4px 0 0 0'}}
|
||
>DBG ({debugLogsRef.current.length})</button>
|
||
{debugVisible && (
|
||
<div style={{background:'rgba(0,0,0,0.9)',color:'#0f0',fontSize:'9px',maxHeight:'40vh',overflow:'auto',padding:'4px',fontFamily:'monospace',whiteSpace:'pre-wrap',width:'100vw'}}>
|
||
{debugSnapshot.map((l,i) => <div key={i}>{l}</div>)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
{/* #endregion */}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
function _categoryIcon(category: string): string {
|
||
const icons: Record<string, string> = {
|
||
leadership: 'L', conflict: 'K', negotiation: 'V',
|
||
presentation: 'P', feedback: 'F', delegation: 'D',
|
||
changeManagement: 'C', custom: '*',
|
||
};
|
||
return icons[category] || '*';
|
||
}
|
||
|
||
function _tabLabel(tab: TabKey, coach: any, documents: CoachingDocument[]): string {
|
||
switch (tab) {
|
||
case 'coaching': return coach.session ? 'Coaching (aktiv)' : 'Coaching';
|
||
case 'tasks': return `Aufgaben (${coach.tasks.length})`;
|
||
case 'sessions': return `Sessions (${coach.sessions.length})`;
|
||
case 'scores': return `Bewertungen (${coach.scores.length})`;
|
||
case 'documents': return `Dokumente (${documents.length})`;
|
||
}
|
||
}
|
||
|
||
interface ScoreGroup {
|
||
dimension: string;
|
||
latest: { score: number; trend: string; evidence?: string; createdAt?: string };
|
||
history: Array<{ score: number; createdAt?: string }>;
|
||
}
|
||
|
||
function _groupScoresByDimension(scores: any[]): ScoreGroup[] {
|
||
const groups: Record<string, ScoreGroup> = {};
|
||
for (const s of scores) {
|
||
const dim = s.dimension;
|
||
if (!groups[dim]) groups[dim] = { dimension: dim, latest: s, history: [] };
|
||
groups[dim].history.push({ score: s.score, createdAt: s.createdAt });
|
||
if (s.createdAt > (groups[dim].latest.createdAt || '')) groups[dim].latest = s;
|
||
}
|
||
return Object.values(groups);
|
||
}
|
||
|
||
function _formatFileSize(bytes: number): string {
|
||
if (bytes < 1024) return `${bytes} B`;
|
||
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||
}
|
||
|
||
function _dimensionLabel(dim: string): string {
|
||
const labels: Record<string, string> = {
|
||
empathy: 'Einfühlungsvermögen', clarity: 'Klarheit',
|
||
assertiveness: 'Durchsetzung', listening: 'Zuhören',
|
||
selfReflection: 'Selbstreflexion',
|
||
};
|
||
return labels[dim] || dim;
|
||
}
|
||
|
||
export default CommcoachDossierView;
|