feat: Bot antowrten im Vollbild
All checks were successful
Deploy Nyla Frontend INT / build-and-deploy (push) Successful in 10m29s

This commit is contained in:
Ida 2026-05-27 10:07:00 +02:00
parent eb35e4b463
commit c5d7d85dda
2 changed files with 159 additions and 46 deletions

View file

@ -851,6 +851,52 @@
background: var(--surface-alt, #fafafa); background: var(--surface-alt, #fafafa);
} }
.panelTitleBar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-color, #e0e0e0);
background: var(--surface-alt, #fafafa);
flex-shrink: 0;
}
.panelTitleBar .panelTitle {
padding: 0;
border: none;
background: none;
flex: 1;
min-width: 0;
}
.panelExpandBtn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 4px;
background: var(--surface-color, #fff);
color: var(--text-secondary, #666);
cursor: pointer;
flex-shrink: 0;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.panelExpandBtn:hover {
background: var(--surface-alt, #f5f5f5);
color: var(--primary-color, #4A90D9);
border-color: var(--primary-color, #4A90D9);
}
.popupPanelList {
max-height: none;
padding: 0;
}
.transcriptList, .transcriptList,
.responseList { .responseList {
flex: 1; flex: 1;

View file

@ -25,6 +25,7 @@ import styles from './Teamsbot.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext'; import { useLanguage } from '../../../providers/language/LanguageContext';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
import { Popup } from '../../../components/UiComponents/Popup';
/** /**
* TeamsbotSessionView - Live session view with real-time transcript and bot responses. * TeamsbotSessionView - Live session view with real-time transcript and bot responses.
@ -54,6 +55,8 @@ export const TeamsbotSessionView: React.FC = () => {
const [screenshotsLoading, setScreenshotsLoading] = useState(false); const [screenshotsLoading, setScreenshotsLoading] = useState(false);
const [screenshotsLoaded, setScreenshotsLoaded] = useState(false); const [screenshotsLoaded, setScreenshotsLoaded] = useState(false);
const [screenshotsExpanded, setScreenshotsExpanded] = useState(false); const [screenshotsExpanded, setScreenshotsExpanded] = useState(false);
const [transcriptPopupOpen, setTranscriptPopupOpen] = useState(false);
const [botResponsesPopupOpen, setBotResponsesPopupOpen] = useState(false);
const [ttsStatusEvents, setTtsStatusEvents] = useState<Array<{ const [ttsStatusEvents, setTtsStatusEvents] = useState<Array<{
status: string; status: string;
message?: string; message?: string;
@ -724,6 +727,64 @@ export const TeamsbotSessionView: React.FC = () => {
return colors[Math.abs(hash) % colors.length]; return colors[Math.abs(hash) % colors.length];
}; };
const _renderExpandIcon = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3" />
</svg>
);
const _renderTranscriptList = (endRef?: React.RefObject<HTMLDivElement | null>) => (
<>
{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>
))}
{endRef && <div ref={endRef} />}
{transcripts.length === 0 && (
<div className={styles.emptyState}>{t('Noch kein Transkript vorhanden')}</div>
)}
</>
);
const _renderBotResponsesList = () => (
<>
{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}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{r.responseText || ''}</ReactMarkdown>
</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>
)}
</>
);
if (loading) return <div className={styles.loading}>{t('Sitzung laden')}</div>; if (loading) return <div className={styles.loading}>{t('Sitzung laden')}</div>;
if (noSessions) return ( if (noSessions) return (
<div className={styles.emptyState || styles.loading} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: '1rem', padding: '3rem' }}> <div className={styles.emptyState || styles.loading} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: '1rem', padding: '3rem' }}>
@ -1132,63 +1193,69 @@ export const TeamsbotSessionView: React.FC = () => {
<div className={styles.sessionContent}> <div className={styles.sessionContent}>
{/* Left: Transcript */} {/* Left: Transcript */}
<div className={styles.transcriptPanel}> <div className={styles.transcriptPanel}>
<h4 className={styles.panelTitle}> <div className={styles.panelTitleBar}>
{t('Transkript ({count} Segmente)', { count: transcripts.length })} <h4 className={styles.panelTitle}>
</h4> {t('Transkript ({count} Segmente)', { count: transcripts.length })}
</h4>
<button
type="button"
className={styles.panelExpandBtn}
onClick={() => setTranscriptPopupOpen(true)}
title={t('Vollbild')}
aria-label={t('Transkript im Vollbild anzeigen')}
>
{_renderExpandIcon()}
</button>
</div>
<div className={styles.transcriptList}> <div className={styles.transcriptList}>
{transcripts.map((seg) => ( {_renderTranscriptList(transcriptEndRef)}
<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>
</div> </div>
{/* Right: Bot Responses */} {/* Right: Bot Responses */}
<div className={styles.responsesPanel}> <div className={styles.responsesPanel}>
<h4 className={styles.panelTitle}>Bot-Antworten ({botResponses.length})</h4> <div className={styles.panelTitleBar}>
<h4 className={styles.panelTitle}>Bot-Antworten ({botResponses.length})</h4>
<button
type="button"
className={styles.panelExpandBtn}
onClick={() => setBotResponsesPopupOpen(true)}
title={t('Vollbild')}
aria-label={t('Bot-Antworten im Vollbild anzeigen')}
>
{_renderExpandIcon()}
</button>
</div>
<div className={styles.responseList}> <div className={styles.responseList}>
{botResponses.map((r) => ( {_renderBotResponsesList()}
<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}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{r.responseText || ''}</ReactMarkdown>
</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> </div>
</div> </div>
<Popup
isOpen={transcriptPopupOpen}
title={t('Transkript ({count} Segmente)', { count: transcripts.length })}
onClose={() => setTranscriptPopupOpen(false)}
size="fullscreen"
closeOnBackdropClick
>
<div className={`${styles.transcriptList} ${styles.popupPanelList}`}>
{_renderTranscriptList()}
</div>
</Popup>
<Popup
isOpen={botResponsesPopupOpen}
title={`Bot-Antworten (${botResponses.length})`}
onClose={() => setBotResponsesPopupOpen(false)}
size="fullscreen"
closeOnBackdropClick
>
<div className={`${styles.responseList} ${styles.popupPanelList}`}>
{_renderBotResponsesList()}
</div>
</Popup>
{/* Summary (for ended sessions) */} {/* Summary (for ended sessions) */}
{session.summary && ( {session.summary && (
<div className={styles.summaryCard}> <div className={styles.summaryCard}>