frontend_nyla/src/pages/views/workspace/ChatStream.tsx
2026-03-17 22:51:36 +01:00

520 lines
18 KiB
TypeScript

/**
* 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<ChatStreamProps> = ({
messages,
agentProgress,
isProcessing,
pendingEdits,
onAcceptEdit,
onRejectEdit,
onOpenEditor,
}) => {
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, agentProgress]);
return (
<div style={{
flex: 1,
minHeight: 0,
overflowY: 'auto',
padding: '16px 24px',
display: 'flex',
flexDirection: 'column',
gap: 12,
}}>
{messages.map((msg) => (
<div
key={msg.id}
style={{
flexShrink: 0,
padding: '10px 14px',
borderRadius: 8,
maxWidth: '85%',
alignSelf: msg.role === 'user' ? 'flex-end' : 'flex-start',
background: _getBubbleBackground(msg.role || 'assistant'),
border: msg.role === 'user'
? 'none'
: '1px solid var(--border-color, #e0e0e0)',
fontSize: msg.role === 'status' ? 12 : 14,
color: msg.role === 'status' ? '#795548' : 'inherit',
fontStyle: msg.role === 'status' ? 'italic' : 'normal',
wordBreak: 'break-word',
overflow: 'hidden',
}}
>
{msg.role === 'assistant' && (
<div style={{ fontSize: 11, color: '#888', marginBottom: 4 }}>Assistant</div>
)}
{msg.role === 'status' ? (
<span>{msg.message}</span>
) : (
<div className="workspace-markdown">
{msg.message && (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
code: _CodeBlock,
table: ({ children }) => (
<div style={{ overflowX: 'auto', margin: '8px 0' }}>
<table style={{
borderCollapse: 'collapse',
width: '100%',
fontSize: 13,
}}>
{children}
</table>
</div>
),
th: ({ children }) => (
<th style={{
borderBottom: '2px solid #ddd',
padding: '6px 10px',
textAlign: 'left',
fontWeight: 600,
background: '#f8f9fa',
fontSize: 12,
}}>
{children}
</th>
),
td: ({ children }) => (
<td style={{
borderBottom: '1px solid #eee',
padding: '5px 10px',
fontSize: 12,
}}>
{children}
</td>
),
a: ({ href, children }) => (
<a href={href} target="_blank" rel="noopener noreferrer" style={{ color: '#1976d2' }}>
{children}
</a>
),
}}
>
{msg.message}
</ReactMarkdown>
)}
{msg.documents && msg.documents.length > 0 && (
<div style={{ marginTop: msg.message ? 8 : 0, display: 'flex', flexDirection: 'column', gap: 6 }}>
{msg.documents.map((doc) => (
<_FileCard key={doc.id || doc.fileId} doc={doc} />
))}
</div>
)}
{(msg as any)._audioUrl && (
<_AudioPlayer
url={(msg as any)._audioUrl}
language={(msg as any)._audioLang}
charCount={(msg as any)._audioCharCount}
/>
)}
</div>
)}
</div>
))}
{/* File edit proposals -- compact notification cards */}
{pendingEdits.filter(e => e.status === 'pending').length > 0 && (
<div
style={{
flexShrink: 0,
padding: 12,
borderRadius: 8,
border: '1px solid var(--warning-color, #ff9800)',
background: 'var(--edit-bg, #fff8e1)',
alignSelf: 'flex-start',
maxWidth: '85%',
}}
>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 6, display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ color: '#ff9800' }}></span>
{pendingEdits.filter(e => e.status === 'pending').length} Aenderungsvorschlag(e)
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, marginBottom: 8 }}>
{pendingEdits.filter(e => e.status === 'pending').map(edit => (
<div key={edit.id} style={{ fontSize: 12, color: '#555', display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ width: 6, height: 6, borderRadius: '50%', background: '#ff9800', flexShrink: 0 }} />
{edit.fileName}
</div>
))}
</div>
<div style={{ display: 'flex', gap: 8 }}>
{onOpenEditor && (
<button
onClick={onOpenEditor}
style={{
padding: '5px 14px', borderRadius: 4, border: 'none',
background: 'var(--primary-color, #1976d2)', color: '#fff',
cursor: 'pointer', fontSize: 12, fontWeight: 600,
}}
>
Im Editor pruefen
</button>
)}
<button
onClick={() => pendingEdits.filter(e => e.status === 'pending').forEach(e => onAcceptEdit(e.id))}
style={{
padding: '5px 14px', borderRadius: 4, border: 'none',
background: 'var(--success-color, #4caf50)', color: '#fff',
cursor: 'pointer', fontSize: 12, fontWeight: 600,
}}
>
Alle annehmen
</button>
<button
onClick={() => pendingEdits.filter(e => e.status === 'pending').forEach(e => onRejectEdit(e.id))}
style={{
padding: '5px 14px', borderRadius: 4,
border: '1px solid var(--border-color, #ccc)',
background: '#fff', cursor: 'pointer', fontSize: 12,
}}
>
Alle ablehnen
</button>
</div>
</div>
)}
{/* Agent progress */}
{isProcessing && agentProgress && (
<div style={{
flexShrink: 0,
padding: '8px 14px', borderRadius: 8, fontSize: 12,
background: 'var(--progress-bg, #e8f5e9)',
border: '1px solid var(--progress-border, #c8e6c9)',
alignSelf: 'flex-start',
display: 'flex', gap: 12, alignItems: 'center',
}}>
<span style={{ fontWeight: 600 }}>
Round {agentProgress.round}{agentProgress.maxRounds ? `/${agentProgress.maxRounds}` : ''}
</span>
<span>{agentProgress.totalToolCalls} tools</span>
<span>{agentProgress.costCHF?.toFixed(4) || '0'} CHF</span>
</div>
)}
{isProcessing && !agentProgress && (
<div style={{
flexShrink: 0,
padding: '8px 14px', borderRadius: 8, fontSize: 12,
color: '#666', alignSelf: 'flex-start', fontStyle: 'italic',
display: 'flex', alignItems: 'center', gap: 8,
}}>
<span className="workspace-spinner" style={{
display: 'inline-block', width: 12, height: 12,
border: '2px solid #ccc', borderTopColor: '#1976d2',
borderRadius: '50%', animation: 'workspace-spin 0.8s linear infinite',
}} />
Processing...
</div>
)}
<div ref={bottomRef} />
<style>{`
@keyframes workspace-spin {
to { transform: rotate(360deg); }
}
.workspace-markdown p { margin: 4px 0; }
.workspace-markdown ul, .workspace-markdown ol { margin: 4px 0; padding-left: 20px; }
.workspace-markdown blockquote {
margin: 8px 0; padding: 4px 12px;
border-left: 3px solid #ddd; color: #666;
}
.workspace-markdown h1, .workspace-markdown h2, .workspace-markdown h3 {
margin: 8px 0 4px; line-height: 1.3;
}
.workspace-markdown img { max-width: 100%; border-radius: 4px; }
.workspace-markdown hr { border: none; border-top: 1px solid #e0e0e0; margin: 8px 0; }
`}</style>
</div>
);
};
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 (
<div
onClick={_handleDownload}
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '8px 12px',
borderRadius: 6,
border: '1px solid var(--border-color, #e0e0e0)',
background: 'var(--file-card-bg, #f8f9fa)',
cursor: 'pointer',
transition: 'background 0.15s',
maxWidth: 340,
}}
title={`Download ${doc.fileName}`}
onMouseEnter={e => (e.currentTarget.style.background = '#e8f0fe')}
onMouseLeave={e => (e.currentTarget.style.background = 'var(--file-card-bg, #f8f9fa)')}
>
<span style={{ fontSize: 22 }}>{icon}</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: 13, fontWeight: 600, overflow: 'hidden',
textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{doc.fileName}
</div>
<div style={{ fontSize: 11, color: '#888' }}>
{ext.toUpperCase()}{sizeLabel ? ` \u00b7 ${sizeLabel}` : ''}
</div>
</div>
<span style={{ fontSize: 14, color: '#1976d2' }} title="Download">&#x2B07;</span>
</div>
);
}
function _getFileIcon(ext: string): string {
const map: Record<string, string> = {
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<HTMLAudioElement | null>(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 (
<div style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '8px 12px', borderRadius: 8,
background: 'var(--audio-player-bg, #f0f4f8)',
border: '1px solid var(--border-color, #e0e0e0)',
maxWidth: 360, marginTop: 6,
}}>
<button
onClick={_togglePlay}
style={{
width: 32, height: 32, borderRadius: '50%', border: 'none',
background: 'var(--primary-color, #1976d2)', color: '#fff',
cursor: 'pointer', fontSize: 14,
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
}}
title={playing ? 'Pause' : 'Play'}
>
{playing ? '\u275A\u275A' : '\u25B6'}
</button>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
height: 4, borderRadius: 2,
background: 'var(--border-color, #ddd)',
overflow: 'hidden',
}}>
<div style={{
height: '100%', borderRadius: 2,
background: 'var(--primary-color, #1976d2)',
width: `${progress * 100}%`,
transition: 'width 0.2s',
}} />
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 2 }}>
<span style={{ fontSize: 10, color: '#888' }}>
{duration > 0 ? _formatTime(progress * duration) : '0:00'}
</span>
<span style={{ fontSize: 10, color: '#888' }}>
{duration > 0 ? _formatTime(duration) : '--:--'}
</span>
</div>
</div>
<button
onClick={_stop}
style={{
width: 28, height: 28, borderRadius: '50%', border: '1px solid #ccc',
background: 'transparent', color: '#888',
cursor: 'pointer', fontSize: 12,
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
}}
title="Stop"
>
&#x25A0;
</button>
{language && (
<span style={{ fontSize: 10, color: '#aaa', flexShrink: 0 }}>
{language}
</span>
)}
</div>
);
}
function _CodeBlock({
className,
children,
...props
}: React.HTMLAttributes<HTMLElement> & { inline?: boolean }) {
const match = /language-(\w+)/.exec(className || '');
const isInline = !match && !String(children).includes('\n');
if (isInline) {
return (
<code
style={{
background: '#f0f0f0',
padding: '1px 5px',
borderRadius: 3,
fontSize: '0.9em',
fontFamily: 'Consolas, Monaco, "Courier New", monospace',
}}
{...props}
>
{children}
</code>
);
}
return (
<div style={{ position: 'relative', margin: '8px 0' }}>
{match && (
<div style={{
position: 'absolute', top: 0, right: 0,
padding: '2px 8px', fontSize: 10, color: '#888',
background: '#2d2d2d', borderBottomLeftRadius: 4,
}}>
{match[1]}
</div>
)}
<pre style={{
background: '#1e1e1e',
color: '#d4d4d4',
padding: '12px 14px',
borderRadius: 6,
overflow: 'auto',
fontSize: 13,
fontFamily: 'Consolas, Monaco, "Courier New", monospace',
lineHeight: 1.5,
margin: 0,
}}>
<code className={className} {...props}>
{children}
</code>
</pre>
</div>
);
}