frontend_nyla/src/pages/views/teamsbot/TeamsbotSessionView.tsx
2026-02-16 09:02:44 +01:00

301 lines
11 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 } from '../../../api/teamsbotApi';
import styles from './Teamsbot.module.css';
/**
* TeamsbotSessionView - Live session view with real-time transcript and bot responses.
*/
export const TeamsbotSessionView: React.FC = () => {
const { instance } = useCurrentInstance();
const instanceId = instance?.id || '';
const [searchParams, setSearchParams] = useSearchParams();
const sessionId = searchParams.get('sessionId') || '';
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 transcriptEndRef = useRef<HTMLDivElement>(null);
const eventSourceRef = useRef<EventSource | null>(null);
// 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 || 'Fehler beim Laden der Sitzung');
} finally {
setLoading(false);
}
}, [instanceId, sessionId, setSearchParams]);
useEffect(() => {
_loadSession();
}, [_loadSession]);
// SSE Live Stream
useEffect(() => {
if (!instanceId || !sessionId || !session) return;
if (!['active', 'joining', 'pending'].includes(session.status)) return;
const eventSource = teamsbotApi.createSessionStream(instanceId, sessionId);
eventSourceRef.current = eventSource;
setIsLive(true);
eventSource.onmessage = (event) => {
try {
const sseEvent: TeamsbotSSEEvent = JSON.parse(event.data);
switch (sseEvent.type) {
case 'transcript':
setTranscripts(prev => [...prev, sseEvent.data as TeamsbotTranscript]);
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();
}
break;
case 'analysis':
// Debug info - could show in UI
break;
case 'suggestedResponse':
// Manual mode: show suggested response
break;
case 'ping':
break;
}
} catch (err) {
console.error('SSE parse error:', err);
}
};
eventSource.onerror = () => {
setIsLive(false);
};
return () => {
eventSource.close();
eventSourceRef.current = null;
setIsLive(false);
};
}, [instanceId, sessionId, session?.status]);
// Polling fallback: refresh session data every 5s when session is active (in case SSE fails)
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const isActive = useMemo(() => session && ['pending', 'joining', 'active'].includes(session.status), [session]);
useEffect(() => {
if (isActive && instanceId && sessionId && !isLive) {
pollRef.current = setInterval(async () => {
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]);
// 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 {
return new Date(timestamp).toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
} catch {
return '';
}
};
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}>Lade Sitzung...</div>;
if (noSessions) return (
<div className={styles.emptyState || styles.loading}>
<p>Keine Sitzungen vorhanden.</p>
<p>Starte eine neue Sitzung im <strong>Dashboard</strong>.</p>
</div>
);
if (!session) return <div className={styles.errorBanner}>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'].includes(session.status) && (
<button className={styles.stopButton} onClick={_handleStop}>Sitzung beenden</button>
)}
</div>
</div>
{error && <div className={styles.errorBanner}>{error}</div>}
{/* Main Content: Transcript + Responses */}
<div className={styles.sessionContent}>
{/* Left: Transcript */}
<div className={styles.transcriptPanel}>
<h4 className={styles.panelTitle}>Transkript ({transcripts.length} Segmente)</h4>
<div className={styles.transcriptList}>
{transcripts.map((t) => (
<div key={t.id} className={styles.transcriptItem}>
<span className={styles.transcriptTime}>{_formatTime(t.timestamp)}</span>
<span
className={styles.transcriptSpeaker}
style={{ color: _getSpeakerColor(t.speaker || 'Unknown') }}
>
{t.speaker || 'Unknown'}:
</span>
<span className={styles.transcriptText}>{t.text}</span>
</div>
))}
<div ref={transcriptEndRef} />
{transcripts.length === 0 && (
<div className={styles.emptyState}>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>Reasoning: {r.reasoning}</em>
</div>
)}
<div className={styles.responseMeta}>
<span>{r.modelName}</span>
<span>{r.processingTime.toFixed(1)}s</span>
<span>{r.priceCHF.toFixed(4)} CHF</span>
</div>
</div>
))}
{botResponses.length === 0 && (
<div className={styles.emptyState}>Noch keine Bot-Antworten.</div>
)}
</div>
</div>
</div>
{/* Summary (for ended sessions) */}
{session.summary && (
<div className={styles.summaryCard}>
<h4 className={styles.panelTitle}>Meeting-Zusammenfassung</h4>
<div className={styles.summaryText}>{session.summary}</div>
</div>
)}
</div>
);
};
export default TeamsbotSessionView;