1014 lines
40 KiB
TypeScript
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;
|