diff --git a/src/api/commcoachApi.ts b/src/api/commcoachApi.ts index 2462668..9a29be1 100644 --- a/src/api/commcoachApi.ts +++ b/src/api/commcoachApi.ts @@ -58,6 +58,7 @@ export interface CoachingDocument { fileSize: number; extractedText?: string; summary?: string; + fileRef?: string; createdAt?: string; } @@ -550,6 +551,11 @@ export async function uploadDocumentApi(instanceId: string, contextId: string, f const headers: Record = {}; const authToken = localStorage.getItem('authToken'); if (authToken) headers['Authorization'] = `Bearer ${authToken}`; + const pathMatch = window.location.pathname.match(/^\/mandates\/([^/]+)\/([^/]+)\/([^/]+)/); + if (pathMatch) { + headers['X-Mandate-Id'] = pathMatch[1]; + headers['X-Instance-Id'] = pathMatch[3]; + } if (!getCSRFToken()) generateAndStoreCSRFToken(); addCSRFTokenToHeaders(headers); diff --git a/src/hooks/useCommcoach.ts b/src/hooks/useCommcoach.ts index a7e5eab..df9ab1d 100644 --- a/src/hooks/useCommcoach.ts +++ b/src/hooks/useCommcoach.ts @@ -36,7 +36,7 @@ export interface CommcoachHookReturn { inputValue: string; setInputValue: (v: string) => void; - selectContext: (contextId: string) => Promise; + selectContext: (contextId: string, options?: { skipSessionResume?: boolean }) => Promise; createContext: (title: string, description?: string, category?: string, goals?: string[]) => Promise; archiveContext: (contextId: string) => Promise; @@ -59,6 +59,8 @@ export interface CommcoachHookReturn { addTask: (title: string, description?: string) => Promise; removeTask: (taskId: string) => Promise; + onDocumentCreatedRef: MutableRefObject<((doc: any) => void) | null>; + refreshContexts: () => Promise; } @@ -92,6 +94,7 @@ export function useCommcoach(): CommcoachHookReturn { const currentAudioRef = useRef(null); const isTtsPlayingRef = useRef(false); const lastTtsAudioRef = useRef(null); + const onDocumentCreatedRef = useRef<((doc: any) => void) | null>(null); useEffect(() => { isMountedRef.current = true; return () => { isMountedRef.current = false; }; }, []); @@ -134,7 +137,6 @@ export function useCommcoach(): CommcoachHookReturn { const stopTts = useCallback(() => { if (currentAudioRef.current) { currentAudioRef.current.pause(); - currentAudioRef.current = null; } if (isTtsPlayingRef.current) { setWasInterrupted(true); @@ -143,12 +145,14 @@ export function useCommcoach(): CommcoachHookReturn { }, []); const resumeTts = useCallback(() => { - if (lastTtsAudioRef.current) { - _playTtsAudio(lastTtsAudioRef.current); + if (currentAudioRef.current && currentAudioRef.current.paused) { + isTtsPlayingRef.current = true; + setWasInterrupted(false); + currentAudioRef.current.play().catch(() => { isTtsPlayingRef.current = false; }); } - }, [_playTtsAudio]); + }, []); - const selectContext = useCallback(async (contextId: string) => { + const selectContext = useCallback(async (contextId: string, options?: { skipSessionResume?: boolean }) => { if (!instanceId) return; setSelectedContextId(contextId); setError(null); @@ -160,6 +164,12 @@ export function useCommcoach(): CommcoachHookReturn { setScores(detail.scores || []); setSessions(detail.sessions || []); + if (options?.skipSessionResume) { + setSession(null); + setMessages([]); + return; + } + const activeSession = detail.sessions?.find((s: CoachingSession) => s.status === 'active'); if (activeSession) { await _unlockAudioForTts(); @@ -285,6 +295,10 @@ export function useCommcoach(): CommcoachHookReturn { _playTtsAudio(eventData.audio); } else if (eventType === 'status' && eventData) { setStreamingStatus(eventData.label || null); + } else if (eventType === 'taskCreated' && eventData) { + setTasks(prev => [eventData, ...prev]); + } else if (eventType === 'documentCreated' && eventData) { + onDocumentCreatedRef.current?.(eventData); } else if (eventType === 'error' && eventData) { setError(eventData.message || 'Stream-Fehler'); } @@ -316,7 +330,9 @@ export function useCommcoach(): CommcoachHookReturn { }, [instanceId, selectedContextId, _playTtsAudio]); const sendMessage = useCallback(async (content: string) => { - if (!content.trim() || isStreaming || !instanceId || !session) return; + const normalizedContent = content.trim(); + if (!normalizedContent || !instanceId || !session) return; + stopTts(); await _unlockAudioForTts(); setError(null); setIsStreaming(true); @@ -327,7 +343,7 @@ export function useCommcoach(): CommcoachHookReturn { sessionId: session.id, contextId: session.contextId, role: 'user', - content: content.trim(), + content: normalizedContent, contentType: 'text', createdAt: new Date().toISOString(), }; @@ -338,7 +354,7 @@ export function useCommcoach(): CommcoachHookReturn { await sendMessageStreamApi( instanceId, session.id, - content, + normalizedContent, (event: SSEEvent) => { if (!isMountedRef.current) return; const eventType = event.type; @@ -371,6 +387,8 @@ export function useCommcoach(): CommcoachHookReturn { setStreamingStatus(eventData.label || null); } else if (eventType === 'taskCreated' && eventData) { setTasks(prev => [eventData, ...prev]); + } else if (eventType === 'documentCreated' && eventData) { + onDocumentCreatedRef.current?.(eventData); } else if (eventType === 'scoreUpdate') { // Will refresh on complete } else if (eventType === 'error' && eventData) { @@ -398,7 +416,7 @@ export function useCommcoach(): CommcoachHookReturn { setIsStreaming(false); } } - }, [isStreaming, instanceId, session, _playTtsAudio]); + }, [instanceId, session, _playTtsAudio, stopTts]); const sendAudio = useCallback(async (audioBlob: Blob) => { if (!instanceId || !session) return; @@ -437,6 +455,10 @@ export function useCommcoach(): CommcoachHookReturn { } else if (eventType === 'ttsAudio' && eventData?.audio) { setError(null); _playTtsAudio(eventData.audio); + } else if (eventType === 'taskCreated' && eventData) { + setTasks(prev => [eventData, ...prev]); + } else if (eventType === 'documentCreated' && eventData) { + onDocumentCreatedRef.current?.(eventData); } else if (eventType === 'error' && eventData) { setError(eventData.message || 'Audio-Fehler'); } @@ -546,6 +568,7 @@ export function useCommcoach(): CommcoachHookReturn { isMuted, setMuted: setIsMuted, stopTts, resumeTts, wasInterrupted, isTtsPlayingRef, actionLoading, toggleTaskStatus, addTask, removeTask, + onDocumentCreatedRef, refreshContexts, }; } diff --git a/src/pages/FeatureView.tsx b/src/pages/FeatureView.tsx index c3251dd..b11384f 100644 --- a/src/pages/FeatureView.tsx +++ b/src/pages/FeatureView.tsx @@ -45,7 +45,7 @@ import { TeamsbotSettingsView } from './views/teamsbot/TeamsbotSettingsView'; import { NeutralizationView } from './views/neutralization'; // CommCoach Views -import { CommcoachDashboardView, CommcoachCoachingView, CommcoachDossierView, CommcoachSettingsView } from './views/commcoach'; +import { CommcoachDashboardView, CommcoachDossierView, CommcoachSettingsView } from './views/commcoach'; import styles from './FeatureView.module.css'; @@ -148,7 +148,7 @@ const VIEW_COMPONENTS: Record> = { }, commcoach: { dashboard: CommcoachDashboardView, - coaching: CommcoachCoachingView, + coaching: CommcoachDossierView, dossier: CommcoachDossierView, settings: CommcoachSettingsView, }, diff --git a/src/pages/views/commcoach/CommcoachCoachingView.tsx b/src/pages/views/commcoach/CommcoachCoachingView.tsx index 9d0db6f..ff07215 100644 --- a/src/pages/views/commcoach/CommcoachCoachingView.tsx +++ b/src/pages/views/commcoach/CommcoachCoachingView.tsx @@ -148,14 +148,23 @@ export const CommcoachCoachingView: React.FC = () => { const SILENCE_TIMEOUT_MS = 5000; + const _sendAndClearTranscript = () => { + const fullTranscript = transcriptPartsRef.current.join(' ').trim(); + 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; - setIsUserSpeaking(false); - transcriptPartsRef.current = []; - processedResultIndexRef.current = 0; - setLiveTranscript(''); + _sendAndClearTranscript(); }, SILENCE_TIMEOUT_MS); }; @@ -170,7 +179,7 @@ export const CommcoachCoachingView: React.FC = () => { recognition.onresult = (event: SpeechRecognitionEvent) => { if (cancelled || coach.isTtsPlayingRef.current) return; - let currentInterim = ''; + const interimParts: string[] = []; for (let i = processedResultIndexRef.current; i < event.results.length; i++) { const r = event.results[i]; if (r.isFinal) { @@ -178,9 +187,11 @@ export const CommcoachCoachingView: React.FC = () => { if (text) transcriptPartsRef.current.push(text); processedResultIndexRef.current = i + 1; } else { - currentInterim = r[0].transcript.trim(); + 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(); @@ -198,15 +209,7 @@ export const CommcoachCoachingView: React.FC = () => { setIsUserSpeaking(false); return; } - const fullTranscript = transcriptPartsRef.current.join(' ').trim(); - 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); + _sendAndClearTranscript(); }; recognition.onend = () => { @@ -238,6 +241,7 @@ export const CommcoachCoachingView: React.FC = () => { init(); return () => { cancelled = true; + coach.stopTts(); if (silenceTimerRef.current) clearTimeout(silenceTimerRef.current); if (speechRecognitionRef.current) { try { diff --git a/src/pages/views/commcoach/CommcoachDossierView.module.css b/src/pages/views/commcoach/CommcoachDossierView.module.css index 8372ef0..d078335 100644 --- a/src/pages/views/commcoach/CommcoachDossierView.module.css +++ b/src/pages/views/commcoach/CommcoachDossierView.module.css @@ -1,16 +1,25 @@ .dossier { - padding: 1rem; - max-width: 900px; + display: flex; + flex-direction: column; + height: calc(100vh - 140px); + overflow: hidden; } +/* Context Selector */ .contextSelector { display: flex; flex-wrap: wrap; gap: 0.5rem; - margin-bottom: 1.25rem; + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--border-color, #e0e0e0); + flex-shrink: 0; + align-items: center; } .contextChip { + display: flex; + align-items: center; + gap: 0.35rem; padding: 0.4rem 0.9rem; background: var(--bg-card, #fff); border: 1px solid var(--border-color, #ddd); @@ -19,6 +28,7 @@ font-size: 0.85rem; color: var(--text-primary, #333); transition: all 0.15s; + white-space: nowrap; } .contextChip:hover { @@ -32,21 +42,79 @@ border-color: var(--primary-color, #F25843); } -.contextChipActive:hover { - color: #fff; +.contextChipActive:hover { color: #fff; } + +.contextChipIcon { + font-weight: 700; + font-size: 0.75rem; +} + +.contextChipNew { + width: 32px; + height: 32px; + border: 1px dashed var(--border-color, #ccc); + border-radius: 50%; + background: transparent; + cursor: pointer; + font-size: 1.2rem; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-secondary, #888); + flex-shrink: 0; +} + +.contextChipNew:hover { + background: var(--bg-hover, #f5f5f5); + color: var(--primary-color, #F25843); +} + +/* New Context Form */ +.newContextForm { + padding: 1rem; + background: var(--bg-card, #fff); + border-bottom: 1px solid var(--border-color, #e0e0e0); + display: flex; + flex-direction: column; + gap: 0.5rem; + flex-shrink: 0; +} + +.newContextInput { + padding: 0.5rem 0.75rem; + border: 1px solid var(--border-color, #ddd); + border-radius: 6px; + font-size: 0.9rem; + background: var(--bg-input, #fff); + color: var(--text-primary, #333); +} + +.newContextActions { + display: flex; + gap: 0.5rem; } .empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + flex: 1; padding: 3rem; text-align: center; color: var(--text-secondary, #666); } +.empty h3 { color: var(--text-primary, #333); margin-bottom: 0.5rem; } +.empty p { margin-bottom: 1rem; } + +/* Header */ .header { display: flex; justify-content: space-between; align-items: flex-start; - margin-bottom: 1.5rem; + padding: 0.75rem 1rem; + flex-shrink: 0; } .title { @@ -62,6 +130,53 @@ margin: 0; } +.headerActions { + display: flex; + gap: 0.5rem; + align-items: center; +} + +/* Buttons */ +.btnPrimary { + padding: 0.5rem 1.25rem; + background: var(--primary-color, #F25843); + color: #fff; + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 0.85rem; + font-weight: 500; +} + +.btnPrimary:hover:not(:disabled) { filter: brightness(1.08); } +.btnPrimary:disabled { background: var(--color-medium-gray, #ccc); color: var(--text-secondary, #888); cursor: not-allowed; opacity: 0.8; } + +.btnSecondary { + padding: 0.5rem 1.25rem; + background: transparent; + color: var(--text-primary, #333); + border: 1px solid var(--border-color, #ddd); + border-radius: 6px; + cursor: pointer; + font-size: 0.85rem; +} + +.btnSecondary:hover:not(:disabled) { background: var(--hover-bg, #f5f5f5); border-color: var(--primary-color, #F25843); color: var(--primary-color, #F25843); } + +.btnExport { + padding: 0.4rem 0.75rem; + background: transparent; + border: 1px solid var(--border-color, #ddd); + border-radius: 6px; + cursor: pointer; + font-size: 0.8rem; + color: var(--text-primary, #333); + text-decoration: none; + display: inline-block; +} + +.btnExport:hover { border-color: var(--primary-color, #F25843); color: var(--primary-color, #F25843); } + .btnArchive { padding: 0.4rem 0.75rem; background: transparent; @@ -72,17 +187,41 @@ color: var(--text-primary, #333); } -.btnArchive:hover:not(:disabled) { - color: var(--error-color, #dc2626); - border-color: var(--error-color, #dc2626); +.btnArchive:hover:not(:disabled) { color: var(--error-color, #dc2626); border-color: var(--error-color, #dc2626); } + +.btnSmall { + padding: 0.3rem 0.75rem; + background: var(--primary-color, #F25843); + color: #fff; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 0.8rem; } +.btnSmall:hover:not(:disabled) { filter: brightness(1.08); } + +.btnSmallDanger { + padding: 0.3rem 0.75rem; + background: transparent; + color: var(--error-color, #dc2626); + border: 1px solid var(--error-color, #dc2626); + border-radius: 4px; + cursor: pointer; + font-size: 0.8rem; +} + +.btnSmallDanger:hover:not(:disabled) { background: var(--error-color, #dc2626); color: #fff; } + +.mutedActive { background: var(--color-medium-gray, #999); color: #fff; border-color: var(--color-medium-gray, #999); } + /* Tabs */ .tabs { display: flex; gap: 0; border-bottom: 1px solid var(--border-color, #e0e0e0); - margin-bottom: 1rem; + flex-shrink: 0; + padding: 0 1rem; } .tab { @@ -104,22 +243,177 @@ border-bottom-color: var(--primary-color, #F25843); } -.tabContent { min-height: 200px; } -.emptyTab { text-align: center; padding: 2rem; color: var(--text-secondary, #888); } - -/* Tasks */ -.addTaskRow { - display: flex; - gap: 0.5rem; - margin-bottom: 1rem; +.tabContent { + flex: 1; + overflow-y: auto; + padding: 1rem; } -.addTaskInput { +.emptyTab { text-align: center; padding: 2rem; color: var(--text-secondary, #888); } + +/* ============================================================ */ +/* COACHING TAB */ +/* ============================================================ */ +.coachingTab { flex: 1; - padding: 0.5rem 0.75rem; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.sessionStart { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + flex: 1; + text-align: center; + padding: 2rem; +} + +.sessionStart p { color: var(--text-secondary, #666); margin-bottom: 1rem; } + +.personaSelector { margin-bottom: 1rem; } +.personaLabel { font-size: 0.85rem; font-weight: 500; color: var(--text-primary, #333); display: block; margin-bottom: 0.5rem; } +.personaGrid { display: flex; flex-wrap: wrap; gap: 0.5rem; justify-content: center; } +.personaChip { + display: flex; align-items: center; gap: 0.3rem; + padding: 0.4rem 0.8rem; border: 1px solid var(--border-color, #ddd); - border-radius: 6px; + border-radius: 20px; background: var(--bg-card, #fff); + cursor: pointer; font-size: 0.8rem; + color: var(--text-primary, #333); transition: all 0.15s; +} +.personaChip:hover { border-color: var(--primary-color, #F25843); color: var(--primary-color, #F25843); } +.personaChipActive { background: var(--primary-color, #F25843); color: #fff; border-color: var(--primary-color, #F25843); } +.personaGender { font-size: 1rem; } + +.sessionHeader { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem 1rem; + background: var(--bg-card, #fff); + border-bottom: 1px solid var(--border-color, #e0e0e0); + flex-shrink: 0; +} + +.sessionLabel { font-size: 0.85rem; font-weight: 500; color: var(--text-primary, #333); } +.sessionActions { display: flex; gap: 0.5rem; } + +/* Messages */ +.messages { + flex: 1; + padding: 1rem; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.message { max-width: 80%; } +.messageUser { align-self: flex-end; } +.messageAssistant { align-self: flex-start; } + +.messageBubble { + padding: 0.75rem 1rem; + border-radius: 12px; font-size: 0.9rem; + line-height: 1.5; +} + +.messageUser .messageBubble { + background: var(--primary-color, #F25843); + color: #fff; + border-bottom-right-radius: 4px; +} + +.messageLive { + opacity: 0.7; + font-style: italic; + border: 1px dashed rgba(255, 255, 255, 0.4); +} + +.messageAssistant .messageBubble { + background: var(--bg-card, #f5f5f5); + color: var(--text-primary, #333); + border: 1px solid var(--border-color, #e0e0e0); + border-bottom-left-radius: 4px; +} + +.messageBubble p { margin: 0 0 0.5rem; } +.messageBubble p:last-child { margin-bottom: 0; } + +.messageTime { + font-size: 0.7rem; + color: var(--text-secondary, #999); + margin-top: 0.2rem; + padding: 0 0.25rem; +} + +.messageUser .messageTime { text-align: right; } + +.typing { color: var(--text-secondary, #888); font-style: italic; } +.typingDots { animation: blink 1.4s infinite both; } +@keyframes blink { 0%, 80%, 100% { opacity: 0; } 40% { opacity: 1; } } + +/* Input Area */ +.inputArea { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.75rem 1rem; + border-top: 1px solid var(--border-color, #e0e0e0); + background: var(--bg-card, #fff); + flex-shrink: 0; +} + +.textInputRow { display: flex; gap: 0.5rem; align-items: flex-end; } + +.textInput { + flex: 1; min-width: 0; + padding: 0.6rem 0.75rem; + border: 1px solid var(--border-color, #ddd); + border-radius: 8px; resize: none; + font-size: 0.9rem; font-family: inherit; + min-height: 40px; max-height: 120px; + background: var(--bg-input, #fff); + color: var(--text-primary, #333); +} + +.sendBtn { + padding: 0.6rem 1.25rem; + background: var(--primary-color, #F25843); + color: #fff; border: none; border-radius: 8px; + cursor: pointer; font-size: 0.85rem; font-weight: 500; + align-self: flex-end; +} + +.sendBtn:hover:not(:disabled) { filter: brightness(1.08); } +.sendBtn:disabled { background: var(--color-medium-gray, #ccc); color: var(--text-secondary, #888); cursor: not-allowed; opacity: 0.8; } + +.voiceStatus { display: flex; align-items: center; padding: 0.25rem 0; min-height: 1.5rem; } +.voiceIndicator { font-size: 0.9rem; color: var(--text-secondary, #888); } +.voiceIndicator.voiceActive { color: var(--primary-color, #F25843); font-weight: 500; } + +.errorBanner { + padding: 0.5rem 1rem; + background: #fde8e8; + color: var(--color-error, #d32f2f); + font-size: 0.85rem; + text-align: center; + flex-shrink: 0; +} + +/* ============================================================ */ +/* TASKS */ +/* ============================================================ */ +.addTaskRow { display: flex; gap: 0.5rem; margin-bottom: 1rem; } + +.addTaskInput { + flex: 1; padding: 0.5rem 0.75rem; + border: 1px solid var(--border-color, #ddd); + border-radius: 6px; font-size: 0.9rem; background: var(--bg-input, #fff); color: var(--text-primary, #333); } @@ -127,27 +421,17 @@ .addTaskBtn { padding: 0.5rem 1rem; background: var(--primary-color, #F25843); - color: #fff; - border: none; - border-radius: 6px; - cursor: pointer; - font-size: 0.85rem; + color: #fff; border: none; border-radius: 6px; + cursor: pointer; font-size: 0.85rem; } .addTaskBtn:hover:not(:disabled) { filter: brightness(1.08); } -.addTaskBtn:disabled { - background: var(--color-medium-gray, #ccc); - color: var(--text-secondary, #888); - cursor: not-allowed; - opacity: 0.8; -} +.addTaskBtn:disabled { background: var(--color-medium-gray, #ccc); color: var(--text-secondary, #888); cursor: not-allowed; opacity: 0.8; } .taskList { display: flex; flex-direction: column; gap: 0.5rem; } .taskItem { - display: flex; - align-items: flex-start; - gap: 0.75rem; + display: flex; align-items: flex-start; gap: 0.75rem; padding: 0.75rem; background: var(--bg-card, #fff); border: 1px solid var(--border-color, #e0e0e0); @@ -158,72 +442,44 @@ .taskDone .taskTitle { text-decoration: line-through; } .taskCheck { - width: 28px; - height: 28px; + width: 28px; height: 28px; border: 2px solid var(--border-color, #ccc); - border-radius: 50%; - background: transparent; - cursor: pointer; - font-size: 1rem; - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - color: var(--primary-color, #F25843); + border-radius: 50%; background: transparent; + cursor: pointer; font-size: 1rem; + display: flex; align-items: center; justify-content: center; + flex-shrink: 0; color: var(--primary-color, #F25843); } .taskContent { flex: 1; } .taskTitle { font-size: 0.9rem; font-weight: 500; color: var(--text-primary, #333); } .taskDesc { font-size: 0.8rem; color: var(--text-secondary, #666); margin-top: 0.2rem; } - .taskMeta { font-size: 0.75rem; } -.taskPriority { - padding: 0.15rem 0.4rem; - border-radius: 4px; - font-size: 0.7rem; - text-transform: uppercase; -} - +.taskPriority { padding: 0.15rem 0.4rem; border-radius: 4px; font-size: 0.7rem; text-transform: uppercase; } .priority_high { background: #fde8e8; color: #c62828; } .priority_medium { background: #fff3e0; color: #e65100; } .priority_low { background: #e8f5e9; color: #2e7d32; } .taskDelete { - background: transparent; - border: none; - cursor: pointer; - color: var(--text-secondary, #aaa); - font-size: 0.9rem; - padding: 0.2rem; + background: transparent; border: none; cursor: pointer; + color: var(--text-secondary, #aaa); font-size: 0.9rem; padding: 0.2rem; } - .taskDelete:hover { color: var(--error-color, #dc2626); } -/* Sessions */ +/* ============================================================ */ +/* SESSIONS */ +/* ============================================================ */ .sessionTimeline { display: flex; flex-direction: column; gap: 1rem; } .sessionItem { background: var(--bg-card, #fff); border: 1px solid var(--border-color, #e0e0e0); - border-radius: 8px; - padding: 1rem; + border-radius: 8px; padding: 1rem; } -.sessionItemHeader { - display: flex; - gap: 0.75rem; - align-items: center; - margin-bottom: 0.5rem; -} - -.sessionStatus { - padding: 0.15rem 0.5rem; - border-radius: 4px; - font-size: 0.75rem; - font-weight: 500; -} +.sessionItemHeader { display: flex; gap: 0.75rem; align-items: center; margin-bottom: 0.5rem; } +.sessionStatus { padding: 0.15rem 0.5rem; border-radius: 4px; font-size: 0.75rem; font-weight: 500; } .status_completed { background: #e8f5e9; color: #2e7d32; } .status_active { background: #e3f2fd; color: #1565c0; } .status_cancelled { background: #fde8e8; color: #c62828; } @@ -231,34 +487,26 @@ .sessionDate { font-size: 0.8rem; color: var(--text-secondary, #666); } .sessionScore { font-size: 0.8rem; font-weight: 600; color: var(--primary-color, #F25843); } -.sessionSummary { - font-size: 0.85rem; - line-height: 1.5; - color: var(--text-primary, #333); - margin-bottom: 0.5rem; -} - +.sessionSummary { font-size: 0.85rem; line-height: 1.5; color: var(--text-primary, #333); margin-bottom: 0.5rem; } .sessionSummary p { margin: 0 0 0.4rem; } .sessionMeta { font-size: 0.75rem; color: var(--text-secondary, #888); } -/* Scores */ +.sessionExport { margin-left: 0.5rem; font-size: 0.75rem; color: var(--primary-color, #F25843); text-decoration: none; } +.sessionExport:hover { text-decoration: underline; } + +/* ============================================================ */ +/* SCORES */ +/* ============================================================ */ .scoreList { display: flex; flex-direction: column; gap: 1rem; } .scoreGroup { background: var(--bg-card, #fff); border: 1px solid var(--border-color, #e0e0e0); - border-radius: 8px; - padding: 1rem; -} - -.scoreDimension { - display: flex; - align-items: center; - gap: 0.75rem; - margin-bottom: 0.5rem; + border-radius: 8px; padding: 1rem; } +.scoreDimension { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.5rem; } .scoreDimensionLabel { font-weight: 600; font-size: 0.9rem; flex: 1; } .scoreLatest { font-weight: 700; font-size: 1rem; color: var(--primary-color, #F25843); } @@ -267,113 +515,33 @@ .trend_stable { color: #e65100; } .trend_declining { color: #c62828; } -.scoreBar { - height: 6px; - background: var(--bg-hover, #eee); - border-radius: 3px; - overflow: hidden; - margin-bottom: 0.5rem; -} +.scoreBar { height: 6px; background: var(--bg-hover, #eee); border-radius: 3px; overflow: hidden; margin-bottom: 0.5rem; } +.scoreBarFill { height: 100%; background: var(--primary-color, #F25843); border-radius: 3px; transition: width 0.3s; } -.scoreBarFill { - height: 100%; - background: var(--primary-color, #F25843); - border-radius: 3px; - transition: width 0.3s; -} +.scoreEvidence { font-size: 0.8rem; color: var(--text-secondary, #666); line-height: 1.4; } -.scoreEvidence { - font-size: 0.8rem; - color: var(--text-secondary, #666); - line-height: 1.4; -} +.scoreHistory { margin-top: 0.5rem; display: flex; align-items: center; gap: 0.5rem; } +.scoreHistoryLabel { font-size: 0.75rem; color: var(--text-secondary, #888); } +.scoreHistoryPoints { display: flex; gap: 0.4rem; flex-wrap: wrap; } +.scoreHistoryPoint { padding: 0.15rem 0.4rem; background: var(--bg-hover, #f0f0f0); border-radius: 4px; font-size: 0.7rem; color: var(--text-secondary, #666); } -/* Score History */ -.scoreHistory { - margin-top: 0.5rem; - display: flex; - align-items: center; - gap: 0.5rem; -} - -.scoreHistoryLabel { - font-size: 0.75rem; - color: var(--text-secondary, #888); -} - -.scoreHistoryPoints { - display: flex; - gap: 0.4rem; - flex-wrap: wrap; -} - -.scoreHistoryPoint { - padding: 0.15rem 0.4rem; - background: var(--bg-hover, #f0f0f0); - border-radius: 4px; - font-size: 0.7rem; - color: var(--text-secondary, #666); -} - -/* Export Button */ -.btnExport { - padding: 0.4rem 0.75rem; - background: transparent; - border: 1px solid var(--border-color, #ddd); - border-radius: 6px; - cursor: pointer; - font-size: 0.8rem; - color: var(--text-primary, #333); - text-decoration: none; - display: inline-block; -} - -.btnExport:hover { - border-color: var(--primary-color, #F25843); - color: var(--primary-color, #F25843); -} - -.headerActions { - display: flex; - gap: 0.5rem; - align-items: center; -} - -/* Session Export */ -.sessionExport { - margin-left: 0.5rem; - font-size: 0.75rem; - color: var(--primary-color, #F25843); - text-decoration: none; -} - -.sessionExport:hover { - text-decoration: underline; -} - -/* Documents */ +/* ============================================================ */ +/* DOCUMENTS */ +/* ============================================================ */ .uploadLabel { padding: 0.5rem 1rem; background: var(--primary-color, #F25843); - color: #fff; - border-radius: 6px; - cursor: pointer; - font-size: 0.85rem; + color: #fff; border-radius: 6px; + cursor: pointer; font-size: 0.85rem; display: inline-block; } .uploadLabel:hover { filter: brightness(1.08); } -.documentList { - display: flex; - flex-direction: column; - gap: 0.5rem; -} +.documentList { display: flex; flex-direction: column; gap: 0.5rem; } .documentItem { - display: flex; - align-items: flex-start; - gap: 0.75rem; + display: flex; align-items: flex-start; gap: 0.75rem; padding: 0.75rem; background: var(--bg-card, #fff); border: 1px solid var(--border-color, #e0e0e0); @@ -381,22 +549,7 @@ } .documentInfo { flex: 1; } - -.documentName { - font-size: 0.9rem; - font-weight: 500; - color: var(--text-primary, #333); -} - -.documentMeta { - font-size: 0.75rem; - color: var(--text-secondary, #888); - margin-top: 0.2rem; -} - -.documentSummary { - font-size: 0.8rem; - color: var(--text-secondary, #666); - margin-top: 0.4rem; - line-height: 1.4; -} +.documentName { font-size: 0.9rem; font-weight: 500; color: var(--text-primary, #333); } +.documentMeta { font-size: 0.75rem; color: var(--text-secondary, #888); margin-top: 0.2rem; } +.documentSummary { font-size: 0.8rem; color: var(--text-secondary, #666); margin-top: 0.4rem; line-height: 1.4; } +.documentActions { display: flex; gap: 0.5rem; align-items: center; flex-shrink: 0; } diff --git a/src/pages/views/commcoach/CommcoachDossierView.tsx b/src/pages/views/commcoach/CommcoachDossierView.tsx index 52c617e..1ac75f5 100644 --- a/src/pages/views/commcoach/CommcoachDossierView.tsx +++ b/src/pages/views/commcoach/CommcoachDossierView.tsx @@ -1,38 +1,65 @@ /** - * CommCoach Dossier View - * - * Shows context detail: sessions timeline, tasks checklist, scores, insights. + * 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, useCallback, useEffect } from 'react'; +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, - type CoachingDocument, + 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('coaching'); + const [showNewContext, setShowNewContext] = useState(false); + const [newTitle, setNewTitle] = useState(''); + const [newDescription, setNewDescription] = useState(''); + const [newCategory, setNewCategory] = useState('custom'); + const [newTaskTitle, setNewTaskTitle] = useState(''); - const [activeTab, setActiveTab] = useState<'sessions' | 'tasks' | 'scores' | 'documents'>('tasks'); const [documents, setDocuments] = useState([]); const [uploading, setUploading] = useState(false); const [scoreHistory, setScoreHistory] = useState>>({}); + const [personas, setPersonas] = useState([]); + const [selectedPersonaId, setSelectedPersonaId] = useState(undefined); + const inputRef = useRef(null); + const streamRef = useRef(null); + const speechRecognitionRef = useRef(null); + const transcriptPartsRef = useRef([]); + const processedResultIndexRef = useRef(0); + const silenceTimerRef = useRef | null>(null); + const [isListening, setIsListening] = useState(false); + const [isUserSpeaking, setIsUserSpeaking] = useState(false); + const [liveTranscript, setLiveTranscript] = useState(''); + const [isTtsPlaying, setIsTtsPlaying] = useState(false); + + // Auto-select first context useEffect(() => { if (!coach.selectedContextId && coach.contexts.length > 0) { - coach.selectContext(coach.contexts[0].id); + 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) @@ -43,6 +70,208 @@ export const CommcoachDossierView: React.FC = () => { .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(); + 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 = () => { + 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 totalWords = preview.split(/\s+/).filter(Boolean).length; + const finalizedWords = transcriptPartsRef.current.join(' ').split(/\s+/).filter(Boolean).length; + if (coach.isTtsPlayingRef.current && finalizedWords >= MIN_WORDS_TO_INTERRUPT) { + coach.stopTts(); + } + }; + + recognition.onspeechend = () => { + 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 = () => { + if (cancelled) return; + setIsUserSpeaking(false); + transcriptPartsRef.current = []; + setLiveTranscript(''); + if (speechRecognitionRef.current === recognition) { + try { recognition.start(); } catch { speechRecognitionRef.current = null; } + } + }; + + recognition.onerror = (event: any) => { + 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 () => { + 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]); + + // 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) => { const file = e.target.files?.[0]; if (!file || !instanceId || !coach.selectedContextId) return; @@ -50,9 +279,7 @@ export const CommcoachDossierView: React.FC = () => { try { const doc = await uploadDocumentApi(instanceId, coach.selectedContextId, file); setDocuments(prev => [doc, ...prev]); - } catch { - // upload failed - } finally { + } catch { /* upload failed */ } finally { setUploading(false); e.target.value = ''; } @@ -63,11 +290,28 @@ export const CommcoachDossierView: React.FC = () => { try { await deleteDocumentApi(request, instanceId, docId); setDocuments(prev => prev.filter(d => d.id !== docId)); - } catch { - // delete failed - } + } 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); @@ -78,14 +322,6 @@ export const CommcoachDossierView: React.FC = () => { return

Lade...

; } - if (coach.contexts.length === 0) { - return ( -
-

Noch keine Coaching-Themen vorhanden. Erstelle zuerst eines im Coaching-Tab.

-
- ); - } - return (
{/* Context Selector */} @@ -94,261 +330,403 @@ export const CommcoachDossierView: React.FC = () => { ))} +
- {!coach.selectedContextId ? ( -

Wähle ein Coaching-Thema.

- ) : (<> - {/* Context Header */} -
-
-

{coach.selectedContext?.title}

- {coach.selectedContext?.description && ( -

{coach.selectedContext.description}

- )} + {/* New Context Form */} + {showNewContext && ( +
+ setNewTitle(e.target.value)} + onKeyDown={e => e.key === 'Enter' && handleCreateContext()} + autoFocus + /> + setNewDescription(e.target.value)} + /> + +
+ + +
-
- {instanceId && coach.selectedContextId && ( - <> - - Export MD - - - Export PDF - - - )} - + )} + + {/* No context selected */} + {!coach.selectedContextId && !showNewContext && coach.contexts.length === 0 && ( +
+

Willkommen beim Kommunikations-Coach

+

Erstelle ein Thema, um zu beginnen.

+
-
+ )} - {/* Tab Navigation */} -
- - - - -
- - {/* Tasks Tab */} - {activeTab === 'tasks' && ( -
-
- setNewTaskTitle(e.target.value)} - onKeyDown={e => e.key === 'Enter' && handleAddTask()} - /> -
- {coach.tasks.length === 0 ? ( -
Noch keine Aufgaben. Der Coach schlägt während Sessions Aufgaben vor.
- ) : ( -
- {coach.tasks.map(task => ( -
- -
-
{task.title}
- {task.description &&
{task.description}
} -
-
- - {task.priority} - -
- -
- ))} -
- )}
- )} - {/* Sessions Tab */} - {activeTab === 'sessions' && ( -
- {coach.sessions.length === 0 ? ( -
Noch keine abgeschlossenen Sessions.
- ) : ( -
- {coach.sessions.map(s => ( -
-
- - {s.status === 'completed' ? 'Abgeschlossen' : s.status === 'active' ? 'Aktiv' : 'Abgebrochen'} - - - {s.startedAt ? new Date(s.startedAt).toLocaleDateString('de-CH') : ''} - - {s.competenceScore != null && ( - Score: {Math.round(s.competenceScore)} - )} -
- {s.summary && ( -
- {s.summary} + {/* Tab Navigation */} +
+ {(['coaching', 'tasks', 'sessions', 'scores', 'documents'] as TabKey[]).map(tab => ( + + ))} +
+ + {/* ============================================================ */} + {/* COACHING TAB */} + {/* ============================================================ */} + {activeTab === 'coaching' && ( +
+ {!coach.session ? ( +
+

Starte eine neue Coaching-Session zu diesem Thema.

+ {personas.length > 0 && ( +
+ +
+ {personas.map(p => ( + + ))}
- )} -
- {s.messageCount} Nachrichten | {Math.round(s.durationSeconds / 60)} Min. - {s.personaId && | Persona} - {instanceId && s.status === 'completed' && ( - e.stopPropagation()} - > - Export - +
+ )} + +
+ ) : ( + <> + {/* Session Header */} +
+ Session aktiv +
+ {isTtsPlaying && ( + )} + {coach.wasInterrupted && !isTtsPlaying && ( + + )} + + +
- ))} -
- )} -
- )} - {/* Scores Tab */} - {activeTab === 'scores' && ( -
- {coach.scores.length === 0 ? ( -
Noch keine Bewertungen. Schliesse eine Session ab, um Scores zu erhalten.
- ) : ( -
- {_groupScoresByDimension(coach.scores).map(group => ( -
-
- {_dimensionLabel(group.dimension)} - {Math.round(group.latest.score)}/100 - - {group.latest.trend === 'improving' ? 'steigend' : group.latest.trend === 'declining' ? 'sinkend' : 'stabil'} - -
-
-
-
- {group.latest.evidence && ( -
{group.latest.evidence}
- )} - {scoreHistory[group.dimension] && scoreHistory[group.dimension].length > 1 && ( -
-
Verlauf:
-
- {scoreHistory[group.dimension].map((entry, i) => ( - - {Math.round(entry.score)} - - ))} + {/* Messages */} + +
+ {coach.messages.map(msg => ( +
+
+ {msg.content} +
+
+ {msg.createdAt ? new Date(msg.createdAt).toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' }) : ''} +
+
+ ))} + {liveTranscript && ( +
+
{liveTranscript}
+
+ )} + {coach.isStreaming && ( +
+
+ {coach.streamingMessage ? ( + {coach.streamingMessage} + ) : ( +
{coach.streamingStatus || 'Coach denkt nach'}...
+ )} +
-
- )} -
- ))} -
- )} -
- )} - - {/* Documents Tab */} - {activeTab === 'documents' && ( -
-
- -
- {documents.length === 0 ? ( -
Keine Dokumente. Lade Dateien hoch, um sie mit diesem Kontext zu verknüpfen.
- ) : ( -
- {documents.map(doc => ( -
-
-
{doc.fileName}
-
- {_formatFileSize(doc.fileSize)} | {doc.createdAt ? new Date(doc.createdAt).toLocaleDateString('de-CH') : ''} -
- {doc.summary && ( -
{doc.summary}
)}
- + + + {/* Input Area */} +
+
+ + {coach.isMuted + ? 'Stumm – Mikrofon aus' + : coach.isStreaming + ? (coach.streamingStatus || 'Coach antwortet...') + : isUserSpeaking + ? 'Spricht...' + : isListening + ? 'Mikrofon an – bitte sprechen' + : 'Mikrofon wird gestartet...'} + +
+
+