/** * useCommcoach Hook * * State management for CommCoach coaching sessions, contexts, and chat. */ import { useState, useEffect, useCallback, useRef, type MutableRefObject } from 'react'; import { useApiRequest } from './useApi'; import { useInstanceId } from './useCurrentInstance'; import { getContextsApi, createContextApi, getContextDetailApi, startSessionStreamApi, completeSessionApi, cancelSessionApi, sendMessageStreamApi, sendAudioStreamApi, createTaskApi, updateTaskStatusApi, deleteTaskApi, type CoachingContext, type CoachingSession, type CoachingMessage, type CoachingTask, type CoachingScore, type SSEEvent, type SendMessageOptions, } from '../api/commcoachApi'; import { useTtsPlayback, type TtsEvent } from './useTtsPlayback'; export interface CommcoachHookReturn { contexts: CoachingContext[]; selectedContextId: string | null; selectedContext: CoachingContext | null; loadingContexts: boolean; session: CoachingSession | null; messages: CoachingMessage[]; isStreaming: boolean; streamingStatus: string | null; streamingMessage: string | null; tasks: CoachingTask[]; scores: CoachingScore[]; sessions: CoachingSession[]; error: string | null; inputValue: string; setInputValue: (v: string) => void; agentToolCalls: Array<{ toolName: string; args?: Record; result?: string; success?: boolean }>; selectContext: (contextId: string, options?: { skipSessionResume?: boolean }) => Promise; createContext: (title: string, description?: string, category?: string, goals?: string[]) => Promise; archiveContext: (contextId: string) => Promise; startSession: (personaId?: string) => Promise; sendMessage: (content: string, options?: SendMessageOptions) => Promise; sendAudio: (audioBlob: Blob) => Promise; completeSession: () => Promise; cancelSession: () => Promise; stopTts: () => void; pauseTts: () => void; resumeTts: () => void; hasAudioToResume: () => boolean; ttsIsPlaying: boolean; ttsIsPaused: boolean; onTtsEventRef: MutableRefObject<((event: TtsEvent) => void) | null>; actionLoading: string | null; toggleTaskStatus: (taskId: string, currentStatus: string) => Promise; addTask: (title: string, description?: string) => Promise; removeTask: (taskId: string) => Promise; onDocumentCreatedRef: MutableRefObject<((doc: any) => void) | null>; refreshContexts: () => Promise; } export function useCommcoach(instanceIdOverride?: string): CommcoachHookReturn { const { request } = useApiRequest(); const routeInstanceId = useInstanceId(); const instanceId = instanceIdOverride || routeInstanceId; const [contexts, setContexts] = useState([]); const [selectedContextId, setSelectedContextId] = useState(null); const [selectedContext, setSelectedContext] = useState(null); const [loadingContexts, setLoadingContexts] = useState(false); const [session, setSession] = useState(null); const [messages, setMessages] = useState([]); const [isStreaming, setIsStreaming] = useState(false); const [streamingStatus, setStreamingStatus] = useState(null); const [streamingMessage, setStreamingMessage] = useState(null); const [tasks, setTasks] = useState([]); const [scores, setScores] = useState([]); const [sessions, setSessions] = useState([]); const [error, setError] = useState(null); const [inputValue, setInputValue] = useState(''); const [agentToolCalls, setAgentToolCalls] = useState; result?: string; success?: boolean }>>([]); const [actionLoading, setActionLoading] = useState(null); const isMountedRef = useRef(true); const abortControllerRef = useRef(null); const onTtsEventRef = useRef<((event: TtsEvent) => void) | null>(null); const onDocumentCreatedRef = useRef<((doc: any) => void) | null>(null); const ttsPlayback = useTtsPlayback({ onPlaying: () => { (window as any).__dlog?.('TTS-PLAYING'); onTtsEventRef.current?.('playing'); }, onEnded: () => { (window as any).__dlog?.('TTS-ENDED'); onTtsEventRef.current?.('ended'); }, onPaused: () => { (window as any).__dlog?.('TTS-PAUSED'); onTtsEventRef.current?.('paused'); }, onError: () => { (window as any).__dlog?.('TTS-ERROR'); onTtsEventRef.current?.('error'); }, }); useEffect(() => { isMountedRef.current = true; return () => { isMountedRef.current = false; }; }, []); const refreshContexts = useCallback(async () => { if (!instanceId) return; setLoadingContexts(true); setError(null); try { const data = await getContextsApi(request, instanceId); if (isMountedRef.current) setContexts(data); } catch (err: any) { if (isMountedRef.current) setError(err.message || 'Fehler beim Laden der Kontexte'); } finally { if (isMountedRef.current) setLoadingContexts(false); } }, [request, instanceId]); const stopTts = useCallback(() => { ttsPlayback.stop(); }, [ttsPlayback]); const pauseTts = useCallback(() => { ttsPlayback.pause(); }, [ttsPlayback]); const resumeTts = useCallback(() => { ttsPlayback.resume(); }, [ttsPlayback]); const hasAudioToResume = useCallback(() => { return ttsPlayback.isPaused; }, [ttsPlayback]); const selectContext = useCallback(async (contextId: string, options?: { skipSessionResume?: boolean }) => { if (!instanceId) return; setSelectedContextId(contextId); setError(null); try { const detail = await getContextDetailApi(request, instanceId, contextId); if (!isMountedRef.current) return; setSelectedContext(detail.context); setTasks(detail.tasks || []); 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(); setIsStreaming(true); await startSessionStreamApi( instanceId, contextId, (event: SSEEvent) => { if (!isMountedRef.current) return; const eventType = event.type; const eventData = event.data; if (eventType === 'sessionState' && eventData) { const sess = eventData.session; if (sess) setSession(sess); if (eventData.resumed && Array.isArray(eventData.messages)) { setMessages(eventData.messages); } } else if (eventType === 'ttsAudio' && eventData?.audio) { ttsPlayback.play(eventData.audio); } if (eventType === 'complete') setIsStreaming(false); }, (err) => { if (isMountedRef.current) { setError(err.message); setIsStreaming(false); } }, () => { if (isMountedRef.current) setIsStreaming(false); }, ); } else { setSession(null); setMessages([]); } } catch (err: any) { if (isMountedRef.current) setError(err.message || 'Fehler beim Laden des Kontexts'); } }, [request, instanceId, ttsPlayback.play]); const createContext = useCallback(async (title: string, description?: string, category?: string, goals?: string[]) => { if (!instanceId) return; setActionLoading('creating'); try { const created = await createContextApi(request, instanceId, { title, description, category, goals }); if (isMountedRef.current) { await refreshContexts(); setSelectedContextId(created.id); setSelectedContext(created); setTasks([]); setScores([]); setSessions([]); setSession(null); setMessages([]); } } catch (err: any) { if (isMountedRef.current) setError(err.message || 'Fehler beim Erstellen des Kontexts'); } finally { if (isMountedRef.current) setActionLoading(null); } }, [request, instanceId, refreshContexts]); const archiveContext = useCallback(async (contextId: string) => { if (!instanceId) return; setActionLoading('archiving'); try { const { archiveContextApi } = await import('../api/commcoachApi'); await archiveContextApi(request, instanceId, contextId); if (isMountedRef.current) { await refreshContexts(); if (selectedContextId === contextId) { setSelectedContextId(null); setSelectedContext(null); } } } catch (err: any) { if (isMountedRef.current) setError(err.message || 'Fehler beim Archivieren'); } finally { if (isMountedRef.current) setActionLoading(null); } }, [request, instanceId, selectedContextId, refreshContexts]); const startSessionCb = useCallback(async (personaId?: string) => { if (!instanceId || !selectedContextId) return; setActionLoading('starting'); await _unlockAudioForTts(); setError(null); setIsStreaming(true); setStreamingStatus(null); setStreamingMessage(null); setMessages([]); setSession(null); try { await startSessionStreamApi( instanceId, selectedContextId, (event: SSEEvent) => { if (!isMountedRef.current) return; const eventType = event.type; const eventData = event.data; if (eventType === 'sessionState' && eventData) { const sess = eventData.session; if (sess) { setSession(sess); } if (eventData.resumed && Array.isArray(eventData.messages)) { setMessages(eventData.messages); } } else if (eventType === 'messageChunk' && eventData) { setStreamingStatus(prev => prev || 'Coach formuliert Antwort...'); } else if (eventType === 'message' && eventData) { setStreamingMessage(null); const msg: CoachingMessage = { id: eventData.id || `msg-${Date.now()}`, sessionId: eventData.sessionId || '', contextId: eventData.contextId || '', role: eventData.role, content: eventData.content, contentType: eventData.contentType || 'text', createdAt: eventData.createdAt, }; setMessages(prev => { if (prev.some(m => m.id === msg.id)) return prev; return [...prev, msg]; }); } else if (eventType === 'ttsAudio' && eventData?.audio) { ttsPlayback.play(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'); } }, (err) => { if (isMountedRef.current) { setError(err.message); setIsStreaming(false); setStreamingMessage(null); } }, () => { if (isMountedRef.current) { setIsStreaming(false); setStreamingStatus(null); setStreamingMessage(null); } }, personaId, ); } catch (err: any) { if (isMountedRef.current) { setError(err.message || 'Fehler beim Starten der Session'); setIsStreaming(false); } } finally { if (isMountedRef.current) setActionLoading(null); } }, [instanceId, selectedContextId, ttsPlayback.play]); const sendMessage = useCallback(async (content: string, options?: SendMessageOptions) => { const normalizedContent = content.trim(); if (!normalizedContent || !instanceId || !session) return; abortControllerRef.current?.abort(); const ac = new AbortController(); abortControllerRef.current = ac; ttsPlayback.stop(); await _unlockAudioForTts(); setError(null); setIsStreaming(true); setStreamingStatus(null); setStreamingMessage(null); setAgentToolCalls([]); const tempMsg: CoachingMessage = { id: `temp-${Date.now()}`, sessionId: session.id, contextId: session.contextId, role: 'user', content: normalizedContent, contentType: 'text', createdAt: new Date().toISOString(), }; setMessages(prev => [...prev, tempMsg]); setInputValue(''); try { await sendMessageStreamApi( instanceId, session.id, normalizedContent, (event: SSEEvent) => { if (!isMountedRef.current || ac.signal.aborted) return; const eventType = event.type; const eventData = event.data; if (eventType === 'messageChunk' && eventData) { setStreamingStatus(prev => prev || 'Coach formuliert Antwort...'); } else if (eventType === 'message' && eventData) { setStreamingMessage(null); const msg: CoachingMessage = { id: eventData.id || `msg-${Date.now()}`, sessionId: session.id, contextId: session.contextId, role: eventData.role, content: eventData.content, contentType: 'text', createdAt: eventData.createdAt, }; setMessages(prev => { if (msg.role === 'user') { return prev.map(m => m.id === tempMsg.id ? msg : m); } if (prev.some(m => m.id === msg.id)) return prev; return [...prev, msg]; }); } else if (eventType === 'ttsAudio' && eventData?.audio) { setError(null); ttsPlayback.play(eventData.audio); } else if (eventType === 'status' && eventData) { setStreamingStatus(eventData.label || null); } else if (eventType === 'toolCall' && eventData) { setAgentToolCalls(prev => [...prev, { toolName: eventData.toolName, args: eventData.args }]); setStreamingStatus(`Tool: ${eventData.toolName}...`); } else if (eventType === 'toolResult' && eventData) { setAgentToolCalls(prev => prev.map((tc, idx) => idx === prev.length - 1 ? { ...tc, result: eventData.data?.slice(0, 200), success: eventData.success } : tc )); } else if (eventType === 'agentProgress' && eventData) { setStreamingStatus(`Runde ${eventData.round}/${eventData.maxRounds}...`); } 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) { setError(eventData.message || 'Stream-Fehler'); } }, (err) => { if (err.name === 'AbortError') return; if (isMountedRef.current) { setError(err.message); setIsStreaming(false); setStreamingMessage(null); } }, () => { if (isMountedRef.current) { setIsStreaming(false); setStreamingStatus(null); setStreamingMessage(null); } }, ac.signal, options, ); } catch (err: any) { if (err.name === 'AbortError') return; if (isMountedRef.current) { setError(err.message); setIsStreaming(false); } } }, [instanceId, session, ttsPlayback.play]); const sendAudio = useCallback(async (audioBlob: Blob) => { if (!instanceId || !session) return; ttsPlayback.stop(); await _unlockAudioForTts(); setError(null); setIsStreaming(true); setStreamingStatus(null); setStreamingMessage(null); try { await sendAudioStreamApi( instanceId, session.id, audioBlob, (event: SSEEvent) => { if (!isMountedRef.current) return; const eventType = event.type; const eventData = event.data; if (eventType === 'messageChunk' && eventData) { setStreamingStatus(prev => prev || 'Coach formuliert Antwort...'); } else if (eventType === 'status' && eventData) { setStreamingStatus(eventData.label || null); } else if (eventType === 'message' && eventData) { if (eventData.role === 'assistant') setError(null); const msg: CoachingMessage = { id: eventData.id || `msg-${Date.now()}`, sessionId: session.id, contextId: session.contextId, role: eventData.role, content: eventData.content, contentType: eventData.contentType || 'text', createdAt: eventData.createdAt, }; setMessages(prev => { if (prev.some(m => m.id === msg.id)) return prev; return [...prev, msg]; }); } else if (eventType === 'ttsAudio' && eventData?.audio) { setError(null); ttsPlayback.play(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'); } }, (err) => { if (isMountedRef.current) { setError(err.message); setIsStreaming(false); } }, () => { if (isMountedRef.current) { setIsStreaming(false); setStreamingStatus(null); } }, ); } catch (err: any) { if (isMountedRef.current) { setError(err.message); setIsStreaming(false); } } }, [instanceId, session]); const completeSessionCb = useCallback(async () => { if (!instanceId || !session) return; setActionLoading('completing'); try { const completed = await completeSessionApi(request, instanceId, session.id); if (isMountedRef.current) { setMessages([]); setSession(completed); if (selectedContextId) await selectContext(selectedContextId, { skipSessionResume: true }); } } catch (err: any) { if (isMountedRef.current) setError(err.message || 'Fehler beim Abschliessen'); } finally { if (isMountedRef.current) setActionLoading(null); } }, [request, instanceId, session, selectedContextId, selectContext]); const cancelSessionCb = useCallback(async () => { if (!instanceId || !session) return; setActionLoading('cancelling'); try { await cancelSessionApi(request, instanceId, session.id); if (isMountedRef.current) { setSession(null); setMessages([]); } } catch (err: any) { if (isMountedRef.current) setError(err.message || 'Fehler beim Abbrechen'); } finally { if (isMountedRef.current) setActionLoading(null); } }, [request, instanceId, session]); const toggleTaskStatus = useCallback(async (taskId: string, currentStatus: string) => { if (!instanceId) return; setActionLoading('togglingTask'); const newStatus = currentStatus === 'done' ? 'open' : 'done'; try { const updated = await updateTaskStatusApi(request, instanceId, taskId, newStatus); if (isMountedRef.current) { setTasks(prev => prev.map(t => t.id === taskId ? updated : t)); } } catch (err: any) { if (isMountedRef.current) setError(err.message); } finally { if (isMountedRef.current) setActionLoading(null); } }, [request, instanceId]); const addTask = useCallback(async (title: string, description?: string) => { if (!instanceId || !selectedContextId) return; setActionLoading('addingTask'); try { const created = await createTaskApi(request, instanceId, selectedContextId, { title, description }); if (isMountedRef.current) setTasks(prev => [created, ...prev]); } catch (err: any) { if (isMountedRef.current) setError(err.message); } finally { if (isMountedRef.current) setActionLoading(null); } }, [request, instanceId, selectedContextId]); const removeTask = useCallback(async (taskId: string) => { if (!instanceId) return; try { await deleteTaskApi(request, instanceId, taskId); if (isMountedRef.current) setTasks(prev => prev.filter(t => t.id !== taskId)); } catch (err: any) { if (isMountedRef.current) setError(err.message); } }, [request, instanceId]); useEffect(() => { if (instanceId) refreshContexts(); }, [instanceId, refreshContexts]); return { contexts, selectedContextId, selectedContext, loadingContexts, session, messages, isStreaming, streamingStatus, streamingMessage, tasks, scores, sessions, error, inputValue, setInputValue, agentToolCalls, selectContext, createContext, archiveContext, startSession: startSessionCb, sendMessage, sendAudio, completeSession: completeSessionCb, cancelSession: cancelSessionCb, stopTts, pauseTts, resumeTts, hasAudioToResume, ttsIsPlaying: ttsPlayback.isPlaying, ttsIsPaused: ttsPlayback.isPaused, onTtsEventRef, actionLoading, toggleTaskStatus, addTask, removeTask, onDocumentCreatedRef, refreshContexts, }; } async function _unlockAudioForTts(): Promise { try { const ctx = new (window.AudioContext || (window as any).webkitAudioContext)(); if (ctx.state === 'suspended') await ctx.resume(); const silent = new Audio('data:audio/wav;base64,UklGRiQAAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YQAAAAA='); silent.volume = 0; await silent.play(); } catch { // Ignore if audio unlock fails } }