225 lines
7.9 KiB
TypeScript
225 lines
7.9 KiB
TypeScript
import React, { useState, useEffect, useRef, useCallback } 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] = useSearchParams();
|
|
const sessionId = searchParams.get('sessionId') || '';
|
|
|
|
const [session, setSession] = useState<TeamsbotSession | null>(null);
|
|
const [transcripts, setTranscripts] = useState<TeamsbotTranscript[]>([]);
|
|
const [botResponses, setBotResponses] = useState<TeamsbotBotResponse[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
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
|
|
const _loadSession = useCallback(async () => {
|
|
if (!instanceId || !sessionId) return;
|
|
try {
|
|
setLoading(true);
|
|
const result = await teamsbotApi.getSession(instanceId, sessionId);
|
|
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]);
|
|
|
|
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]);
|
|
|
|
// 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 (!session) return <div className={styles.errorBanner}>Sitzung nicht gefunden</div>;
|
|
|
|
return (
|
|
<div className={styles.sessionContainer}>
|
|
{/* 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;
|