diff --git a/src/App.tsx b/src/App.tsx index d1c9c5e..87a11d2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -175,6 +175,10 @@ function App() { } /> } /> + {/* CommCoach Feature Views */} + } /> + } /> + {/* Catch-all für unbekannte Sub-Pfade */} } /> diff --git a/src/api/commcoachApi.ts b/src/api/commcoachApi.ts new file mode 100644 index 0000000..0c5246f --- /dev/null +++ b/src/api/commcoachApi.ts @@ -0,0 +1,475 @@ +import api from '../api'; +import { addCSRFTokenToHeaders, getCSRFToken, generateAndStoreCSRFToken } from '../utils/csrfUtils'; +import { ApiRequestOptions } from '../hooks/useApi'; + +// ============================================================================ +// Types +// ============================================================================ + +export interface CoachingContext { + id: string; + userId: string; + mandateId: string; + instanceId: string; + title: string; + description?: string; + category: string; + status: string; + goals?: string; + insights?: string; + sessionCount: number; + taskCount: number; + lastSessionAt?: string; + createdAt?: string; + updatedAt?: string; +} + +export interface CoachingSession { + id: string; + contextId: string; + userId: string; + status: string; + summary?: string; + durationSeconds: number; + messageCount: number; + competenceScore?: number; + emailSent: boolean; + startedAt?: string; + endedAt?: string; +} + +export interface CoachingMessage { + id: string; + sessionId: string; + contextId: string; + role: 'user' | 'assistant' | 'system'; + content: string; + contentType: string; + audioRef?: string; + createdAt?: string; +} + +export interface CoachingTask { + id: string; + contextId: string; + sessionId?: string; + title: string; + description?: string; + status: string; + priority: string; + dueDate?: string; + completedAt?: string; + createdAt?: string; +} + +export interface CoachingScore { + id: string; + contextId: string; + sessionId: string; + dimension: string; + score: number; + trend: string; + evidence?: string; + createdAt?: string; +} + +export interface CoachingUserProfile { + id: string; + userId: string; + preferredLanguage: string; + preferredVoice?: string; + dailyReminderTime?: string; + dailyReminderEnabled: boolean; + emailSummaryEnabled: boolean; + streakDays: number; + longestStreak: number; + totalSessions: number; + totalMinutes: number; + lastSessionAt?: string; +} + +export interface DashboardData { + totalContexts: number; + activeContexts: number; + totalSessions: number; + totalMinutes: number; + streakDays: number; + longestStreak: number; + averageScore?: number; + recentScores: CoachingScore[]; + openTasks: number; + completedTasks: number; + contexts: Array<{ id: string; title: string; category: string; sessionCount: number; lastSessionAt?: string }>; +} + +export interface SSEEvent { + type: string; + data?: any; + timestamp?: string; +} + +export type ApiRequestFunction = (options: ApiRequestOptions) => Promise; + +// ============================================================================ +// Context API +// ============================================================================ + +export async function getContextsApi(request: ApiRequestFunction, instanceId: string): Promise { + const data = await request({ url: `/api/commcoach/${instanceId}/contexts`, method: 'get' }); + return data.contexts || []; +} + +export async function createContextApi(request: ApiRequestFunction, instanceId: string, body: { + title: string; description?: string; category?: string; goals?: string[]; +}): Promise { + const data = await request({ url: `/api/commcoach/${instanceId}/contexts`, method: 'post', data: body }); + return data.context; +} + +export async function getContextDetailApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise<{ + context: CoachingContext; tasks: CoachingTask[]; scores: CoachingScore[]; sessions: CoachingSession[]; +}> { + const data = await request({ + url: `/api/commcoach/${instanceId}/contexts/${contextId}`, + method: 'get', + params: { _t: Date.now() }, + }); + const ctx = data?.context ?? data; + return { + context: ctx, + tasks: data?.tasks ?? [], + scores: data?.scores ?? [], + sessions: data?.sessions ?? [], + }; +} + +export async function updateContextApi(request: ApiRequestFunction, instanceId: string, contextId: string, body: any): Promise { + const data = await request({ url: `/api/commcoach/${instanceId}/contexts/${contextId}`, method: 'put', data: body }); + return data.context; +} + +export async function deleteContextApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise { + await request({ url: `/api/commcoach/${instanceId}/contexts/${contextId}`, method: 'delete' }); +} + +export async function archiveContextApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise { + const data = await request({ url: `/api/commcoach/${instanceId}/contexts/${contextId}/archive`, method: 'post' }); + return data.context; +} + +export async function activateContextApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise { + const data = await request({ url: `/api/commcoach/${instanceId}/contexts/${contextId}/activate`, method: 'post' }); + return data.context; +} + +// ============================================================================ +// Session API +// ============================================================================ + +export async function startSessionApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise<{ + session: CoachingSession; messages: CoachingMessage[]; resumed: boolean; +}> { + const data = await request({ url: `/api/commcoach/${instanceId}/contexts/${contextId}/sessions/start`, method: 'post' }); + return data; +} + +export async function startSessionStreamApi( + instanceId: string, + contextId: string, + onEvent: (event: SSEEvent) => void, + onError?: (error: Error) => void, + onComplete?: () => void, +): Promise { + try { + const baseURL = api.defaults.baseURL || ''; + const url = `${baseURL}/api/commcoach/${instanceId}/contexts/${contextId}/sessions/start`; + + const headers: Record = { 'Content-Type': 'application/json' }; + const authToken = localStorage.getItem('authToken'); + if (authToken) headers['Authorization'] = `Bearer ${authToken}`; + if (!getCSRFToken()) generateAndStoreCSRFToken(); + addCSRFTokenToHeaders(headers); + + const response = await fetch(url, { + method: 'POST', + headers, + credentials: 'include', + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`HTTP ${response.status}: ${errorText}`); + } + + if (!response.body) throw new Error('Response body is null'); + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (line.startsWith('data: ')) { + try { + const jsonStr = line.slice(6); + if (jsonStr.trim()) { + const event: SSEEvent = JSON.parse(jsonStr); + onEvent(event); + } + } catch { + // skip malformed lines + } + } + } + } + onComplete?.(); + } finally { + reader.releaseLock(); + } + } catch (error: any) { + if (onError) onError(error instanceof Error ? error : new Error(String(error))); + else throw error; + } +} + +export async function getSessionApi(request: ApiRequestFunction, instanceId: string, sessionId: string): Promise<{ + session: CoachingSession; messages: CoachingMessage[]; +}> { + const data = await request({ url: `/api/commcoach/${instanceId}/sessions/${sessionId}`, method: 'get' }); + return data; +} + +export async function completeSessionApi(request: ApiRequestFunction, instanceId: string, sessionId: string): Promise { + const data = await request({ url: `/api/commcoach/${instanceId}/sessions/${sessionId}/complete`, method: 'post' }); + return data.session; +} + +export async function cancelSessionApi(request: ApiRequestFunction, instanceId: string, sessionId: string): Promise { + await request({ url: `/api/commcoach/${instanceId}/sessions/${sessionId}/cancel`, method: 'post' }); +} + +// ============================================================================ +// Streaming Chat API +// ============================================================================ + +export async function sendMessageStreamApi( + instanceId: string, + sessionId: string, + content: string, + onEvent: (event: SSEEvent) => void, + onError?: (error: Error) => void, + onComplete?: () => void, +): Promise { + try { + const baseURL = api.defaults.baseURL || ''; + const url = `${baseURL}/api/commcoach/${instanceId}/sessions/${sessionId}/message/stream`; + + const headers: Record = { 'Content-Type': 'application/json' }; + const authToken = localStorage.getItem('authToken'); + if (authToken) headers['Authorization'] = `Bearer ${authToken}`; + if (!getCSRFToken()) generateAndStoreCSRFToken(); + addCSRFTokenToHeaders(headers); + + const response = await fetch(url, { + method: 'POST', + headers, + body: JSON.stringify({ content }), + credentials: 'include', + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`HTTP ${response.status}: ${errorText}`); + } + + if (!response.body) throw new Error('Response body is null'); + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (line.startsWith('data: ')) { + try { + const jsonStr = line.slice(6); + if (jsonStr.trim()) { + const event: SSEEvent = JSON.parse(jsonStr); + onEvent(event); + } + } catch { + // skip malformed lines + } + } + } + } + onComplete?.(); + } finally { + reader.releaseLock(); + } + } catch (error: any) { + if (onError) onError(error instanceof Error ? error : new Error(String(error))); + else throw error; + } +} + +export async function sendAudioStreamApi( + instanceId: string, + sessionId: string, + audioBlob: Blob, + onEvent: (event: SSEEvent) => void, + onError?: (error: Error) => void, + onComplete?: () => void, +): Promise { + try { + const baseURL = api.defaults.baseURL || ''; + const url = `${baseURL}/api/commcoach/${instanceId}/sessions/${sessionId}/audio/stream`; + + const headers: Record = { 'Content-Type': 'application/octet-stream' }; + const authToken = localStorage.getItem('authToken'); + if (authToken) headers['Authorization'] = `Bearer ${authToken}`; + const pathMatch = window.location.pathname.match(/^\/mandates\/([^/]+)\/([^/]+)\/([^/]+)/); + if (pathMatch) { + headers['X-Mandate-Id'] = pathMatch[1]; + headers['X-Instance-Id'] = pathMatch[3]; + } + if (!getCSRFToken()) generateAndStoreCSRFToken(); + addCSRFTokenToHeaders(headers); + + const audioBuffer = await audioBlob.arrayBuffer(); + + const response = await fetch(url, { + method: 'POST', + headers, + body: audioBuffer, + credentials: 'include', + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`HTTP ${response.status}: ${errorText}`); + } + + if (!response.body) throw new Error('Response body is null'); + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (line.startsWith('data: ')) { + try { + const jsonStr = line.slice(6); + if (jsonStr.trim()) onEvent(JSON.parse(jsonStr)); + } catch { /* skip */ } + } + } + } + onComplete?.(); + } finally { + reader.releaseLock(); + } + } catch (error: any) { + if (onError) onError(error instanceof Error ? error : new Error(String(error))); + else throw error; + } +} + +// ============================================================================ +// Task API +// ============================================================================ + +export async function getTasksApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise { + const data = await request({ url: `/api/commcoach/${instanceId}/contexts/${contextId}/tasks`, method: 'get' }); + return data.tasks || []; +} + +export async function createTaskApi(request: ApiRequestFunction, instanceId: string, contextId: string, body: { + title: string; description?: string; priority?: string; dueDate?: string; +}): Promise { + const data = await request({ url: `/api/commcoach/${instanceId}/contexts/${contextId}/tasks`, method: 'post', data: body }); + return data.task; +} + +export async function updateTaskApi(request: ApiRequestFunction, instanceId: string, taskId: string, body: any): Promise { + const data = await request({ url: `/api/commcoach/${instanceId}/tasks/${taskId}`, method: 'put', data: body }); + return data.task; +} + +export async function updateTaskStatusApi(request: ApiRequestFunction, instanceId: string, taskId: string, status: string): Promise { + const data = await request({ url: `/api/commcoach/${instanceId}/tasks/${taskId}/status`, method: 'put', data: { status } }); + return data.task; +} + +export async function deleteTaskApi(request: ApiRequestFunction, instanceId: string, taskId: string): Promise { + await request({ url: `/api/commcoach/${instanceId}/tasks/${taskId}`, method: 'delete' }); +} + +// ============================================================================ +// Dashboard API +// ============================================================================ + +export async function getDashboardApi(request: ApiRequestFunction, instanceId: string): Promise { + const data = await request({ url: `/api/commcoach/${instanceId}/dashboard`, method: 'get' }); + return data.dashboard; +} + +// ============================================================================ +// Profile API +// ============================================================================ + +export async function getProfileApi(request: ApiRequestFunction, instanceId: string): Promise { + const data = await request({ url: `/api/commcoach/${instanceId}/profile`, method: 'get' }); + return data.profile; +} + +export async function updateProfileApi(request: ApiRequestFunction, instanceId: string, body: any): Promise { + const data = await request({ url: `/api/commcoach/${instanceId}/profile`, method: 'put', data: body }); + return data.profile; +} + +// ============================================================================ +// Voice API +// ============================================================================ + +export async function getVoiceLanguagesApi(request: ApiRequestFunction, instanceId: string): Promise { + const data = await request({ url: `/api/commcoach/${instanceId}/voice/languages`, method: 'get' }); + return data.languages || []; +} + +export async function getVoiceVoicesApi(request: ApiRequestFunction, instanceId: string, language: string = 'de-DE'): Promise { + const data = await request({ url: `/api/commcoach/${instanceId}/voice/voices`, method: 'get', params: { language } }); + return data.voices || []; +} + +export async function testVoiceApi(request: ApiRequestFunction, instanceId: string, body: { + text?: string; language?: string; voiceId?: string; +}): Promise<{ success: boolean; audio?: string; format?: string; text?: string }> { + const data = await request({ url: `/api/commcoach/${instanceId}/voice/tts`, method: 'post', data: body }); + return data; +} diff --git a/src/config/pageRegistry.tsx b/src/config/pageRegistry.tsx index 0c168ee..902c588 100644 --- a/src/config/pageRegistry.tsx +++ b/src/config/pageRegistry.tsx @@ -21,7 +21,7 @@ import { FaLightbulb, FaRegFileAlt, FaLink, FaComments, FaListAlt, FaCogs, FaChartLine, FaFileAlt, FaUserShield, FaDatabase, FaProjectDiagram, FaMapMarkedAlt, FaWallet, FaMoneyBillAlt, FaClock, - FaHeadset, FaVideo, FaHatWizard, FaStore, + FaHeadset, FaVideo, FaHatWizard, FaStore, FaUserTie, FaClipboardList, } from 'react-icons/fa'; // ============================================================================= @@ -98,7 +98,14 @@ export const PAGE_ICONS: Record = { 'page.feature.neutralization.config': , 'page.feature.neutralization.attributes': , + // Feature pages - CommCoach + 'page.feature.commcoach.dashboard': , + 'page.feature.commcoach.coaching': , + 'page.feature.commcoach.dossier': , + 'page.feature.commcoach.settings': , + // Feature icons (for feature grouping in navigation) + 'feature.commcoach': , 'feature.neutralization': , 'feature.trustee': , 'feature.realestate': , diff --git a/src/hooks/useCommcoach.ts b/src/hooks/useCommcoach.ts new file mode 100644 index 0000000..d25fc73 --- /dev/null +++ b/src/hooks/useCommcoach.ts @@ -0,0 +1,559 @@ +/** + * 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, getSessionApi, completeSessionApi, cancelSessionApi, + sendMessageStreamApi, sendAudioStreamApi, + getTasksApi, createTaskApi, updateTaskStatusApi, deleteTaskApi, + getProfileApi, testVoiceApi, + type CoachingContext, type CoachingSession, type CoachingMessage, + type CoachingTask, type CoachingScore, type SSEEvent, +} from '../api/commcoachApi'; + +export interface CommcoachHookReturn { + contexts: CoachingContext[]; + selectedContextId: string | null; + selectedContext: CoachingContext | null; + loadingContexts: boolean; + + session: CoachingSession | null; + messages: CoachingMessage[]; + isStreaming: boolean; + streamingStatus: string | null; + + tasks: CoachingTask[]; + scores: CoachingScore[]; + sessions: CoachingSession[]; + + error: string | null; + inputValue: string; + setInputValue: (v: string) => void; + + selectContext: (contextId: string) => Promise; + createContext: (title: string, description?: string, category?: string, goals?: string[]) => Promise; + archiveContext: (contextId: string) => Promise; + + startSession: () => Promise; + sendMessage: (content: string) => Promise; + sendAudio: (audioBlob: Blob) => Promise; + completeSession: () => Promise; + cancelSession: () => Promise; + + isMuted: boolean; + setMuted: (muted: boolean) => void; + stopTts: () => void; + isTtsPlayingRef: MutableRefObject; + + toggleTaskStatus: (taskId: string, currentStatus: string) => Promise; + addTask: (title: string, description?: string) => Promise; + removeTask: (taskId: string) => Promise; + + 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 [tasks, setTasks] = useState([]); + const [scores, setScores] = useState([]); + const [sessions, setSessions] = useState([]); + + const [error, setError] = useState(null); + const [inputValue, setInputValue] = useState(''); + + const [isMuted, setIsMuted] = useState(false); + + const isMountedRef = useRef(true); + const currentAudioRef = useRef(null); + const isTtsPlayingRef = useRef(false); + const profileRef = useRef<{ preferredLanguage?: string; preferredVoice?: string } | null>(null); + + useEffect(() => { isMountedRef.current = true; return () => { isMountedRef.current = false; }; }, []); + + const _speakText = useCallback(async (text: string) => { + if (!instanceId) return; + const plain = _stripMarkdownForTts(text); + if (!plain.trim()) return; + if (currentAudioRef.current) { + currentAudioRef.current.pause(); + currentAudioRef.current = null; + } + try { + let profile = profileRef.current; + if (!profile) { + const p = await getProfileApi(request, instanceId); + profile = { preferredLanguage: p?.preferredLanguage || 'de-DE', preferredVoice: p?.preferredVoice }; + profileRef.current = profile; + } + const lang = profile?.preferredLanguage || 'de-DE'; + const voiceId = profile?.preferredVoice || undefined; + const result = await testVoiceApi(request, instanceId, { text: plain, language: lang, voiceId }); + if (result?.success && result?.audio && isMountedRef.current) { + const audio = new Audio(`data:audio/mp3;base64,${result.audio}`); + currentAudioRef.current = audio; + audio.onended = () => { currentAudioRef.current = null; }; + try { + await audio.play(); + } catch (playErr: any) { + if (playErr?.name === 'NotAllowedError') { + console.warn('CommCoach TTS: Browser blocked audio. Click "Session starten" or "Senden" first.'); + } + } + } + } catch { + // TTS failed silently, text is still visible + } + }, [request, instanceId]); + + 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 _playTtsAudio = useCallback((audioB64: string) => { + if (!audioB64 || !isMountedRef.current) return; + if (currentAudioRef.current) { + currentAudioRef.current.pause(); + currentAudioRef.current = null; + } + isTtsPlayingRef.current = true; + try { + const audio = new Audio(`data:audio/mp3;base64,${audioB64}`); + currentAudioRef.current = audio; + audio.onended = () => { + currentAudioRef.current = null; + isTtsPlayingRef.current = false; + }; + audio.play().catch(() => { isTtsPlayingRef.current = false; }); + } catch { + isTtsPlayingRef.current = false; + } + }, []); + + const stopTts = useCallback(() => { + if (currentAudioRef.current) { + currentAudioRef.current.pause(); + currentAudioRef.current = null; + } + isTtsPlayingRef.current = false; + }, []); + + const selectContext = useCallback(async (contextId: string) => { + 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 || []); + + 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; + 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'); + } + }, [request, instanceId, refreshContexts]); + + const archiveContext = useCallback(async (contextId: string) => { + if (!instanceId) return; + 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'); + } + }, [request, instanceId, selectedContextId, refreshContexts]); + + const startSessionCb = useCallback(async () => { + if (!instanceId || !selectedContextId) return; + await _unlockAudioForTts(); + setError(null); + setIsMuted(false); + 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); + setIsMuted(false); + } + if (eventData.resumed && Array.isArray(eventData.messages)) { + setMessages(eventData.messages); + } + } else if (eventType === 'message' && eventData) { + 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 === 'error' && eventData) { + setError(eventData.message || 'Stream-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 || 'Fehler beim Starten der Session'); + setIsStreaming(false); + } + } + }, [instanceId, selectedContextId, _playTtsAudio]); + + const sendMessage = useCallback(async (content: string) => { + if (!content.trim() || isStreaming || !instanceId || !session) return; + await _unlockAudioForTts(); + setError(null); + setIsStreaming(true); + setStreamingStatus(null); + + const tempMsg: CoachingMessage = { + id: `temp-${Date.now()}`, + sessionId: session.id, + contextId: session.contextId, + role: 'user', + content: content.trim(), + contentType: 'text', + createdAt: new Date().toISOString(), + }; + setMessages(prev => [...prev, tempMsg]); + setInputValue(''); + + try { + await sendMessageStreamApi( + instanceId, + session.id, + content, + (event: SSEEvent) => { + if (!isMountedRef.current) return; + const eventType = event.type; + const eventData = event.data; + + if (eventType === 'message' && eventData) { + 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 === 'scoreUpdate') { + // Will refresh on complete + } else if (eventType === 'error' && eventData) { + setError(eventData.message || 'Stream-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); + } + } + }, [isStreaming, instanceId, session, _playTtsAudio]); + + const sendAudio = useCallback(async (audioBlob: Blob) => { + if (!instanceId || !session) return; + stopTts(); + 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 === '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, stopTts]); + + const completeSessionCb = useCallback(async () => { + if (!instanceId || !session) return; + 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'); + } + }, [request, instanceId, session, selectedContextId, selectContext]); + + const cancelSessionCb = useCallback(async () => { + if (!instanceId || !session) return; + 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'); + } + }, [request, instanceId, session]); + + const toggleTaskStatus = useCallback(async (taskId: string, currentStatus: string) => { + if (!instanceId) return; + 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); + } + }, [request, instanceId]); + + const addTask = useCallback(async (title: string, description?: string) => { + if (!instanceId || !selectedContextId) return; + 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); + } + }, [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]); + + useEffect(() => { profileRef.current = null; }, [instanceId]); + + return { + contexts, selectedContextId, selectedContext, loadingContexts, + session, messages, isStreaming, streamingStatus, + tasks, scores, sessions, + error, inputValue, setInputValue, + selectContext, createContext, archiveContext, + startSession: startSessionCb, + sendMessage, sendAudio, completeSession: completeSessionCb, cancelSession: cancelSessionCb, + isMuted, setMuted: setIsMuted, stopTts, isTtsPlayingRef, + toggleTaskStatus, addTask, removeTask, + refreshContexts, + }; +} + +function _stripMarkdownForTts(text: string): string { + return text + .replace(/\*\*(.+?)\*\*/g, '$1') + .replace(/\*(.+?)\*/g, '$1') + .replace(/\[(.+?)\]\(.+?\)/g, '$1') + .replace(/^#+\s*/gm, '') + .replace(/`[^`]+`/g, (m) => m.slice(1, -1)) + .trim(); +} + +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 + } +} diff --git a/src/hooks/useCommcoachDashboard.ts b/src/hooks/useCommcoachDashboard.ts new file mode 100644 index 0000000..6a0228a --- /dev/null +++ b/src/hooks/useCommcoachDashboard.ts @@ -0,0 +1,55 @@ +/** + * useCommcoachDashboard Hook + * + * Loads and manages dashboard data for the CommCoach feature. + */ + +import { useState, useEffect, useCallback, useRef } from 'react'; +import { useApiRequest } from './useApi'; +import { useInstanceId } from './useCurrentInstance'; +import { getDashboardApi, getProfileApi, type DashboardData, type CoachingUserProfile } from '../api/commcoachApi'; + +export interface CommcoachDashboardReturn { + dashboard: DashboardData | null; + profile: CoachingUserProfile | null; + loading: boolean; + error: string | null; + refresh: () => Promise; +} + +export function useCommcoachDashboard(): CommcoachDashboardReturn { + const { request } = useApiRequest(); + const instanceId = useInstanceId(); + + const [dashboard, setDashboard] = useState(null); + const [profile, setProfile] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const isMountedRef = useRef(true); + useEffect(() => { isMountedRef.current = true; return () => { isMountedRef.current = false; }; }, []); + + const refresh = useCallback(async () => { + if (!instanceId) return; + setLoading(true); + setError(null); + try { + const [dashData, profileData] = await Promise.all([ + getDashboardApi(request, instanceId), + getProfileApi(request, instanceId), + ]); + if (isMountedRef.current) { + setDashboard(dashData); + setProfile(profileData); + } + } catch (err: any) { + if (isMountedRef.current) setError(err.message || 'Fehler beim Laden des Dashboards'); + } finally { + if (isMountedRef.current) setLoading(false); + } + }, [request, instanceId]); + + useEffect(() => { if (instanceId) refresh(); }, [instanceId, refresh]); + + return { dashboard, profile, loading, error, refresh }; +} diff --git a/src/pages/FeatureView.tsx b/src/pages/FeatureView.tsx index 5c906b7..c3251dd 100644 --- a/src/pages/FeatureView.tsx +++ b/src/pages/FeatureView.tsx @@ -8,6 +8,7 @@ import React from 'react'; import { useCurrentInstance } from '../hooks/useCurrentInstance'; import { useCanViewFeatureView } from '../hooks/useInstancePermissions'; +import { useLanguage } from '../providers/language/LanguageContext'; import { getLabel, FEATURE_REGISTRY } from '../types/mandate'; // Trustee Views @@ -43,6 +44,9 @@ import { TeamsbotSettingsView } from './views/teamsbot/TeamsbotSettingsView'; // Neutralization Views import { NeutralizationView } from './views/neutralization'; +// CommCoach Views +import { CommcoachDashboardView, CommcoachCoachingView, CommcoachDossierView, CommcoachSettingsView } from './views/commcoach'; + import styles from './FeatureView.module.css'; // ============================================================================= @@ -142,6 +146,12 @@ const VIEW_COMPONENTS: Record> = { dashboard: NeutralizationView, playground: NeutralizationView, }, + commcoach: { + dashboard: CommcoachDashboardView, + coaching: CommcoachCoachingView, + dossier: CommcoachDossierView, + settings: CommcoachSettingsView, + }, }; // ============================================================================= @@ -154,6 +164,7 @@ interface FeatureViewPageProps { export const FeatureViewPage: React.FC = ({ view }) => { const { instance, featureCode, isValid } = useCurrentInstance(); + const { currentLanguage } = useLanguage(); // Berechtigungs-Check const viewCode = `${featureCode}-${view}`; @@ -202,7 +213,8 @@ export const FeatureViewPage: React.FC = ({ view }) => { // View-Info aus Registry const featureConfig = FEATURE_REGISTRY[featureCode]; const viewConfig = featureConfig?.views?.find(v => v.code === view); - const viewLabel = viewConfig ? getLabel(viewConfig.label) : view; + const lang = (currentLanguage?.slice(0, 2) || 'de') as 'de' | 'en' | 'fr'; + const viewLabel = viewConfig ? getLabel(viewConfig.label, lang) : view; return (
diff --git a/src/pages/views/commcoach/CommcoachCoachingView.module.css b/src/pages/views/commcoach/CommcoachCoachingView.module.css new file mode 100644 index 0000000..b898159 --- /dev/null +++ b/src/pages/views/commcoach/CommcoachCoachingView.module.css @@ -0,0 +1,402 @@ +.coaching { + display: flex; + flex-direction: column; + height: calc(100vh - 140px); + overflow: hidden; +} + +/* Context Tabs */ +.contextBar { + border-bottom: 1px solid var(--border-color, #e0e0e0); + padding: 0.5rem 1rem; + flex-shrink: 0; +} + +.contextTabs { + display: flex; + gap: 0.5rem; + overflow-x: auto; + align-items: center; +} + +.contextTab { + display: flex; + align-items: center; + gap: 0.35rem; + padding: 0.4rem 0.75rem; + border: 1px solid var(--border-color, #e0e0e0); + border-radius: 20px; + background: var(--bg-card, #fff); + cursor: pointer; + font-size: 0.8rem; + white-space: nowrap; + transition: all 0.15s; + color: var(--text-primary, #333); +} + +.contextTab:hover { + background: var(--bg-hover, #f5f5f5); +} + +.contextTabActive { + background: var(--primary-color, #F25843); + color: #fff; + border-color: var(--primary-color, #F25843); +} + +.contextTabIcon { + font-weight: 700; + font-size: 0.75rem; +} + +.contextTabLabel { + max-width: 120px; + overflow: hidden; + text-overflow: ellipsis; +} + +.contextTabNew { + width: 32px; + height: 32px; + border: 1px dashed var(--border-color, #ccc); + border-radius: 50%; + background: transparent; + cursor: pointer; + font-size: 1.2rem; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-secondary, #888); + flex-shrink: 0; +} + +.contextTabNew:hover { + background: var(--bg-hover, #f5f5f5); + color: var(--primary-color, #F25843); +} + +/* New Context Form */ +.newContextForm { + padding: 1rem; + background: var(--bg-card, #fff); + border-bottom: 1px solid var(--border-color, #e0e0e0); + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.newContextInput, +.newContextSelect { + padding: 0.5rem 0.75rem; + border: 1px solid var(--border-color, #ddd); + border-radius: 6px; + font-size: 0.9rem; + background: var(--bg-input, #fff); + color: var(--text-primary, #333); +} + +.newContextActions { + display: flex; + gap: 0.5rem; +} + +/* Buttons */ +.btnPrimary { + padding: 0.5rem 1.25rem; + background: var(--primary-color, #F25843); + color: #fff; + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 0.85rem; + font-weight: 500; +} + +.btnPrimary:hover:not(:disabled) { filter: brightness(1.08); } +.btnPrimary:disabled { + background: var(--color-medium-gray, #ccc); + color: var(--text-secondary, #888); + cursor: not-allowed; + opacity: 0.8; +} + +.btnSecondary { + padding: 0.5rem 1.25rem; + background: transparent; + color: var(--text-primary, #333); + border: 1px solid var(--border-color, #ddd); + border-radius: 6px; + cursor: pointer; + font-size: 0.85rem; +} + +.btnSecondary:hover:not(:disabled) { + background: var(--hover-bg, #f5f5f5); + border-color: var(--primary-color, #F25843); + color: var(--primary-color, #F25843); +} + +.btnSmall { + padding: 0.3rem 0.75rem; + background: var(--primary-color, #F25843); + color: #fff; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 0.8rem; +} + +.btnSmall:hover:not(:disabled) { filter: brightness(1.08); } + +.btnSmallDanger { + padding: 0.3rem 0.75rem; + background: transparent; + color: var(--error-color, #dc2626); + border: 1px solid var(--error-color, #dc2626); + border-radius: 4px; + cursor: pointer; + font-size: 0.8rem; +} + +.btnSmallDanger:hover:not(:disabled) { + background: var(--error-color, #dc2626); + color: #fff; +} + +/* No context */ +.noContext { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + flex: 1; + text-align: center; + padding: 2rem; + color: var(--text-secondary, #666); +} + +.noContext h3 { + color: var(--text-primary, #333); + margin-bottom: 0.5rem; +} + +.noContext p { + margin-bottom: 1rem; +} + +/* Chat Area */ +.chatArea { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.sessionStart { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + flex: 1; + text-align: center; + padding: 2rem; +} + +.sessionStart h3 { + color: var(--text-primary, #333); + margin-bottom: 0.5rem; +} + +.sessionStart p { + color: var(--text-secondary, #666); + margin-bottom: 1rem; +} + +.sessionHeader { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem 1rem; + background: var(--bg-card, #fff); + border-bottom: 1px solid var(--border-color, #e0e0e0); + flex-shrink: 0; +} + +.sessionLabel { + font-size: 0.85rem; + font-weight: 500; + color: var(--text-primary, #333); +} + +.sessionActions { + display: flex; + gap: 0.5rem; +} + +/* Messages */ +.messages { + flex: 1; + padding: 1rem; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.message { + max-width: 80%; +} + +.messageUser { + align-self: flex-end; +} + +.messageAssistant { + align-self: flex-start; +} + +.messageBubble { + padding: 0.75rem 1rem; + border-radius: 12px; + font-size: 0.9rem; + line-height: 1.5; +} + +.messageUser .messageBubble { + background: var(--primary-color, #F25843); + color: #fff; + border-bottom-right-radius: 4px; +} + +.messageLive { + opacity: 0.7; + font-style: italic; + border: 1px dashed rgba(255, 255, 255, 0.4); +} + +.messageAssistant .messageBubble { + background: var(--bg-card, #f5f5f5); + color: var(--text-primary, #333); + border: 1px solid var(--border-color, #e0e0e0); + border-bottom-left-radius: 4px; +} + +.messageBubble p { + margin: 0 0 0.5rem; +} + +.messageBubble p:last-child { + margin-bottom: 0; +} + +.messageTime { + font-size: 0.7rem; + color: var(--text-secondary, #999); + margin-top: 0.2rem; + padding: 0 0.25rem; +} + +.messageUser .messageTime { + text-align: right; +} + +.typing { + color: var(--text-secondary, #888); + font-style: italic; +} + +.typingDots { + animation: blink 1.4s infinite both; +} + +@keyframes blink { + 0%, 80%, 100% { opacity: 0; } + 40% { opacity: 1; } +} + +/* Input */ +.inputArea { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.75rem 1rem; + border-top: 1px solid var(--border-color, #e0e0e0); + background: var(--bg-card, #fff); + flex-shrink: 0; +} + +.textInputRow { + display: flex; + gap: 0.5rem; + align-items: flex-end; +} + +.textInput { + flex: 1; + min-width: 0; + padding: 0.6rem 0.75rem; + border: 1px solid var(--border-color, #ddd); + border-radius: 8px; + resize: none; + font-size: 0.9rem; + font-family: inherit; + min-height: 40px; + max-height: 120px; + background: var(--bg-input, #fff); + color: var(--text-primary, #333); +} + +.sendBtn { + padding: 0.6rem 1.25rem; + background: var(--primary-color, #F25843); + color: #fff; + border: none; + border-radius: 8px; + cursor: pointer; + font-size: 0.85rem; + font-weight: 500; + align-self: flex-end; +} + +.sendBtn:hover:not(:disabled) { filter: brightness(1.08); } +.sendBtn:disabled { + background: var(--color-medium-gray, #ccc); + color: var(--text-secondary, #888); + cursor: not-allowed; + opacity: 0.8; +} + +.voiceStatus { + display: flex; + align-items: center; + padding: 0.25rem 0; + min-height: 1.5rem; +} + +.voiceIndicator { + font-size: 0.9rem; + color: var(--text-secondary, #888); +} + +.voiceIndicator.voiceActive { + color: var(--primary-color, #F25843); + font-weight: 500; +} + +.voiceActive { + border: 2px solid #22c55e; +} + +.mutedActive { + background: var(--color-medium-gray, #999); + color: #fff; + border-color: var(--color-medium-gray, #999); +} + +.errorBanner { + padding: 0.5rem 1rem; + background: #fde8e8; + color: var(--color-error, #d32f2f); + font-size: 0.85rem; + text-align: center; +} diff --git a/src/pages/views/commcoach/CommcoachCoachingView.tsx b/src/pages/views/commcoach/CommcoachCoachingView.tsx new file mode 100644 index 0000000..38bd1a3 --- /dev/null +++ b/src/pages/views/commcoach/CommcoachCoachingView.tsx @@ -0,0 +1,411 @@ +/** + * CommCoach Coaching View + * + * Voice first, always with text fallback (CONCEPT.md). + * Chat und Voice parallel: Mikrofon und Texteingabe gleichzeitig nutzbar. + * Mute: nur Mikrofon stummschalten, kein Moduswechsel. + */ + +import React, { useState, useRef, useEffect, useCallback } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { useCommcoach } from '../../../hooks/useCommcoach'; +import AutoScroll from '../../../components/UiComponents/AutoScroll/AutoScroll'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import styles from './CommcoachCoachingView.module.css'; + +export const CommcoachCoachingView: React.FC = () => { + const [searchParams, setSearchParams] = useSearchParams(); + const coach = useCommcoach(); + const [showNewContext, setShowNewContext] = useState(false); + const [newTitle, setNewTitle] = useState(''); + const [newDescription, setNewDescription] = useState(''); + const [newCategory, setNewCategory] = useState('custom'); + const inputRef = useRef(null); + + const streamRef = useRef(null); + const speechRecognitionRef = useRef(null); + const transcriptPartsRef = useRef([]); + const [isListening, setIsListening] = useState(false); + const [isUserSpeaking, setIsUserSpeaking] = useState(false); + const [liveTranscript, setLiveTranscript] = useState(''); + + const handleSend = useCallback(async () => { + if (!coach.inputValue.trim() || coach.isStreaming) return; + await coach.sendMessage(coach.inputValue); + }, [coach]); + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }, [handleSend]); + + const handleCreateContext = useCallback(async () => { + if (!newTitle.trim()) return; + await coach.createContext(newTitle, newDescription || undefined, newCategory); + setNewTitle(''); + setNewDescription(''); + setNewCategory('custom'); + setShowNewContext(false); + }, [newTitle, newDescription, newCategory, coach]); + + useEffect(() => { + const contextId = searchParams.get('context'); + if (contextId && coach.contexts.some(c => c.id === contextId)) { + coach.selectContext(contextId); + setSearchParams({}, { replace: true }); + } + }, [searchParams, coach.contexts, coach.selectContext, setSearchParams]); + + useEffect(() => { + if (coach.session && inputRef.current) { + inputRef.current.focus(); + } + }, [coach.session]); + + useEffect(() => { + if (!coach.session) { + coach.setMuted(false); + } + }, [coach.session]); + + useEffect(() => { + if (!coach.session || coach.isMuted) { + if (speechRecognitionRef.current) { + try { + speechRecognitionRef.current.stop(); + } catch { + // ignore + } + speechRecognitionRef.current = null; + } + if (streamRef.current) { + streamRef.current.getTracks().forEach((t) => t.stop()); + streamRef.current = null; + } + setIsListening(false); + setIsUserSpeaking(false); + return; + } + + const SpeechRecognitionApi = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition; + if (!SpeechRecognitionApi) { + console.warn('SpeechRecognition not supported'); + return; + } + + let cancelled = false; + const MIN_WORDS_TO_INTERRUPT = 2; + const lang = 'de-DE'; + + const init = async () => { + try { + const stream = await navigator.mediaDevices.getUserMedia({ + audio: { echoCancellation: true, noiseSuppression: true }, + }); + if (cancelled) { + stream.getTracks().forEach((t) => t.stop()); + return; + } + streamRef.current = stream; + setIsListening(true); + + const recognition = new SpeechRecognitionApi(); + recognition.continuous = true; + recognition.interimResults = true; + recognition.lang = lang; + + recognition.onstart = () => { + if (cancelled) return; + }; + + recognition.onspeechstart = () => { + if (cancelled) return; + setIsUserSpeaking(true); + transcriptPartsRef.current = []; + setLiveTranscript(''); + }; + + recognition.onresult = (event: SpeechRecognitionEvent) => { + if (cancelled) return; + const finalized: string[] = []; + let currentInterim = ''; + for (let i = 0; i < event.results.length; i++) { + const r = event.results[i]; + if (r.isFinal) { + finalized.push(r[0].transcript.trim()); + } else { + currentInterim = r[0].transcript.trim(); + } + } + transcriptPartsRef.current = finalized.filter(Boolean); + const preview = [...transcriptPartsRef.current, currentInterim].join(' ').trim(); + setLiveTranscript(preview); + const totalWords = preview.split(/\s+/).filter(Boolean).length; + if (totalWords >= MIN_WORDS_TO_INTERRUPT) coach.stopTts(); + }; + + recognition.onspeechend = () => { + if (cancelled) return; + const fullTranscript = transcriptPartsRef.current.join(' ').trim(); + if (fullTranscript) { + const wordCount = fullTranscript.split(/\s+/).filter(Boolean).length; + if (wordCount >= MIN_WORDS_TO_INTERRUPT) coach.sendMessage(fullTranscript); + } + transcriptPartsRef.current = []; + setLiveTranscript(''); + setIsUserSpeaking(false); + }; + + recognition.onend = () => { + if (cancelled) return; + if (speechRecognitionRef.current === recognition) { + speechRecognitionRef.current = null; + setIsUserSpeaking(false); + } + }; + + recognition.onerror = (event: any) => { + if (event.error === 'no-speech' || event.error === 'aborted') return; + console.warn('SpeechRecognition error:', event.error); + }; + + speechRecognitionRef.current = recognition; + recognition.start(); + } catch (err) { + console.warn('Mic access failed:', err); + } + }; + + init(); + return () => { + cancelled = true; + if (speechRecognitionRef.current) { + try { + speechRecognitionRef.current.stop(); + } catch { + // ignore + } + speechRecognitionRef.current = null; + } + if (streamRef.current) { + streamRef.current.getTracks().forEach((t) => t.stop()); + streamRef.current = null; + } + }; + }, [coach.session, coach.isMuted, coach.stopTts, coach.sendMessage]); + + return ( +
+ {/* Context Tabs */} +
+
+ {coach.contexts.map(ctx => ( + + ))} + +
+
+ + {/* New Context Form */} + {showNewContext && ( +
+ setNewTitle(e.target.value)} + onKeyDown={e => e.key === 'Enter' && handleCreateContext()} + autoFocus + /> + setNewDescription(e.target.value)} + /> + +
+ + +
+
+ )} + + {/* No Context Selected */} + {!coach.selectedContextId && !showNewContext && ( +
+

Willkommen beim Kommunikations-Coach

+

Waehle ein bestehendes Thema oder erstelle ein neues, um zu beginnen.

+ +
+ )} + + {/* Chat Area */} + {coach.selectedContextId && ( +
+ {/* Session controls */} + {!coach.session && ( +
+

{coach.selectedContext?.title}

+

{coach.selectedContext?.description || 'Starte eine neue Coaching-Session zu diesem Thema.'}

+ +
+ )} + + {/* Messages */} + {coach.session && ( + <> +
+ + Session aktiv - {coach.selectedContext?.title} + +
+ + + +
+
+ + +
+ {coach.messages.map(msg => ( +
+
+ + {msg.content} + +
+
+ {msg.createdAt ? new Date(msg.createdAt).toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' }) : ''} +
+
+ ))} + {liveTranscript && ( +
+
+ {liveTranscript} +
+
+ )} + {coach.isStreaming && ( +
+
+
+ {coach.streamingStatus || 'Coach denkt nach'} + ... +
+
+
+ )} +
+
+ + {/* Input: Chat und Voice parallel (CONCEPT: Voice first, always with text fallback) */} +
+
+ + {coach.isMuted + ? 'Stumm – Mikrofon aus' + : coach.isStreaming + ? (coach.streamingStatus || 'Coach antwortet...') + : isUserSpeaking + ? 'Spricht...' + : isListening + ? 'Mikrofon an – bitte sprechen' + : 'Mikrofon wird gestartet...'} + +
+
+