frontend_nyla/src/pages/views/teamsbot/TeamsbotSessionView.tsx
2026-04-25 01:13:13 +02:00

1014 lines
40 KiB
TypeScript

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<TeamsbotSession | null>(null);
const [allSessions, setAllSessions] = useState<TeamsbotSession[]>([]);
const [transcripts, setTranscripts] = useState<TeamsbotTranscript[]>([]);
const [botResponses, setBotResponses] = useState<TeamsbotBotResponse[]>([]);
const [loading, setLoading] = useState(true);
const [noSessions, setNoSessions] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isLive, setIsLive] = useState(false);
const [screenshots, setScreenshots] = useState<ScreenshotInfo[]>([]);
const [screenshotsLoading, setScreenshotsLoading] = useState(false);
const [screenshotsLoaded, setScreenshotsLoaded] = useState(false);
const [ttsStatusEvents, setTtsStatusEvents] = useState<Array<{
status: string;
message?: string;
hasWebSocket?: boolean;
timestamp: string;
}>>([]);
// Director Prompt panel state
const [directorPrompts, setDirectorPrompts] = useState<DirectorPrompt[]>([]);
const [directorText, setDirectorText] = useState('');
const [directorMode, setDirectorMode] = useState<DirectorPromptMode>('oneShot');
const [directorFiles, setDirectorFiles] = useState<Array<{ id: string; name: string }>>([]);
const [directorSubmitting, setDirectorSubmitting] = useState(false);
const [directorError, setDirectorError] = useState<string | null>(null);
const [directorDragOver, setDirectorDragOver] = useState(false);
const [directorUploading, setDirectorUploading] = useState(false);
const directorDragCounterRef = useRef(0);
const directorFileInputRef = useRef<HTMLInputElement>(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<UdbTab>('files');
const _udbContext: UdbContext | null = instanceId
? { instanceId, featureInstanceId: instanceId }
: null;
const fileCtx = useFileContext();
const transcriptEndRef = useRef<HTMLDivElement>(null);
const eventSourceRef = useRef<EventSource | null>(null);
const debugLogsRef = useRef<string[]>([]);
const [debugVisible, setDebugVisible] = useState(false);
const [debugSnapshot, setDebugSnapshot] = useState<string[]>([]);
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<string | null>(null);
const sessionStatusRef = useRef<string | undefined>(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<ReturnType<typeof setInterval> | 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<HTMLInputElement>) => {
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 <div className={styles.loading}>{t('Sitzung laden')}</div>;
if (noSessions) return (
<div className={styles.emptyState || styles.loading}>
<p>{t('Keine Sitzungen vorhanden')}</p>
<p>{t('Starte eine neue Sitzung im Dashboard.')}</p>
</div>
);
if (!session) return <div className={styles.errorBanner}>{t('Sitzung nicht gefunden')}</div>;
const _switchSession = (newSessionId: string) => {
setSearchParams({ sessionId: newSessionId });
};
return (
<div className={styles.sessionContainer}>
{/* Session Switcher (if multiple sessions exist) */}
{allSessions.length > 1 && (
<div style={{ display: 'flex', gap: '8px', marginBottom: '12px', flexWrap: 'wrap' }}>
{allSessions.map((s) => (
<button
key={s.id}
onClick={() => _switchSession(s.id)}
style={{
padding: '6px 12px',
borderRadius: '6px',
border: s.id === sessionId ? '2px solid #4A90D9' : '1px solid #ddd',
background: s.id === sessionId ? '#EBF3FC' : '#fff',
cursor: 'pointer',
fontSize: '13px',
fontWeight: s.id === sessionId ? 600 : 400,
}}
>
{s.botName}
{['active', 'joining', 'pending'].includes(s.status) && ' (aktiv)'}
{s.status === 'ended' && ' (beendet)'}
</button>
))}
</div>
)}
{/* Session Header */}
<div className={styles.sessionViewHeader}>
<div className={styles.sessionInfo}>
<h3 className={styles.sessionTitle}>{session.botName}</h3>
<span className={`${styles.statusBadge} ${styles[`status${session.status.charAt(0).toUpperCase() + session.status.slice(1)}`] || ''}`}>
{session.status}
</span>
{isLive && <span className={styles.liveBadge}>LIVE</span>}
</div>
<div className={styles.sessionControls}>
{['active', 'joining', 'pending'].includes(session.status) && (
<button className={styles.stopButton} onClick={_handleStop}>{t('Sitzung beenden')}</button>
)}
</div>
</div>
{error && <div className={styles.errorBanner}>{error}</div>}
{/* Layout: UDB Sidebar + Main */}
<div className={styles.sessionLayout}>
{/* UDB Sidebar (Files / Sources) */}
{_udbContext && (
<div className={`${styles.udbSidebar} ${udbCollapsed ? styles.udbSidebarCollapsed : ''}`}>
<button
className={styles.udbToggle}
onClick={() => setUdbCollapsed((v) => !v)}
title={udbCollapsed ? t('Seitenleiste einblenden') : t('Seitenleiste ausblenden')}
>
{udbCollapsed ? '\u25B6' : '\u25C0'}
</button>
{!udbCollapsed && (
<UnifiedDataBar
context={_udbContext}
activeTab={udbTab}
onTabChange={setUdbTab}
hideTabs={['chats']}
onFileSelect={_handleUdbFileSelect}
/>
)}
</div>
)}
{/* Main column */}
<div className={styles.sessionMain}>
{/* Director Prompt Panel (private operator instructions) */}
{['active', 'joining', 'pending'].includes(session.status) && (
<div
className={`${styles.directorPanel} ${directorDragOver ? styles.directorPanelDragOver : ''}`}
onDragEnter={_onDirectorDragEnter}
onDragOver={_onDirectorDragOver}
onDragLeave={_onDirectorDragLeave}
onDrop={_onDirectorDrop}
>
{(() => {
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 (
<div className={styles.directorHeader}>
<div className={styles.directorHeaderLeft}>
<h4 className={styles.directorTitle}>{t('Regieanweisungen')}</h4>
<span
className={`${styles.botStatusDot} ${botConnected ? styles.botStatusDotLive : styles.botStatusDotIdle}`}
title={statusTitle}
/>
<span className={styles.directorMeta} title={statusTitle}>
{statusLabel}
</span>
{activePersistentCount > 0 && (
<span className={styles.directorBadge} title={t('Aktive persistente Anweisungen')}>
{activePersistentCount}
</span>
)}
</div>
<div className={styles.directorMeta}>
{t('Privat - nur fuer den Bot sichtbar')}
</div>
</div>
);
})()}
<div className={styles.directorBody}>
<textarea
className={styles.directorTextarea}
placeholder={t('Anweisung an den Bot (z. B. recherchiere ... und gib eine Empfehlung) ...')}
value={directorText}
maxLength={DIRECTOR_PROMPT_TEXT_LIMIT}
onChange={(e) => setDirectorText(e.target.value)}
onKeyDown={(e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault();
void _submitDirectorPrompt();
}
}}
/>
{directorFiles.length > 0 && (
<div className={styles.directorChips}>
{directorFiles.map((f) => (
<span key={f.id} className={styles.directorChip} title={f.name}>
<span className={styles.directorChipName}>{f.name}</span>
<button
className={styles.directorChipRemove}
onClick={() => _removeDirectorFile(f.id)}
title={t('Entfernen')}
>
x
</button>
</span>
))}
</div>
)}
<div className={styles.directorActions}>
<div className={styles.directorRow}>
<span className={styles.directorModeToggle}>
<button
className={`${styles.directorModeButton} ${directorMode === 'oneShot' ? styles.directorModeButtonActive : ''}`}
onClick={() => setDirectorMode('oneShot')}
type="button"
>
{t('Einmalig')}
</button>
<button
className={`${styles.directorModeButton} ${directorMode === 'persistent' ? styles.directorModeButtonActive : ''}`}
onClick={() => setDirectorMode('persistent')}
type="button"
>
{t('Persistent')}
</button>
</span>
<input
ref={directorFileInputRef}
type="file"
multiple
style={{ display: 'none' }}
onChange={_onDirectorFileInput}
/>
<button
className={styles.directorAttachBtn}
onClick={() => directorFileInputRef.current?.click()}
disabled={directorUploading || directorFiles.length >= DIRECTOR_PROMPT_FILE_LIMIT}
title={t('Dateien anhaengen')}
type="button"
>
{directorUploading ? t('Lade hoch ...') : t('Datei anhaengen')}
</button>
<span className={styles.directorMeta}>
{directorText.length}/{DIRECTOR_PROMPT_TEXT_LIMIT} {t('Zeichen')} -
{' '}{directorFiles.length}/{DIRECTOR_PROMPT_FILE_LIMIT} {t('Dateien')}
</span>
</div>
<button
className={styles.directorSubmit}
onClick={_submitDirectorPrompt}
disabled={directorSubmitting || !directorText.trim() || !botConnected}
type="button"
title={botConnected ? t('Strg+Enter: Senden') : t('Bot ist noch nicht live verbunden')}
>
{directorSubmitting ? t('Senden ...') : t('An Bot senden')}
</button>
</div>
{!botConnected && (
<div className={styles.directorHint}>
{session?.status === 'active'
? t('Der Bot ist im Meeting, hat aber den WebSocket-Kanal zum Gateway nicht geoeffnet. Pruefe TEAMSBOT_BROWSER_BOT_URL/APP_API_URL und ob der Browser-Bot-Service das Gateway erreichen kann.')
: t('Der Bot muss erst dem Meeting beitreten und sich verbinden, bevor Regieanweisungen ausgefuehrt werden koennen.')}
</div>
)}
{directorError && (
<div className={styles.errorBanner} style={{ margin: 0 }}>{directorError}</div>
)}
</div>
{directorPrompts.length > 0 && (
<div className={styles.directorHistory}>
{directorPrompts.slice(0, 8).map((p) => (
<div key={p.id} className={styles.directorHistoryItem}>
<div className={styles.directorHistoryHead}>
<span>
{_formatTime(p.createdAt)} - {p.mode === 'persistent' ? t('Persistent') : t('Einmalig')}
{p.fileIds && p.fileIds.length > 0 && ` - ${p.fileIds.length} ${t('Dateien')}`}
</span>
<span style={{ display: 'flex', gap: '6px', alignItems: 'center' }}>
<span className={`${styles.directorStatus} ${styles[`directorStatus${p.status.charAt(0).toUpperCase() + p.status.slice(1)}`] || ''}`}>
{p.status}
</span>
{p.mode === 'persistent' && p.status !== 'consumed' && (
<button
className={styles.directorRemoveBtn}
onClick={() => _removeDirectorPrompt(p.id)}
title={t('Persistente Anweisung entfernen')}
type="button"
>
x
</button>
)}
</span>
</div>
<div className={styles.directorHistoryText}>{p.text}</div>
{p.responseText && (
<div className={styles.directorHistoryText} style={{ opacity: 0.85 }}>
<em>{t('Antwort')}:</em> {p.responseText}
</div>
)}
{p.statusMessage && p.status === 'failed' && (
<div className={styles.directorHistoryText} style={{ color: '#b91c1c' }}>
{p.statusMessage}
</div>
)}
</div>
))}
</div>
)}
</div>
)}
{/* Main Content: Transcript + Responses */}
<div className={styles.sessionContent}>
{/* Left: Transcript */}
<div className={styles.transcriptPanel}>
<h4 className={styles.panelTitle}>
{t('Transkript ({count} Segmente)', { count: transcripts.length })}
</h4>
<div className={styles.transcriptList}>
{transcripts.map((seg) => (
<div key={seg.id} className={styles.transcriptItem}>
<span className={styles.transcriptTime}>{_formatTime(seg.timestamp)}</span>
<span
className={styles.transcriptSpeaker}
style={{ color: _getSpeakerColor(seg.speaker || t('Unbekannt')) }}
>
{seg.speaker || t('Unbekannt')}:
</span>
<span className={styles.transcriptText}>{seg.text}</span>
</div>
))}
<div ref={transcriptEndRef} />
{transcripts.length === 0 && (
<div className={styles.emptyState}>{t('Noch kein Transkript vorhanden')}</div>
)}
</div>
</div>
{/* Right: Bot Responses */}
<div className={styles.responsesPanel}>
<h4 className={styles.panelTitle}>Bot-Antworten ({botResponses.length})</h4>
<div className={styles.responseList}>
{botResponses.map((r) => (
<div key={r.id} className={styles.responseItem}>
<div className={styles.responseHeader}>
<span className={styles.responseIntent}>{r.detectedIntent}</span>
<span className={styles.responseTime}>{_formatTime(r.timestamp || '')}</span>
</div>
<div className={styles.responseText}>{r.responseText}</div>
{r.reasoning && (
<div className={styles.responseReasoning}>
<em>{t('Begründung: {text}', { text: r.reasoning })}</em>
</div>
)}
{(r.modelName || r.processingTime != null) && (
<div className={styles.responseMeta}>
<span>{r.modelName || ''}</span>
{r.processingTime != null && <span>{r.processingTime.toFixed(1)}s</span>}
{r.priceCHF != null && <span>{r.priceCHF.toFixed(4)} CHF</span>}
</div>
)}
</div>
))}
{botResponses.length === 0 && (
<div className={styles.emptyState}>{t('Noch keine Botantworten')}</div>
)}
</div>
</div>
</div>
{/* Summary (for ended sessions) */}
{session.summary && (
<div className={styles.summaryCard}>
<h4 className={styles.panelTitle}>{t('Meeting-Zusammenfassung')}</h4>
<div className={styles.summaryText}>{session.summary}</div>
</div>
)}
{/* TTS Delivery Debug */}
<div className={styles.summaryCard}>
<h4 className={styles.panelTitle}>{t('TTS-Lieferstatus')}</h4>
{ttsStatusEvents.length === 0 ? (
<div className={styles.emptyState}>{t('Noch keine TTS-Events')}</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
{ttsStatusEvents.slice(-10).reverse().map((ev, idx) => (
<div key={`${ev.timestamp}-${idx}`} className={styles.responseMeta}>
<span>{_formatTime(ev.timestamp)}</span>
<span>{ev.status}</span>
<span>ws: {ev.hasWebSocket ? 'yes' : 'no'}</span>
<span>{ev.message || ''}</span>
</div>
))}
</div>
)}
</div>
{/* Debug Log (SSE/Transcript/Chat) */}
<div style={{ position: 'fixed', bottom: 0, right: 0, zIndex: 9999 }}>
<button
onClick={() => { setDebugSnapshot([...debugLogsRef.current]); setDebugVisible(v => !v); }}
style={{ background: '#333', color: '#0f0', border: 'none', padding: '4px 8px', fontSize: '10px', borderRadius: '4px 0 0 0' }}
>
DBG ({debugLogsRef.current.length})
</button>
{debugVisible && (
<div style={{ background: 'rgba(0,0,0,0.9)', color: '#0f0', fontSize: '9px', maxHeight: '40vh', overflow: 'auto', padding: '4px', fontFamily: 'monospace', whiteSpace: 'pre-wrap', width: '100vw' }}>
{debugSnapshot.map((l, i) => <div key={i}>{l}</div>)}
</div>
)}
</div>
{/* Debug Screenshots (SysAdmin only) */}
{_isSysAdmin && (
<div className={styles.summaryCard}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
<h4 className={styles.panelTitle} style={{ margin: 0 }}>{t('Debug-Screenshots')}</h4>
<button
className={styles.viewButton}
onClick={async () => {
setScreenshotsLoading(true);
try {
const result = await teamsbotApi.listScreenshots(instanceId, session.id);
setScreenshots(result.screenshots || []);
setScreenshotsLoaded(true);
} catch (err: any) {
setScreenshots([]);
setScreenshotsLoaded(true);
} finally {
setScreenshotsLoading(false);
}
}}
disabled={screenshotsLoading}
>
{screenshotsLoading ? t('Laden…') : screenshotsLoaded ? t('Aktualisieren') : t('Screenshots laden')}
</button>
</div>
{screenshotsLoaded && screenshots.length === 0 && (
<div className={styles.emptyState}>{t('Keine Screenshots für diese Sitzung')}</div>
)}
{screenshots.length > 0 && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', gap: '12px' }}>
{screenshots.map((s) => {
const imgUrl = teamsbotApi.getScreenshotUrl(instanceId, s.name);
return (
<a
key={s.name}
href={imgUrl}
target="_blank"
rel="noopener noreferrer"
style={{ display: 'block', textDecoration: 'none', color: 'inherit', border: '1px solid #333', borderRadius: '8px', overflow: 'hidden', background: '#1a1a2e' }}
>
<img
src={imgUrl}
alt={s.step}
style={{ width: '100%', height: '140px', objectFit: 'cover', display: 'block' }}
loading="lazy"
/>
<div style={{ padding: '8px', fontSize: '12px' }}>
<div style={{ fontWeight: 600, marginBottom: '2px' }}>{s.step}</div>
<div style={{ color: '#888' }}>
{new Date(s.timestamp).toLocaleTimeString('de-CH')} {(s.sizeBytes / 1024).toFixed(0)} KB
</div>
</div>
</a>
);
})}
</div>
)}
</div>
)}
</div>{/* /sessionMain */}
</div>{/* /sessionLayout */}
</div>
);
};
export default TeamsbotSessionView;