/** * 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, } from '../api/commcoachApi'; export type TtsEvent = 'playing' | 'ended' | 'paused' | 'error'; 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; 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) => Promise; sendAudio: (audioBlob: Blob) => Promise; completeSession: () => Promise; cancelSession: () => Promise; stopTts: () => void; resumeTts: () => void; hasAudioToResume: () => 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(): CommcoachHookReturn { const { request } = useApiRequest(); const instanceId = useInstanceId(); 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 [actionLoading, setActionLoading] = useState(null); const isMountedRef = useRef(true); const currentAudioRef = 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; }; }, []); 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 _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; } try { const audio = new Audio(`data:audio/mp3;base64,${audioB64}`); currentAudioRef.current = audio; audio.onended = () => { currentAudioRef.current = null; _emitTts('ended'); }; audio.play().then(() => { _emitTts('playing'); }).catch(() => { _emitTts('error'); }); } catch { _emitTts('error'); } }, [_emitTts]); const stopTts = useCallback(() => { if (currentAudioRef.current) { currentAudioRef.current.pause(); _emitTts('paused'); } }, [_emitTts]); const resumeTts = useCallback(() => { if (currentAudioRef.current && currentAudioRef.current.paused) { 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 }) => { 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) { _playTtsAudio(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, _playTtsAudio]); 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); 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) { setStreamingMessage(eventData.accumulated || ''); } 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) { _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'); } }, (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, _playTtsAudio]); const sendMessage = useCallback(async (content: string) => { const normalizedContent = content.trim(); if (!normalizedContent || !instanceId || !session) return; if (currentAudioRef.current) { currentAudioRef.current.pause(); currentAudioRef.current = null; } await _unlockAudioForTts(); setError(null); setIsStreaming(true); setStreamingStatus(null); 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) return; const eventType = event.type; const eventData = event.data; if (eventType === 'messageChunk' && eventData) { setStreamingMessage(eventData.accumulated || ''); } 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); _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 === 'scoreUpdate') { // Will refresh on complete } 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); } }, ); } catch (err: any) { if (isMountedRef.current) { setError(err.message); setIsStreaming(false); } } }, [instanceId, session, _playTtsAudio]); const sendAudio = useCallback(async (audioBlob: Blob) => { if (!instanceId || !session) return; if (currentAudioRef.current) { currentAudioRef.current.pause(); currentAudioRef.current = null; } await _unlockAudioForTts(); setError(null); setIsStreaming(true); setStreamingStatus(null); try { await sendAudioStreamApi( instanceId, session.id, audioBlob, (event: SSEEvent) => { if (!isMountedRef.current) return; const eventType = event.type; const eventData = event.data; 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); _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'); } }, (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) { setSession(completed); if (selectedContextId) await selectContext(selectedContextId); } } 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, selectContext, createContext, archiveContext, startSession: startSessionCb, sendMessage, sendAudio, completeSession: completeSessionCb, cancelSession: cancelSessionCb, stopTts, resumeTts, hasAudioToResume, 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 } }