frontend_nyla/src/hooks/useCommcoach.ts

606 lines
22 KiB
TypeScript

/**
* 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<string, unknown>; result?: string; success?: boolean }>;
selectContext: (contextId: string, options?: { skipSessionResume?: boolean }) => Promise<void>;
createContext: (title: string, description?: string, category?: string, goals?: string[]) => Promise<void>;
archiveContext: (contextId: string) => Promise<void>;
startSession: (personaId?: string) => Promise<void>;
sendMessage: (content: string, options?: SendMessageOptions) => Promise<void>;
sendAudio: (audioBlob: Blob) => Promise<void>;
completeSession: () => Promise<void>;
cancelSession: () => Promise<void>;
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<void>;
addTask: (title: string, description?: string) => Promise<void>;
removeTask: (taskId: string) => Promise<void>;
onDocumentCreatedRef: MutableRefObject<((doc: any) => void) | null>;
refreshContexts: () => Promise<void>;
}
export function useCommcoach(instanceIdOverride?: string): CommcoachHookReturn {
const { request } = useApiRequest();
const routeInstanceId = useInstanceId();
const instanceId = instanceIdOverride || routeInstanceId;
const [contexts, setContexts] = useState<CoachingContext[]>([]);
const [selectedContextId, setSelectedContextId] = useState<string | null>(null);
const [selectedContext, setSelectedContext] = useState<CoachingContext | null>(null);
const [loadingContexts, setLoadingContexts] = useState(false);
const [session, setSession] = useState<CoachingSession | null>(null);
const [messages, setMessages] = useState<CoachingMessage[]>([]);
const [isStreaming, setIsStreaming] = useState(false);
const [streamingStatus, setStreamingStatus] = useState<string | null>(null);
const [streamingMessage, setStreamingMessage] = useState<string | null>(null);
const [tasks, setTasks] = useState<CoachingTask[]>([]);
const [scores, setScores] = useState<CoachingScore[]>([]);
const [sessions, setSessions] = useState<CoachingSession[]>([]);
const [error, setError] = useState<string | null>(null);
const [inputValue, setInputValue] = useState('');
const [agentToolCalls, setAgentToolCalls] = useState<Array<{ toolName: string; args?: Record<string, unknown>; result?: string; success?: boolean }>>([]);
const [actionLoading, setActionLoading] = useState<string | null>(null);
const isMountedRef = useRef(true);
const abortControllerRef = useRef<AbortController | null>(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<void> {
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
}
}