import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { useSearchParams } from 'react-router-dom'; import { useCurrentInstance } from '../../../hooks/useCurrentInstance'; import * as teamsbotApi from '../../../api/teamsbotApi'; import type { TeamsbotSession, TeamsbotTranscript, TeamsbotBotResponse, TeamsbotSSEEvent, ScreenshotInfo, DirectorPrompt, DirectorPromptMode, } from '../../../api/teamsbotApi'; import { DIRECTOR_PROMPT_TEXT_LIMIT, DIRECTOR_PROMPT_FILE_LIMIT, } from '../../../api/teamsbotApi'; import { getUserDataCache } from '../../../utils/userCache'; import { UnifiedDataBar } from '../../../components/UnifiedDataBar'; import type { UdbContext, UdbTab } from '../../../components/UnifiedDataBar'; import { useFileContext } from '../../../contexts/FileContext'; import styles from './Teamsbot.module.css'; import { useLanguage } from '../../../providers/language/LanguageContext'; /** * TeamsbotSessionView - Live session view with real-time transcript and bot responses. */ export const TeamsbotSessionView: React.FC = () => { const { t } = useLanguage(); const { instance } = useCurrentInstance(); const instanceId = instance?.id || ''; const [searchParams, setSearchParams] = useSearchParams(); const sessionId = searchParams.get('sessionId') || ''; const cachedUser = getUserDataCache(); const _isSysAdmin = cachedUser?.isSysAdmin === true; const [session, setSession] = useState(null); const [allSessions, setAllSessions] = useState([]); const [transcripts, setTranscripts] = useState([]); const [botResponses, setBotResponses] = useState([]); const [loading, setLoading] = useState(true); const [noSessions, setNoSessions] = useState(false); const [error, setError] = useState(null); const [isLive, setIsLive] = useState(false); const [screenshots, setScreenshots] = useState([]); const [screenshotsLoading, setScreenshotsLoading] = useState(false); const [screenshotsLoaded, setScreenshotsLoaded] = useState(false); const [ttsStatusEvents, setTtsStatusEvents] = useState>([]); // Director Prompt panel state const [directorPrompts, setDirectorPrompts] = useState([]); const [directorText, setDirectorText] = useState(''); const [directorMode, setDirectorMode] = useState('oneShot'); const [directorFiles, setDirectorFiles] = useState>([]); const [directorSubmitting, setDirectorSubmitting] = useState(false); const [directorError, setDirectorError] = useState(null); const [directorDragOver, setDirectorDragOver] = useState(false); const [directorUploading, setDirectorUploading] = useState(false); const directorDragCounterRef = useRef(0); const directorFileInputRef = useRef(null); // Bot WebSocket connection state (separate from session.status: the session // can be 'active' before the bot has actually opened its WebSocket back to // the gateway. Director prompts can only be processed once botConnected=true.) const [botConnected, setBotConnected] = useState(false); // UDB Sidebar state const [udbCollapsed, setUdbCollapsed] = useState(false); const [udbTab, setUdbTab] = useState('files'); const _udbContext: UdbContext | null = instanceId ? { instanceId, featureInstanceId: instanceId } : null; const fileCtx = useFileContext(); const transcriptEndRef = useRef(null); const eventSourceRef = useRef(null); const debugLogsRef = useRef([]); const [debugVisible, setDebugVisible] = useState(false); const [debugSnapshot, setDebugSnapshot] = useState([]); const _dlog = useCallback((tag: string, info?: string) => { const t = new Date(); const ts = `${t.getMinutes()}:${String(t.getSeconds()).padStart(2, '0')}.${String(t.getMilliseconds()).padStart(3, '0')}`; const entry = `[${ts}] ${tag}${info ? ' ' + info : ''}`; debugLogsRef.current.push(entry); if (debugLogsRef.current.length > 120) debugLogsRef.current.shift(); }, []); // Load session data - if no sessionId given, load the most recent session const _loadSession = useCallback(async () => { if (!instanceId) return; try { setLoading(true); setNoSessions(false); // Always load the full session list for the switcher const listResult = await teamsbotApi.listSessions(instanceId, true); const sessions = listResult.sessions || []; setAllSessions(sessions); let targetSessionId = sessionId; // No sessionId in URL -> find the most recent active or latest session if (!targetSessionId) { if (sessions.length === 0) { setNoSessions(true); setLoading(false); return; } // Prefer active sessions, then most recent const activeSession = sessions.find(s => ['active', 'joining', 'pending'].includes(s.status)); targetSessionId = activeSession ? activeSession.id : sessions[0].id; setSearchParams({ sessionId: targetSessionId }, { replace: true }); } const result = await teamsbotApi.getSession(instanceId, targetSessionId); setSession(result.session); setTranscripts(result.transcripts || []); setBotResponses(result.botResponses || []); setError(null); } catch (err: any) { setError(err.message || t('Fehler beim Laden der Sitzung')); } finally { setLoading(false); } }, [instanceId, sessionId, setSearchParams, t]); useEffect(() => { _loadSession(); }, [_loadSession]); // Load director prompt history when session changes useEffect(() => { if (!instanceId || !sessionId) return; let cancelled = false; teamsbotApi .listDirectorPrompts(instanceId, sessionId) .then((res) => { if (!cancelled) setDirectorPrompts(res.prompts || []); }) .catch(() => { if (!cancelled) setDirectorPrompts([]); }); return () => { cancelled = true; }; }, [instanceId, sessionId]); // SSE Live Stream - connect once per session, don't re-create on status changes. // We deliberately depend ONLY on (instanceId, sessionId), not on session.status, // so transient status transitions (pending -> joining -> active) don't tear down // and rebuild the EventSource (which used to flicker botConnected and spawn // multiple parallel /stream connections to the gateway). const sseSessionRef = useRef(null); const sessionStatusRef = useRef(session?.status); sessionStatusRef.current = session?.status; useEffect(() => { if (!instanceId || !sessionId) return; // Avoid reconnecting if already streaming this session if (sseSessionRef.current === sessionId && eventSourceRef.current) return; // Don't open a stream for sessions that are known to already be terminal. const initialStatus = sessionStatusRef.current; if (initialStatus && !['active', 'joining', 'pending'].includes(initialStatus)) return; eventSourceRef.current?.close(); sseSessionRef.current = sessionId; const eventSource = teamsbotApi.createSessionStream(instanceId, sessionId); eventSourceRef.current = eventSource; setIsLive(true); _dlog('SSE', 'connected'); eventSource.onmessage = (event) => { try { const sseEvent: TeamsbotSSEEvent = JSON.parse(event.data); const evType = sseEvent.type || 'unknown'; _dlog('SSE', evType + (sseEvent.data ? ` ${JSON.stringify(sseEvent.data).substring(0, 80)}` : '')); switch (evType) { case 'sessionState': if (sseEvent.data) setSession(prev => prev ? { ...prev, ...sseEvent.data } : sseEvent.data); break; case 'transcript': { const tr = sseEvent.data as TeamsbotTranscript; _dlog('TRANSCRIPT', `[${tr?.speaker || '?'}] ${(tr?.text || '').substring(0, 50)}...`); if (tr?.isContinuation && tr?.id) { setTranscripts(prev => { const idx = prev.findIndex(x => x.id === tr.id); if (idx >= 0) { const updated = [...prev]; updated[idx] = { ...updated[idx], ...tr }; return updated; } return [...prev, tr]; }); } else { setTranscripts(prev => [...prev, tr]); } break; } case 'botResponse': setBotResponses(prev => [...prev, sseEvent.data as TeamsbotBotResponse]); break; case 'statusChange': setSession(prev => prev ? { ...prev, status: sseEvent.data.status } : null); if (['ended', 'error'].includes(sseEvent.data.status)) { setIsLive(false); eventSource.close(); eventSourceRef.current = null; sseSessionRef.current = null; teamsbotApi.getSession(instanceId, sessionId).then((result) => { setSession(result.session); if (result.transcripts) setTranscripts(result.transcripts); if (result.botResponses) setBotResponses(result.botResponses); }).catch(() => {}); } break; case 'analysis': break; case 'ttsDeliveryStatus': { const payload = sseEvent.data || {}; setTtsStatusEvents((prev) => [ ...prev.slice(-24), { status: payload.status || 'unknown', message: payload.message, hasWebSocket: payload.hasWebSocket, timestamp: payload.timestamp || new Date().toISOString(), }, ]); break; } case 'chatSendFailed': { const failData = sseEvent.data || {}; const failMsg = t('Chat-Nachricht konnte nicht gesendet werden: {reason}', { reason: failData.reason || t('unbekannt'), }); _dlog('CHAT-FAIL', failMsg); setTtsStatusEvents((prev) => [ ...prev.slice(-24), { status: 'chat_failed', message: failMsg, hasWebSocket: false, timestamp: failData.timestamp || new Date().toISOString(), }, ]); break; } case 'botConnectionState': { const data = sseEvent.data || {}; setBotConnected(Boolean(data.connected)); _dlog('BOT-WS', data.connected ? 'connected' : 'disconnected'); break; } case 'directorPrompt': { const prompt = sseEvent.data as DirectorPrompt | undefined; if (!prompt || !prompt.id) break; setDirectorPrompts((prev) => { const idx = prev.findIndex((p) => p.id === prompt.id); if (idx >= 0) { const updated = [...prev]; updated[idx] = { ...updated[idx], ...prompt }; return updated; } return [prompt, ...prev]; }); break; } case 'agentRun': { const data = sseEvent.data || {}; _dlog('AGENT', `${data.status || ''} ${data.reason || ''}`.trim()); break; } case 'error': { const errData = sseEvent.data || {}; const errMsg = errData.message || t('Unbekannter Fehler'); _dlog('ERROR', errMsg); setError(errMsg); break; } case 'suggestedResponse': break; case 'ping': break; } } catch (err) { _dlog('SSE-ERR', String(err)); console.error('SSE parse error:', err); } }; eventSource.onerror = () => { setIsLive(false); }; return () => { eventSource.close(); eventSourceRef.current = null; sseSessionRef.current = null; setIsLive(false); setBotConnected(false); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [instanceId, sessionId]); // Polling fallback: refresh session data every 5s when SSE is not connected const pollRef = useRef | null>(null); const isActive = useMemo(() => session && ['pending', 'joining', 'active'].includes(session.status), [session]); useEffect(() => { if (instanceId && sessionId && (isActive || !session)) { pollRef.current = setInterval(async () => { if (isLive) return; try { const result = await teamsbotApi.getSession(instanceId, sessionId); setSession(result.session); if (result.transcripts) setTranscripts(result.transcripts); if (result.botResponses) setBotResponses(result.botResponses); } catch {} }, 5000); } return () => { if (pollRef.current) clearInterval(pollRef.current); }; }, [isActive, instanceId, sessionId, isLive, session]); // Auto-scroll transcript useEffect(() => { transcriptEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [transcripts]); const _handleStop = async () => { if (!instanceId || !sessionId) return; try { await teamsbotApi.stopSession(instanceId, sessionId); } catch (err: any) { setError(err.message); } }; const _formatTime = (timestamp: string) => { try { const dt = new Date(timestamp); if (!timestamp || Number.isNaN(dt.getTime())) return ''; return dt.toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); } catch { return ''; } }; const _addDirectorFile = useCallback((fileId: string, fileName?: string) => { setDirectorFiles((prev) => { if (prev.some((f) => f.id === fileId)) return prev; if (prev.length >= DIRECTOR_PROMPT_FILE_LIMIT) { setDirectorError( t('Maximal {n} Dateien pro Regieanweisung.', { n: String(DIRECTOR_PROMPT_FILE_LIMIT) }), ); return prev; } setDirectorError(null); return [...prev, { id: fileId, name: fileName || fileId }]; }); }, [t]); const _handleUdbFileSelect = _addDirectorFile; const _removeDirectorFile = (fileId: string) => { setDirectorFiles((prev) => prev.filter((f) => f.id !== fileId)); }; const _uploadAndAttachDirectorFile = useCallback(async (file: File) => { if (!fileCtx?.handleFileUpload) return; setDirectorUploading(true); setDirectorError(null); try { const result = await fileCtx.handleFileUpload(file); if (result?.success) { const data: any = (result.fileData as any)?.file || result.fileData; const id = data?.id || (result.fileData as any)?.id; if (id) { _addDirectorFile(id, data?.fileName || file.name); } else { setDirectorError(t('Upload erfolgreich, aber keine Datei-ID erhalten.')); } } else { setDirectorError(result?.error || t('Upload fehlgeschlagen.')); } } catch (err: any) { setDirectorError(err?.message || t('Upload fehlgeschlagen.')); } finally { setDirectorUploading(false); } }, [fileCtx, _addDirectorFile, t]); const _onDirectorDragEnter = useCallback((e: React.DragEvent) => { if ( e.dataTransfer.types.includes('Files') || e.dataTransfer.types.includes('application/file-id') || e.dataTransfer.types.includes('application/file-ids') || e.dataTransfer.types.includes('application/tree-items') ) { e.preventDefault(); e.stopPropagation(); directorDragCounterRef.current += 1; setDirectorDragOver(true); } }, []); const _onDirectorDragOver = useCallback((e: React.DragEvent) => { if ( e.dataTransfer.types.includes('Files') || e.dataTransfer.types.includes('application/file-id') || e.dataTransfer.types.includes('application/file-ids') || e.dataTransfer.types.includes('application/tree-items') ) { e.preventDefault(); e.stopPropagation(); e.dataTransfer.dropEffect = 'copy'; } }, []); const _onDirectorDragLeave = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); directorDragCounterRef.current = Math.max(0, directorDragCounterRef.current - 1); if (directorDragCounterRef.current === 0) setDirectorDragOver(false); }, []); const _onDirectorDrop = useCallback(async (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); directorDragCounterRef.current = 0; setDirectorDragOver(false); const fileIdsJson = e.dataTransfer.getData('application/file-ids'); if (fileIdsJson) { try { const ids: string[] = JSON.parse(fileIdsJson); ids.forEach((id) => _addDirectorFile(id)); } catch { /* ignore malformed */ } return; } const singleFileId = e.dataTransfer.getData('application/file-id'); if (singleFileId) { const label = e.dataTransfer.getData('text/plain'); _addDirectorFile(singleFileId, label || undefined); return; } const treeItemsJson = e.dataTransfer.getData('application/tree-items'); if (treeItemsJson) { try { const items: Array<{ id: string; type: 'file' | 'folder'; name: string }> = JSON.parse(treeItemsJson); items.filter((it) => it.type === 'file').forEach((it) => _addDirectorFile(it.id, it.name)); } catch { /* ignore malformed */ } return; } if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { for (const file of Array.from(e.dataTransfer.files)) { if (directorFiles.length >= DIRECTOR_PROMPT_FILE_LIMIT) { setDirectorError( t('Maximal {n} Dateien pro Regieanweisung.', { n: String(DIRECTOR_PROMPT_FILE_LIMIT) }), ); break; } await _uploadAndAttachDirectorFile(file); } } }, [_addDirectorFile, _uploadAndAttachDirectorFile, directorFiles.length, t]); const _onDirectorFileInput = useCallback(async (e: React.ChangeEvent) => { if (!e.target.files || e.target.files.length === 0) return; for (const file of Array.from(e.target.files)) { if (directorFiles.length >= DIRECTOR_PROMPT_FILE_LIMIT) break; await _uploadAndAttachDirectorFile(file); } e.target.value = ''; }, [_uploadAndAttachDirectorFile, directorFiles.length]); const _submitDirectorPrompt = async () => { if (!instanceId || !sessionId) return; const trimmed = directorText.trim(); if (!trimmed) { setDirectorError(t('Bitte gib eine Anweisung ein.')); return; } if (trimmed.length > DIRECTOR_PROMPT_TEXT_LIMIT) { setDirectorError( t('Text zu lang (max. {n} Zeichen).', { n: String(DIRECTOR_PROMPT_TEXT_LIMIT) }), ); return; } setDirectorSubmitting(true); setDirectorError(null); try { const res = await teamsbotApi.submitDirectorPrompt(instanceId, sessionId, { text: trimmed, mode: directorMode, fileIds: directorFiles.map((f) => f.id), }); if (res.prompt) { setDirectorPrompts((prev) => { const idx = prev.findIndex((p) => p.id === res.prompt.id); if (idx >= 0) { const next = [...prev]; next[idx] = res.prompt; return next; } return [res.prompt, ...prev]; }); } setDirectorText(''); setDirectorFiles([]); } catch (err: any) { setDirectorError(err?.response?.data?.detail || err?.message || t('Senden fehlgeschlagen.')); } finally { setDirectorSubmitting(false); } }; const _removeDirectorPrompt = async (promptId: string) => { if (!instanceId || !sessionId) return; try { await teamsbotApi.deleteDirectorPrompt(instanceId, sessionId, promptId); setDirectorPrompts((prev) => prev.filter((p) => p.id !== promptId)); } catch (err: any) { setDirectorError(err?.message || t('Entfernen fehlgeschlagen.')); } }; const activePersistentCount = useMemo( () => directorPrompts.filter((p) => p.mode === 'persistent' && p.status !== 'consumed').length, [directorPrompts], ); const _getSpeakerColor = (speaker: string) => { const colors = ['#4A90D9', '#D94A4A', '#4AD99A', '#D9A84A', '#9A4AD9', '#4AD9D9']; let hash = 0; for (let i = 0; i < speaker.length; i++) { hash = speaker.charCodeAt(i) + ((hash << 5) - hash); } return colors[Math.abs(hash) % colors.length]; }; if (loading) return
{t('Sitzung laden')}
; if (noSessions) return (

{t('Keine Sitzungen vorhanden')}

{t('Starte eine neue Sitzung im Dashboard.')}

); if (!session) return
{t('Sitzung nicht gefunden')}
; const _switchSession = (newSessionId: string) => { setSearchParams({ sessionId: newSessionId }); }; return (
{/* Session Switcher (if multiple sessions exist) */} {allSessions.length > 1 && (
{allSessions.map((s) => ( ))}
)} {/* Session Header */}

{session.botName}

{session.status} {isLive && LIVE}
{['active', 'joining', 'pending'].includes(session.status) && ( )}
{error &&
{error}
} {/* Layout: UDB Sidebar + Main */}
{/* UDB Sidebar (Files / Sources) */} {_udbContext && (
{!udbCollapsed && ( )}
)} {/* Main column */}
{/* Director Prompt Panel (private operator instructions) */} {['active', 'joining', 'pending'].includes(session.status) && (
{(() => { const sStatus = session?.status; const isSessionLaunching = !!sStatus && ['pending', 'joining'].includes(sStatus); const isSessionActive = sStatus === 'active'; // Bot has joined the meeting (session active) but the WebSocket back // to the gateway is missing -> usually means the browser-bot service // can't reach this gateway (e.g. localhost gateway + remote bot, or // bot behind firewall). Audio + transcripts won't flow. const isBotUnreachable = isSessionActive && !botConnected; const statusLabel = botConnected ? t('Bot live') : isBotUnreachable ? t('Bot ist im Meeting, aber nicht mit dem Gateway verbunden') : isSessionLaunching ? t('Bot startet ...') : t('Keine aktive Session'); const statusTitle = botConnected ? t('Bot ist live im Meeting verbunden und liefert Transkripte') : isBotUnreachable ? t('Der Browser-Bot hat den WebSocket nicht zurueck zum Gateway geoeffnet. Pruefe TEAMSBOT_BROWSER_BOT_URL und APP_API_URL: bei lokalem Gateway muss der Bot ebenfalls lokal laufen oder das Gateway ueber einen Tunnel erreichbar sein.') : isSessionLaunching ? t('Bot tritt dem Meeting bei und oeffnet die WebSocket-Verbindung ...') : t('Es laeuft keine aktive Bot-Session'); return (

{t('Regieanweisungen')}

{statusLabel} {activePersistentCount > 0 && ( {activePersistentCount} )}
{t('Privat - nur fuer den Bot sichtbar')}
); })()}