/** * ChatStream -- SSE-driven message display for the workspace. * * Renders messages with full Markdown (GFM tables, code blocks with syntax * highlighting), agent progress indicators, and file edit proposals. * * Audio playback uses a playlist queue: when the agent sends multiple TTS * chunks they are queued and played one after the other instead of overlapping. */ import React, { useRef, useEffect, useCallback, useState } from 'react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import api from '../../../api'; import { formatBinaryDataSizeBytes } from '../../../utils/formatDataSize'; import type { Message, MessageDocument } from '../../../components/UiComponents/Messages/MessagesTypes'; import type { AgentProgress, FileEditProposal } from './useWorkspace'; import { useAudioQueue, type AudioQueueApi } from '../../../hooks/useAudioQueue'; import { useLanguage } from '../../../providers/language/LanguageContext'; interface ChatStreamProps { messages: Message[]; agentProgress: AgentProgress | null; isProcessing: boolean; pendingEdits: FileEditProposal[]; onAcceptEdit: (editId: string) => void; onRejectEdit: (editId: string) => void; onOpenEditor?: () => void; } export const ChatStream: React.FC = ({ messages, agentProgress, isProcessing, pendingEdits, onAcceptEdit, onRejectEdit, onOpenEditor, }) => { const { t } = useLanguage(); const bottomRef = useRef(null); const audioQueue = useAudioQueue(); const enqueuedIdsRef = useRef>(new Set()); useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages, agentProgress]); useEffect(() => { for (const msg of messages) { const audioUrl = (msg as any)._audioUrl; if (!audioUrl) continue; if (enqueuedIdsRef.current.has(msg.id)) continue; enqueuedIdsRef.current.add(msg.id); audioQueue.enqueue({ id: msg.id, url: audioUrl, language: (msg as any)._audioLang, charCount: (msg as any)._audioCharCount, }); } }, [messages, audioQueue]); return (
{messages.map((msg) => (
{msg.role === 'assistant' && (
Assistant
)} {msg.role === 'status' ? ( {msg.message} ) : (
{msg.documentsLabel && (
{msg.documentsLabel}
)} {msg.message && ( (
{children}
), th: ({ children }) => ( {children} ), td: ({ children }) => ( {children} ), a: ({ href, children }) => ( {children} ), img: ({ src, alt, ...rest }) => src ? {alt : null, }} > {msg.message}
)} {msg.documents && msg.documents.length > 0 && (
{msg.documents.map((doc) => ( <_FileCard key={doc.id || doc.fileId} doc={doc} /> ))}
)} {(msg as any)._audioUrl && ( <_QueuedAudioPlayer msgId={msg.id} url={(msg as any)._audioUrl} language={(msg as any)._audioLang} audioQueue={audioQueue} /> )} {msg.role === 'assistant' && msg.documents && msg.documents.length > 0 && (
Gesendete Daten ({msg.documents.length} {msg.documents.length === 1 ? 'Dokument' : 'Dokumente'})
{msg.documents.map((doc, idx) => (
{doc.documentName || doc.fileName || `Dokument ${idx + 1}`} {doc.validationMetadata?.neutralized && ( neutralisiert )} {doc.validationMetadata?.skipped && ( übersprungen )}
))}
{(msg as any).neutralizationExcluded?.length > 0 && (
Nicht gesendet (Neutralisierung fehlgeschlagen):
{(msg as any).neutralizationExcluded.map((docName: string, i: number) => (
{docName}
))}
)}
)}
)}
))} {/* File edit proposals -- compact notification cards */} {pendingEdits.filter(e => e.status === 'pending').length > 0 && (
{pendingEdits.filter(e => e.status === 'pending').length} Aenderungsvorschlag(e)
{pendingEdits.filter(e => e.status === 'pending').map(edit => (
{edit.fileName}
))}
{onOpenEditor && ( )}
)} {/* Thinking / agent-progress indicator */} {isProcessing && (
Assistant
{agentProgress ? (
Round {agentProgress.round}{agentProgress.maxRounds ? `/${agentProgress.maxRounds}` : ''} {agentProgress.totalToolCalls} tools {agentProgress.costCHF?.toFixed(4) || '0'} CHF
) : (
{t('Denkt nach…')}
)}
)}
); }; function _getBubbleBackground(role: string): string { switch (role) { case 'user': return 'var(--primary-light, #e3f2fd)'; case 'status': return 'var(--status-bg, #fff3e0)'; case 'system': return 'var(--system-bg, #f5f5f5)'; default: return 'var(--assistant-bg, #ffffff)'; } } function _FileCard({ doc }: { doc: MessageDocument }) { const { t } = useLanguage(); const _handleDownload = useCallback(async () => { try { const res = await api.get(`/api/files/${doc.fileId}/download`, { responseType: 'blob', }); const blob = new Blob([res.data], { type: doc.mimeType || 'application/octet-stream' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = doc.fileName || 'download'; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } catch (err) { console.error('Download failed:', err); } }, [doc]); const ext = (doc.fileName || '').split('.').pop()?.toLowerCase() || ''; const icon = _getFileIcon(ext); const sizeLabel = doc.fileSize ? formatBinaryDataSizeBytes(doc.fileSize) : ''; return (
(e.currentTarget.style.background = '#e8f0fe')} onMouseLeave={e => (e.currentTarget.style.background = 'var(--file-card-bg, #f8f9fa)')} > {icon}
{doc.fileName}
{ext.toUpperCase()}{sizeLabel ? ` \u00b7 ${sizeLabel}` : ''}
); } function _getFileIcon(ext: string): string { const map: Record = { pdf: '\uD83D\uDCC4', csv: '\uD83D\uDCCA', xlsx: '\uD83D\uDCCA', xls: '\uD83D\uDCCA', doc: '\uD83D\uDCC3', docx: '\uD83D\uDCC3', txt: '\uD83D\uDCC4', json: '\uD83D\uDCCB', md: '\uD83D\uDCC4', xml: '\uD83D\uDCCB', yaml: '\uD83D\uDCCB', yml: '\uD83D\uDCCB', html: '\uD83C\uDF10', css: '\uD83C\uDFA8', js: '\uD83D\uDCDC', ts: '\uD83D\uDCDC', py: '\uD83D\uDC0D', sql: '\uD83D\uDDC3\uFE0F', log: '\uD83D\uDCDD', png: '\uD83D\uDDBC\uFE0F', jpg: '\uD83D\uDDBC\uFE0F', jpeg: '\uD83D\uDDBC\uFE0F', gif: '\uD83D\uDDBC\uFE0F', svg: '\uD83D\uDDBC\uFE0F', webp: '\uD83D\uDDBC\uFE0F', zip: '\uD83D\uDCE6', rar: '\uD83D\uDCE6', '7z': '\uD83D\uDCE6', tar: '\uD83D\uDCE6', pptx: '\uD83D\uDCCA', ppt: '\uD83D\uDCCA', mp3: '\uD83C\uDFB5', wav: '\uD83C\uDFB5', ogg: '\uD83C\uDFB5', mp4: '\uD83C\uDFAC', avi: '\uD83C\uDFAC', mov: '\uD83C\uDFAC', webm: '\uD83C\uDFAC', eml: '\uD83D\uDCE7', msg: '\uD83D\uDCE7', }; return map[ext] || '\uD83D\uDCC4'; } /** * Queue-aware audio player with replay support. * * During the initial queue pass the player shows queue/active/done state. * Once an item is done, clicking Play starts an independent local replay * so every message can be re-listened at any time. */ function _QueuedAudioPlayer({ msgId, url, language, audioQueue, }: { msgId: string; url: string; language?: string; audioQueue: AudioQueueApi; }) { const isActive = audioQueue.state.currentId === msgId; const isQueued = audioQueue.isItemQueued(msgId); const queueDone = !isActive && !isQueued; const isQueuePlaying = isActive && audioQueue.state.isPlaying && !audioQueue.state.isPaused; const isQueuePaused = isActive && audioQueue.state.isPaused; const replayRef = useRef(null); const [replayPlaying, setReplayPlaying] = useState(false); const [replayProgress, setReplayProgress] = useState(0); const [replayDuration, setReplayDuration] = useState(0); const [queueProgress, setQueueProgress] = useState(0); const [queueDuration, setQueueDuration] = useState(0); useEffect(() => { return () => { if (replayRef.current) { replayRef.current.pause(); replayRef.current = null; } }; }, []); useEffect(() => { if (!isActive) return; const interval = setInterval(() => { setQueueProgress(audioQueue.getProgress()); setQueueDuration(audioQueue.getDuration()); }, 200); return () => clearInterval(interval); }, [isActive, audioQueue]); const _startReplay = useCallback(() => { if (replayRef.current) { replayRef.current.pause(); replayRef.current = null; } const audio = new Audio(url); replayRef.current = audio; audio.addEventListener('loadedmetadata', () => setReplayDuration(audio.duration)); audio.addEventListener('timeupdate', () => { if (audio.duration) setReplayProgress(audio.currentTime / audio.duration); }); audio.addEventListener('ended', () => { setReplayPlaying(false); setReplayProgress(0); replayRef.current = null; }); audio.play() .then(() => setReplayPlaying(true)) .catch(() => setReplayPlaying(false)); }, [url]); const _toggleReplay = useCallback(() => { const audio = replayRef.current; if (!audio) { _startReplay(); return; } if (audio.paused) { audio.play().then(() => setReplayPlaying(true)).catch(() => {}); } else { audio.pause(); setReplayPlaying(false); } }, [_startReplay]); const _stopReplay = useCallback(() => { if (replayRef.current) { replayRef.current.pause(); replayRef.current.currentTime = 0; replayRef.current = null; } setReplayPlaying(false); setReplayProgress(0); }, []); const _handleMainButton = useCallback(() => { if (isActive) { if (isQueuePaused) audioQueue.resume(); else audioQueue.pause(); } else if (queueDone) { _toggleReplay(); } }, [isActive, isQueuePaused, queueDone, audioQueue, _toggleReplay]); const _handleSkip = useCallback(() => { if (isActive) audioQueue.skip(); }, [isActive, audioQueue]); const _formatTime = (s: number) => { const m = Math.floor(s / 60); const sec = Math.floor(s % 60); return `${m}:${sec.toString().padStart(2, '0')}`; }; const isAnythingPlaying = isQueuePlaying || replayPlaying; const showProgress = isActive ? queueProgress : replayProgress; const showDuration = isActive ? queueDuration : replayDuration; const isWaiting = isQueued && !isActive; let statusLabel = ''; if (isWaiting) statusLabel = 'in Warteschlange'; let buttonIcon = '\u25B6'; let buttonTitle = 'Abspielen'; if (isAnythingPlaying) { buttonIcon = '\u275A\u275A'; buttonTitle = 'Pause'; } else if (isQueuePaused) { buttonIcon = '\u25B6'; buttonTitle = 'Weiter'; } else if (isWaiting) { buttonIcon = '\u25B6'; buttonTitle = 'Warten…'; } const canInteract = isActive || queueDone; return (
{showDuration > 0 ? _formatTime(showProgress * showDuration) : '0:00'} {statusLabel || (showDuration > 0 ? _formatTime(showDuration) : '--:--')}
{(isActive || replayPlaying) && ( )} {language && ( {language} )}
); } function _CodeBlock({ className, children, ...props }: React.HTMLAttributes & { inline?: boolean }) { const match = /language-(\w+)/.exec(className || ''); const isInline = !match && !String(children).includes('\n'); if (isInline) { return ( {children} ); } return (
{match && (
{match[1]}
)}
        
          {children}
        
      
); }