From 309d6982e814ffe826093d37227d2751d7e34424 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Fri, 6 Mar 2026 14:43:07 +0100 Subject: [PATCH] commcoach: voice state machine replacing distributed useEffect logic Made-with: Cursor --- src/hooks/useCommcoach.ts | 86 ++- .../CommcoachCoachingView.module.css | 402 ------------- .../views/commcoach/CommcoachCoachingView.tsx | 526 ------------------ .../views/commcoach/CommcoachDossierView.tsx | 223 ++++---- 4 files changed, 163 insertions(+), 1074 deletions(-) delete mode 100644 src/pages/views/commcoach/CommcoachCoachingView.module.css delete mode 100644 src/pages/views/commcoach/CommcoachCoachingView.tsx diff --git a/src/hooks/useCommcoach.ts b/src/hooks/useCommcoach.ts index a2abe8f..7a8c5df 100644 --- a/src/hooks/useCommcoach.ts +++ b/src/hooks/useCommcoach.ts @@ -16,6 +16,8 @@ import { type CoachingTask, type CoachingScore, type SSEEvent, } from '../api/commcoachApi'; +export type TtsEvent = 'playing' | 'ended' | 'paused' | 'error'; + export interface CommcoachHookReturn { contexts: CoachingContext[]; selectedContextId: string | null; @@ -46,12 +48,11 @@ export interface CommcoachHookReturn { completeSession: () => Promise; cancelSession: () => Promise; - isMuted: boolean; - setMuted: (muted: boolean) => void; stopTts: () => void; resumeTts: () => void; - wasInterrupted: boolean; - isTtsPlayingRef: MutableRefObject; + hasAudioToResume: () => boolean; + + onTtsEventRef: MutableRefObject<((event: TtsEvent) => void) | null>; actionLoading: string | null; @@ -86,14 +87,11 @@ export function useCommcoach(): CommcoachHookReturn { const [error, setError] = useState(null); const [inputValue, setInputValue] = useState(''); - const [isMuted, setIsMuted] = useState(false); - const [wasInterrupted, setWasInterrupted] = useState(false); const [actionLoading, setActionLoading] = useState(null); const isMountedRef = useRef(true); const currentAudioRef = useRef(null); - const isTtsPlayingRef = useRef(false); - const lastTtsAudioRef = useRef(null); + const onTtsEventRef = useRef<((event: TtsEvent) => void) | null>(null); const onDocumentCreatedRef = useRef<((doc: any) => void) | null>(null); useEffect(() => { isMountedRef.current = true; return () => { isMountedRef.current = false; }; }, []); @@ -112,62 +110,53 @@ export function useCommcoach(): CommcoachHookReturn { } }, [request, instanceId]); + const _emitTts = useCallback((event: TtsEvent) => { + (window as any).__dlog?.(`TTS-${event.toUpperCase()}`); + onTtsEventRef.current?.(event); + }, []); + const _playTtsAudio = useCallback((audioB64: string) => { if (!audioB64 || !isMountedRef.current) return; if (currentAudioRef.current) { currentAudioRef.current.pause(); currentAudioRef.current = null; } - lastTtsAudioRef.current = audioB64; - setWasInterrupted(false); - isTtsPlayingRef.current = true; try { const audio = new Audio(`data:audio/mp3;base64,${audioB64}`); currentAudioRef.current = audio; - // #region agent log - audio.onpause = () => { (window as any).__dlog?.('TTS-PAUSE', `t=${audio.currentTime.toFixed(1)} dur=${audio.duration.toFixed(1)}`); }; - // #endregion audio.onended = () => { - // #region agent log - (window as any).__dlog?.('TTS-ENDED', `dur=${audio.duration.toFixed(1)}`); - // #endregion currentAudioRef.current = null; - isTtsPlayingRef.current = false; + _emitTts('ended'); }; audio.play().then(() => { - // #region agent log - (window as any).__dlog?.('TTS-PLAY', `dur=${audio.duration.toFixed(1)}`); - // #endregion - }).catch((e) => { - // #region agent log - (window as any).__dlog?.('TTS-PLAY-ERR', e?.message || 'unknown'); - // #endregion - isTtsPlayingRef.current = false; + _emitTts('playing'); + }).catch(() => { + _emitTts('error'); }); } catch { - isTtsPlayingRef.current = false; + _emitTts('error'); } - }, []); + }, [_emitTts]); const stopTts = useCallback(() => { - // #region agent log - (window as any).__dlog?.('STOP-TTS', `playing=${isTtsPlayingRef.current} hasAudio=${!!currentAudioRef.current}`); - // #endregion if (currentAudioRef.current) { currentAudioRef.current.pause(); + _emitTts('paused'); } - if (isTtsPlayingRef.current) { - setWasInterrupted(true); - } - isTtsPlayingRef.current = false; - }, []); + }, [_emitTts]); const resumeTts = useCallback(() => { if (currentAudioRef.current && currentAudioRef.current.paused) { - isTtsPlayingRef.current = true; - setWasInterrupted(false); - currentAudioRef.current.play().catch(() => { isTtsPlayingRef.current = false; }); + currentAudioRef.current.play().then(() => { + _emitTts('playing'); + }).catch(() => { + _emitTts('error'); + }); } + }, [_emitTts]); + + const hasAudioToResume = useCallback(() => { + return !!(currentAudioRef.current && currentAudioRef.current.paused && currentAudioRef.current.currentTime > 0); }, []); const selectContext = useCallback(async (contextId: string, options?: { skipSessionResume?: boolean }) => { @@ -269,7 +258,6 @@ export function useCommcoach(): CommcoachHookReturn { setActionLoading('starting'); await _unlockAudioForTts(); setError(null); - setIsMuted(false); setIsStreaming(true); setStreamingStatus(null); setMessages([]); @@ -287,7 +275,6 @@ export function useCommcoach(): CommcoachHookReturn { const sess = eventData.session; if (sess) { setSession(sess); - setIsMuted(false); } if (eventData.resumed && Array.isArray(eventData.messages)) { setMessages(eventData.messages); @@ -350,7 +337,10 @@ export function useCommcoach(): CommcoachHookReturn { const sendMessage = useCallback(async (content: string) => { const normalizedContent = content.trim(); if (!normalizedContent || !instanceId || !session) return; - stopTts(); + if (currentAudioRef.current) { + currentAudioRef.current.pause(); + currentAudioRef.current = null; + } await _unlockAudioForTts(); setError(null); setIsStreaming(true); @@ -434,11 +424,14 @@ export function useCommcoach(): CommcoachHookReturn { setIsStreaming(false); } } - }, [instanceId, session, _playTtsAudio, stopTts]); + }, [instanceId, session, _playTtsAudio]); const sendAudio = useCallback(async (audioBlob: Blob) => { if (!instanceId || !session) return; - stopTts(); + if (currentAudioRef.current) { + currentAudioRef.current.pause(); + currentAudioRef.current = null; + } await _unlockAudioForTts(); setError(null); setIsStreaming(true); @@ -500,7 +493,7 @@ export function useCommcoach(): CommcoachHookReturn { setIsStreaming(false); } } - }, [instanceId, session, stopTts]); + }, [instanceId, session]); const completeSessionCb = useCallback(async () => { if (!instanceId || !session) return; @@ -583,7 +576,8 @@ export function useCommcoach(): CommcoachHookReturn { selectContext, createContext, archiveContext, startSession: startSessionCb, sendMessage, sendAudio, completeSession: completeSessionCb, cancelSession: cancelSessionCb, - isMuted, setMuted: setIsMuted, stopTts, resumeTts, wasInterrupted, isTtsPlayingRef, + stopTts, resumeTts, hasAudioToResume, + onTtsEventRef, actionLoading, toggleTaskStatus, addTask, removeTask, onDocumentCreatedRef, diff --git a/src/pages/views/commcoach/CommcoachCoachingView.module.css b/src/pages/views/commcoach/CommcoachCoachingView.module.css deleted file mode 100644 index b898159..0000000 --- a/src/pages/views/commcoach/CommcoachCoachingView.module.css +++ /dev/null @@ -1,402 +0,0 @@ -.coaching { - display: flex; - flex-direction: column; - height: calc(100vh - 140px); - overflow: hidden; -} - -/* Context Tabs */ -.contextBar { - border-bottom: 1px solid var(--border-color, #e0e0e0); - padding: 0.5rem 1rem; - flex-shrink: 0; -} - -.contextTabs { - display: flex; - gap: 0.5rem; - overflow-x: auto; - align-items: center; -} - -.contextTab { - display: flex; - align-items: center; - gap: 0.35rem; - padding: 0.4rem 0.75rem; - border: 1px solid var(--border-color, #e0e0e0); - border-radius: 20px; - background: var(--bg-card, #fff); - cursor: pointer; - font-size: 0.8rem; - white-space: nowrap; - transition: all 0.15s; - color: var(--text-primary, #333); -} - -.contextTab:hover { - background: var(--bg-hover, #f5f5f5); -} - -.contextTabActive { - background: var(--primary-color, #F25843); - color: #fff; - border-color: var(--primary-color, #F25843); -} - -.contextTabIcon { - font-weight: 700; - font-size: 0.75rem; -} - -.contextTabLabel { - max-width: 120px; - overflow: hidden; - text-overflow: ellipsis; -} - -.contextTabNew { - 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; -} - -.contextTabNew: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; -} - -.newContextInput, -.newContextSelect { - 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; -} - -/* 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); -} - -.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; -} - -/* No context */ -.noContext { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - flex: 1; - text-align: center; - padding: 2rem; - color: var(--text-secondary, #666); -} - -.noContext h3 { - color: var(--text-primary, #333); - margin-bottom: 0.5rem; -} - -.noContext p { - margin-bottom: 1rem; -} - -/* Chat Area */ -.chatArea { - flex: 1; - 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 h3 { - color: var(--text-primary, #333); - margin-bottom: 0.5rem; -} - -.sessionStart p { - color: var(--text-secondary, #666); - margin-bottom: 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 */ -.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; -} - -.voiceActive { - border: 2px solid #22c55e; -} - -.mutedActive { - background: var(--color-medium-gray, #999); - color: #fff; - border-color: var(--color-medium-gray, #999); -} - -.errorBanner { - padding: 0.5rem 1rem; - background: #fde8e8; - color: var(--color-error, #d32f2f); - font-size: 0.85rem; - text-align: center; -} diff --git a/src/pages/views/commcoach/CommcoachCoachingView.tsx b/src/pages/views/commcoach/CommcoachCoachingView.tsx deleted file mode 100644 index ff07215..0000000 --- a/src/pages/views/commcoach/CommcoachCoachingView.tsx +++ /dev/null @@ -1,526 +0,0 @@ -/** - * CommCoach Coaching View - * - * Voice first, always with text fallback (CONCEPT.md). - * Chat und Voice parallel: Mikrofon und Texteingabe gleichzeitig nutzbar. - * Mute: nur Mikrofon stummschalten, kein Moduswechsel. - */ - -import React, { useState, useRef, useEffect, useCallback } from 'react'; -import { useSearchParams } from 'react-router-dom'; -import { useCommcoach } from '../../../hooks/useCommcoach'; -import { useApiRequest } from '../../../hooks/useApi'; -import { useInstanceId } from '../../../hooks/useCurrentInstance'; -import { getPersonasApi, 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 './CommcoachCoachingView.module.css'; - -export const CommcoachCoachingView: React.FC = () => { - const [searchParams, setSearchParams] = useSearchParams(); - const coach = useCommcoach(); - const { request } = useApiRequest(); - const instanceId = useInstanceId(); - const [showNewContext, setShowNewContext] = useState(false); - const [newTitle, setNewTitle] = useState(''); - const [newDescription, setNewDescription] = useState(''); - const [newCategory, setNewCategory] = useState('custom'); - const inputRef = useRef(null); - const [personas, setPersonas] = useState([]); - const [selectedPersonaId, setSelectedPersonaId] = useState(undefined); - - 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); - - 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]); - - useEffect(() => { - const contextId = searchParams.get('context'); - if (contextId && coach.contexts.some(c => c.id === contextId)) { - coach.selectContext(contextId); - setSearchParams({}, { replace: true }); - } - }, [searchParams, coach.contexts, coach.selectContext, setSearchParams]); - - useEffect(() => { - if (coach.session && inputRef.current) { - inputRef.current.focus(); - } - }, [coach.session]); - - useEffect(() => { - if (!coach.session) { - coach.setMuted(false); - } - }, [coach.session]); - - useEffect(() => { - if (!instanceId) return; - getPersonasApi(request, instanceId) - .then(p => setPersonas(p)) - .catch(() => {}); - }, [instanceId, request]); - - useEffect(() => { - if (!coach.session) return; - const interval = setInterval(() => { - setIsTtsPlaying(coach.isTtsPlayingRef.current); - }, 200); - return () => clearInterval(interval); - }, [coach.session, coach.isTtsPlayingRef]); - - useEffect(() => { - if (!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) { - console.warn('SpeechRecognition not supported'); - return; - } - - let cancelled = false; - const MIN_WORDS_TO_INTERRUPT = 2; - const lang = 'de-DE'; - - 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 = lang; - - recognition.onstart = () => { - if (cancelled) return; - }; - - 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; - _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 || coach.isTtsPlayingRef.current) 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 { - 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; - if (totalWords >= 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; - } - }; - }, [coach.session, coach.isMuted, coach.stopTts, coach.sendMessage]); - - return ( -
- {/* Context Tabs */} -
-
- {coach.contexts.map(ctx => ( - - ))} - -
-
- - {/* New Context Form */} - {showNewContext && ( -
- setNewTitle(e.target.value)} - onKeyDown={e => e.key === 'Enter' && handleCreateContext()} - autoFocus - /> - setNewDescription(e.target.value)} - /> - -
- - -
-
- )} - - {/* No Context Selected */} - {!coach.selectedContextId && !showNewContext && ( -
-

Willkommen beim Kommunikations-Coach

-

Wähle ein bestehendes Thema oder erstelle ein neues, um zu beginnen.

- -
- )} - - {/* Chat Area */} - {coach.selectedContextId && ( -
- {/* Session controls */} - {!coach.session && ( -
-

{coach.selectedContext?.title}

-

{coach.selectedContext?.description || 'Starte eine neue Coaching-Session zu diesem Thema.'}

- - {personas.length > 0 && ( -
- -
- {personas.map(p => ( - - ))} -
-
- )} - - -
- )} - - {/* Messages */} - {coach.session && ( - <> -
- - Session aktiv - {coach.selectedContext?.title} - -
- {isTtsPlaying && ( - - )} - {coach.wasInterrupted && !isTtsPlaying && ( - - )} - - - -
-
- - -
- {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'} - ... -
- )} -
-
- )} -
-
- - {/* Input: Chat und Voice parallel (CONCEPT: Voice first, always with text fallback) */} -
-
- - {coach.isMuted - ? 'Stumm – Mikrofon aus' - : coach.isStreaming - ? (coach.streamingStatus || 'Coach antwortet...') - : isUserSpeaking - ? 'Spricht...' - : isListening - ? 'Mikrofon an – bitte sprechen' - : 'Mikrofon wird gestartet...'} - -
-
-