598 lines
21 KiB
TypeScript
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
|
|
}
|
|
}
|