520 lines
18 KiB
TypeScript
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">⬇</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"
|
|
>
|
|
■
|
|
</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>
|
|
);
|
|
}
|