/** * 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. */ import React, { useRef, useEffect, useCallback, useState } from 'react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import api from '../../../api'; import type { Message, MessageDocument } from '../../../components/UiComponents/Messages/MessagesTypes'; import type { AgentProgress, FileEditProposal } from './useWorkspace'; 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 bottomRef = useRef(null); useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages, agentProgress]); return (
{messages.map((msg) => (
{msg.role === 'assistant' && (
Assistant
)} {msg.role === 'status' ? ( {msg.message} ) : (
{msg.message && ( (
{children}
), th: ({ children }) => ( {children} ), td: ({ children }) => ( {children} ), a: ({ href, children }) => ( {children} ), }} > {msg.message}
)} {msg.documents && msg.documents.length > 0 && (
{msg.documents.map((doc) => ( <_FileCard key={doc.id || doc.fileId} doc={doc} /> ))}
)} {(msg as any)._audioUrl && ( <_AudioPlayer url={(msg as any)._audioUrl} language={(msg as any)._audioLang} charCount={(msg as any)._audioCharCount} /> )}
)}
))} {/* 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 && ( )}
)} {/* Agent progress */} {isProcessing && agentProgress && (
Round {agentProgress.round}{agentProgress.maxRounds ? `/${agentProgress.maxRounds}` : ''} {agentProgress.totalToolCalls} tools {agentProgress.costCHF?.toFixed(4) || '0'} CHF
)} {isProcessing && !agentProgress && (
Processing...
)}
); }; 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 _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 ? doc.fileSize > 1024 * 1024 ? `${(doc.fileSize / (1024 * 1024)).toFixed(1)} MB` : `${(doc.fileSize / 1024).toFixed(1)} KB` : ''; 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'; } function _AudioPlayer({ url, language }: { url: string; language?: string; charCount?: number }) { const audioRef = useRef(null); const [playing, setPlaying] = useState(false); const [progress, setProgress] = useState(0); const [duration, setDuration] = useState(0); useEffect(() => { const audio = new Audio(url); audioRef.current = audio; audio.addEventListener('loadedmetadata', () => setDuration(audio.duration)); audio.addEventListener('timeupdate', () => { if (audio.duration) setProgress(audio.currentTime / audio.duration); }); audio.addEventListener('ended', () => { setPlaying(false); setProgress(0); }); audio.addEventListener('pause', () => setPlaying(false)); audio.addEventListener('play', () => setPlaying(true)); audio.play().catch(() => {}); return () => { audio.pause(); audio.src = ''; }; }, [url]); const _togglePlay = useCallback(() => { const audio = audioRef.current; if (!audio) return; if (playing) { audio.pause(); } else { audio.play().catch(() => {}); } }, [playing]); const _stop = useCallback(() => { const audio = audioRef.current; if (!audio) return; audio.pause(); audio.currentTime = 0; setPlaying(false); setProgress(0); }, []); const _formatTime = (s: number) => { const m = Math.floor(s / 60); const sec = Math.floor(s % 60); return `${m}:${sec.toString().padStart(2, '0')}`; }; return (
{duration > 0 ? _formatTime(progress * duration) : '0:00'} {duration > 0 ? _formatTime(duration) : '--:--'}
{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}
        
      
); }