ui-nyla/src/hooks/useCommcoach.ts
2026-03-06 14:43:07 +01:00

598 lines
21 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,
} 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<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) => Promise<void>;
sendAudio: (audioBlob: Blob) => Promise<void>;
completeSession: () => Promise<void>;
cancelSession: () => Promise<void>;
stopTts: () => void;
resumeTts: () => void;
hasAudioToResume: () => 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(): CommcoachHookReturn {
const { request } = useApiRequest();
const instanceId = useInstanceId();
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 [actionLoading, setActionLoading] = useState<string | null>(null);
const isMountedRef = useRef(true);
const currentAudioRef = useRef<HTMLAudioElement | null>(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<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
}
}