301 lines
11 KiB
TypeScript
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;
|