This commit is contained in:
seb
2026-10-06 11:49:10 +02:00
parent 4769a87c54
commit cbf669f640
6 changed files with 384 additions and 563 deletions

View File

@@ -994,17 +994,23 @@ const actions = {
}
const tempId = `temp-${Date.now()}`;
const payloadMessage = fileAttachments.length > 0
? JSON.stringify({ content: trimmed, attachments: fileAttachments })
: trimmed;
let finalContent = trimmed;
if (fileAttachments.length > 0) {
const formatAttr = (val) => String(val).includes(' ') ? `"${val}"` : String(val);
const tags = fileAttachments.map((f) => {
const vfsRelPath = f.path || `attachments/${f.name}`;
const mime = f.mimeType || f.type || 'application/octet-stream';
return `[ATTACHMENT name=${formatAttr(f.name)} path=${formatAttr(vfsRelPath)} type=${formatAttr(mime)}]`;
});
const prefix = tags.join(' ');
finalContent = trimmed ? `${prefix}\n\n${trimmed}` : prefix;
}
const optimisticMsg = {
id: tempId,
conversation_id: selectedConversationId,
role: 'user',
message: payloadMessage,
content: trimmed,
attachments: fileAttachments,
message: JSON.stringify({ content: finalContent }),
created_at: new Date().toISOString(),
};
@@ -1032,13 +1038,10 @@ const actions = {
try {
const res = await modelconversationApi.addMessage(selectedConversationId, {
message: payloadMessage,
message: trimmed,
attachments: fileAttachments.length > 0 ? fileAttachments : undefined,
});
const createdMsg = res?.message || optimisticMsg;
if (!createdMsg.attachments && fileAttachments.length > 0) {
createdMsg.attachments = fileAttachments;
}
chatAutomat.setState((prev) => {
const alreadyHasRealId = prev.messages.some((m) => m.id === createdMsg.id && m.id !== tempId);
if (alreadyHasRealId) {

View File

@@ -1,44 +1,342 @@
/**
* @file ChatAttachmentItems.jsx
* @description UI components for rendering staged attachments in the input tray,
* and attached files inside message bubbles.
* and safe direct VFS links for `[ATTACHMENT name="..." path="..." type=...]` tags in messages.
*/
import React, { PureComponent } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
import CircularProgress from '@mui/material/CircularProgress';
import Tooltip from '@mui/material/Tooltip';
import Chip from '@mui/material/Chip';
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
import PictureAsPdfRoundedIcon from '@mui/icons-material/PictureAsPdfRounded';
import TableChartRoundedIcon from '@mui/icons-material/TableChartRounded';
import CodeRoundedIcon from '@mui/icons-material/CodeRounded';
import DescriptionRoundedIcon from '@mui/icons-material/DescriptionRounded';
import InsertDriveFileRoundedIcon from '@mui/icons-material/InsertDriveFileRounded';
import DownloadRoundedIcon from '@mui/icons-material/DownloadRounded';
import VisibilityRoundedIcon from '@mui/icons-material/VisibilityRounded';
import InsertPhotoRoundedIcon from '@mui/icons-material/InsertPhotoRounded';
import ErrorOutlineRoundedIcon from '@mui/icons-material/ErrorOutlineRounded';
import { enqueueSnackbar } from 'notistack';
import {
SUPPORTED_CATEGORIES,
formatFileSize,
downloadAttachment,
} from './chatAttachments';
function getCategoryIcon(category, { size = 20, color } = {}) {
const sx = { fontSize: size, color };
switch (category) {
case SUPPORTED_CATEGORIES.PDF:
return <PictureAsPdfRoundedIcon sx={{ ...sx, color: color || '#e53935' }} />;
case SUPPORTED_CATEGORIES.CSV:
return <TableChartRoundedIcon sx={{ ...sx, color: color || '#43a047' }} />;
case SUPPORTED_CATEGORIES.XML:
case SUPPORTED_CATEGORIES.HTML:
return <CodeRoundedIcon sx={{ ...sx, color: color || '#fb8c00' }} />;
case SUPPORTED_CATEGORIES.TEXT:
return <DescriptionRoundedIcon sx={{ ...sx, color: color || '#0288d1' }} />;
default:
return <InsertDriveFileRoundedIcon sx={sx} />;
/**
* Resolve a relative VFS path (e.g. `attachments/Rechnung 89589.txt`) to a full browser URL (`/api/vfs/...`).
* @param {string} rawPath
* @returns {string}
*/
export function resolveVfsUrl(rawPath) {
const p = String(rawPath ?? '').trim();
if (!p) return '#';
if (/^https?:\/\//i.test(p) || p.startsWith('/')) return p;
const segments = p
.replace(/^\.\//, '')
.split('/')
.filter((s) => s && s !== '.')
.map(encodeURIComponent);
return `/api/vfs/${segments.join('/')}`;
}
/**
* Parse attributes from an attachment tag:
* `name="Rechnung 89589.txt" path="attachments/Rechnung 89589.txt" type=text/plain`
* @param {string} attrString
* @returns {{ name: string, path: string, type: string }}
*/
export function parseAttachmentTag(attrString) {
const attrs = {};
const regex = /([a-zA-Z0-9_-]+)=(?:"([^"]*)"|'([^']*)'|([^\s\]]+))/g;
let m;
while ((m = regex.exec(attrString)) !== null) {
const key = m[1].toLowerCase();
const val = m[2] ?? m[3] ?? m[4] ?? '';
attrs[key] = val;
}
return {
name: attrs.name || (attrs.path ? attrs.path.split('/').pop() : 'Datei'),
path: attrs.path || '',
type: attrs.type || '',
};
}
/**
* Extract all `[ATTACHMENT ...]` markers and return clean text plus attachment metadata.
* @param {string} rawText
* @returns {{ attachments: Array<{ name: string, path: string, type: string, url: string }>, cleanText: string }}
*/
export function parseAttachmentsFromText(rawText) {
if (typeof rawText !== 'string' || !rawText.includes('[ATTACHMENT')) {
return { attachments: [], cleanText: rawText || '' };
}
const attachments = [];
const tagRegex = /\[ATTACHMENT\s+([^\]]+)\]/g;
let match;
while ((match = tagRegex.exec(rawText)) !== null) {
const parsed = parseAttachmentTag(match[1]);
if (parsed.name || parsed.path) {
attachments.push({
...parsed,
url: resolveVfsUrl(parsed.path),
});
}
}
const cleanText = rawText.replace(tagRegex, '').trim();
return { attachments, cleanText };
}
function getAttachmentIcon(type, name, isUser) {
const t = (type || '').toLowerCase();
const n = (name || '').toLowerCase();
const sx = { fontSize: 20, color: isUser ? 'inherit' : undefined, flexShrink: 0 };
if (t === 'application/pdf' || n.endsWith('.pdf')) {
return <PictureAsPdfRoundedIcon sx={{ ...sx, color: isUser ? 'inherit' : '#e53935' }} />;
}
if (t.includes('csv') || n.endsWith('.csv')) {
return <TableChartRoundedIcon sx={{ ...sx, color: isUser ? 'inherit' : '#43a047' }} />;
}
if (t.includes('xml') || t.includes('html') || n.endsWith('.xml') || n.endsWith('.html') || n.endsWith('.htm')) {
return <CodeRoundedIcon sx={{ ...sx, color: isUser ? 'inherit' : '#fb8c00' }} />;
}
if (t.startsWith('text/') || n.endsWith('.txt') || n.endsWith('.md') || n.endsWith('.log')) {
return <DescriptionRoundedIcon sx={{ ...sx, color: isUser ? 'inherit' : '#0288d1' }} />;
}
if (t.startsWith('image/') || /\.(png|jpe?g|webp|gif|svg|avif|bmp|ico)$/i.test(n)) {
return <InsertPhotoRoundedIcon sx={{ ...sx, color: isUser ? 'inherit' : '#8e24aa' }} />;
}
return <InsertDriveFileRoundedIcon sx={sx} />;
}
/**
* Direct link to VFS rendered for `[ATTACHMENT name="..." path="..." type=...]`.
* Safely verifies HTTP response before triggering download, preventing saving 404 JSON error responses.
*/
export class VfsAttachmentLink extends PureComponent {
state = {
isDownloading: false,
notFound: false,
};
handleClick = async (e) => {
// If modifier keys are pressed (e.g. open in new tab with Ctrl/Cmd), let browser handle
if (e.ctrlKey || e.metaKey || e.shiftKey) return;
e.preventDefault();
const { name, path } = this.props;
const { isDownloading, notFound } = this.state;
if (isDownloading) return;
if (notFound) {
enqueueSnackbar(`Datei "${name}" ist nicht mehr im VFS vorhanden (404).`, { variant: 'warning' });
return;
}
const vfsUrl = resolveVfsUrl(path);
this.setState({ isDownloading: true });
try {
const res = await fetch(vfsUrl, { credentials: 'include' });
if (!res.ok) {
if (res.status === 404) {
this.setState({ notFound: true, isDownloading: false });
enqueueSnackbar(`Datei "${name}" nicht mehr im VFS vorhanden (404).`, { variant: 'warning' });
} else {
this.setState({ isDownloading: false });
enqueueSnackbar(`Fehler beim Herunterladen von "${name}" (${res.status}).`, { variant: 'error' });
}
return;
}
const blob = await res.blob();
const objectUrl = URL.createObjectURL(blob);
const tempLink = document.createElement('a');
tempLink.href = objectUrl;
tempLink.download = name || 'datei';
document.body.appendChild(tempLink);
tempLink.click();
document.body.removeChild(tempLink);
setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
this.setState({ isDownloading: false });
} catch (err) {
this.setState({ isDownloading: false });
enqueueSnackbar(`Netzwerkfehler beim Herunterladen: ${err?.message || err}`, { variant: 'error' });
}
};
handleImageError = () => {
this.setState({ notFound: true });
};
render() {
const { name, path, type, isUser = false } = this.props;
const { isDownloading, notFound } = this.state;
const vfsUrl = resolveVfsUrl(path);
const isImage = !notFound && ((type && type.startsWith('image/')) || /\.(png|jpe?g|webp|gif|svg|avif|bmp|ico)$/i.test(name || path));
if (isImage) {
return (
<Box
component="a"
href={vfsUrl}
onClick={this.handleClick}
title={isDownloading ? 'Wird heruntergeladen…' : `VFS Bild öffnen / herunterladen: ${name}`}
sx={{
display: 'inline-block',
my: 0.6,
maxWidth: 280,
borderRadius: 2,
overflow: 'hidden',
border: '1px solid',
borderColor: isUser ? 'rgba(255, 255, 255, 0.35)' : 'divider',
textDecoration: 'none',
cursor: 'pointer',
opacity: isDownloading ? 0.75 : 1,
transition: 'transform 0.15s, box-shadow 0.15s, opacity 0.15s',
'&:hover': {
transform: 'scale(1.02)',
boxShadow: '0 4px 12px rgba(0,0,0,0.2)',
},
}}
>
<Box
component="img"
src={vfsUrl}
alt={name}
onError={this.handleImageError}
sx={{
width: '100%',
maxHeight: 200,
objectFit: 'cover',
display: 'block',
bgcolor: 'action.hover',
}}
/>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 0.75,
px: 1,
py: 0.5,
bgcolor: isUser ? 'rgba(0,0,0,0.3)' : (theme) => (theme.palette.mode === 'dark' ? 'background.paper' : 'rgba(0,0,0,0.03)'),
color: isUser ? '#fff' : 'text.primary',
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, minWidth: 0 }}>
<InsertPhotoRoundedIcon sx={{ fontSize: 16 }} />
<Typography
variant="caption"
sx={{
fontWeight: 600,
fontSize: '0.72rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{name}
</Typography>
</Box>
{isDownloading && <CircularProgress size={12} color="inherit" thickness={5} />}
</Box>
</Box>
);
}
const titleText = notFound
? `Datei nicht mehr im VFS vorhanden (404): ${name}`
: isDownloading
? `Wird heruntergeladen…`
: `VFS Datei herunterladen / öffnen: ${name}`;
return (
<Tooltip title={titleText} arrow>
<Box
component="a"
href={notFound ? undefined : vfsUrl}
onClick={this.handleClick}
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 1,
my: 0.35,
mx: 0.2,
px: 1.25,
py: 0.65,
borderRadius: 2,
border: '1px solid',
borderColor: notFound
? (isUser ? 'rgba(255,255,255,0.2)' : 'error.light')
: (isUser ? 'rgba(255, 255, 255, 0.35)' : 'divider'),
bgcolor: notFound
? (isUser ? 'rgba(0,0,0,0.2)' : 'action.disabledBackground')
: (isUser
? 'rgba(255, 255, 255, 0.18)'
: (theme) => (theme.palette.mode === 'dark' ? 'background.paper' : 'rgba(0, 0, 0, 0.02)')),
textDecoration: notFound ? 'line-through' : 'none',
color: notFound
? (isUser ? 'rgba(255,255,255,0.6)' : 'text.disabled')
: (isUser ? 'inherit' : 'primary.main'),
opacity: notFound ? 0.7 : (isDownloading ? 0.75 : 1),
cursor: notFound ? 'not-allowed' : 'pointer',
verticalAlign: 'middle',
maxWidth: '100%',
transition: 'all 150ms ease-in-out',
'&:hover': notFound ? {} : {
bgcolor: isUser ? 'rgba(255, 255, 255, 0.28)' : 'action.hover',
borderColor: isUser ? '#fff' : 'primary.main',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
},
}}
>
{isDownloading ? (
<CircularProgress size={16} color="inherit" thickness={5} />
) : notFound ? (
<ErrorOutlineRoundedIcon sx={{ fontSize: 20, color: isUser ? 'inherit' : 'error.main' }} />
) : (
getAttachmentIcon(type, name, isUser)
)}
<Box sx={{ minWidth: 0 }}>
<Typography
component="span"
sx={{
fontSize: '0.82rem',
fontWeight: 600,
color: 'inherit',
display: 'block',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: 260,
lineHeight: 1.25,
textDecoration: notFound ? 'line-through' : 'none',
}}
>
{name}
</Typography>
<Typography
component="span"
sx={{
fontSize: '0.68rem',
opacity: 0.8,
display: 'block',
lineHeight: 1,
}}
>
{notFound ? 'nicht mehr vorhanden (404)' : (type || 'VFS Datei')}
</Typography>
</Box>
</Box>
</Tooltip>
);
}
}
@@ -47,7 +345,7 @@ function getCategoryIcon(category, { size = 20, color } = {}) {
*/
export class ChatStagedAttachmentsTray extends PureComponent {
render() {
const { attachments, onRemove, onPreview } = this.props;
const { attachments, onRemove } = this.props;
if (!attachments || attachments.length === 0) return null;
return (
@@ -82,13 +380,11 @@ export class ChatStagedAttachmentsTray extends PureComponent {
overflow: 'hidden',
border: '1px solid',
borderColor: 'divider',
cursor: onPreview ? 'pointer' : 'default',
flexShrink: 0,
'&:hover .remove-btn': {
opacity: 1,
},
}}
onClick={() => onPreview?.(att)}
>
<Box
component="img"
@@ -148,15 +444,9 @@ export class ChatStagedAttachmentsTray extends PureComponent {
border: '1px solid',
borderColor: 'divider',
maxWidth: 220,
cursor: onPreview ? 'pointer' : 'default',
transition: 'border-color 0.15s',
'&:hover': {
borderColor: 'primary.main',
},
}}
onClick={() => onPreview?.(att)}
>
{getCategoryIcon(att.category, { size: 18 })}
{getAttachmentIcon(att.type, att.name, false)}
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography
variant="caption"
@@ -205,167 +495,3 @@ export class ChatStagedAttachmentsTray extends PureComponent {
);
}
}
/**
* Renders attachments inside a posted message bubble.
*/
export class ChatMessageAttachments extends PureComponent {
render() {
const { attachments, isUser, onPreview } = this.props;
if (!attachments || !Array.isArray(attachments) || attachments.length === 0) {
return null;
}
const images = attachments.filter((a) => a.category === SUPPORTED_CATEGORIES.IMAGE);
const documents = attachments.filter((a) => a.category !== SUPPORTED_CATEGORIES.IMAGE);
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, my: 0.5, maxWidth: '100%' }}>
{/* Images Grid */}
{images.length > 0 && (
<Box
sx={{
display: 'grid',
gridTemplateColumns: images.length === 1 ? '1fr' : 'repeat(auto-fit, minmax(110px, 1fr))',
gap: 0.75,
maxWidth: 380,
}}
>
{images.map((img, idx) => {
const src = img.dataUrl || img.url;
return (
<Box
key={img.id || `img-${idx}`}
onClick={() => onPreview?.(img)}
sx={{
position: 'relative',
borderRadius: 2,
overflow: 'hidden',
cursor: 'pointer',
maxHeight: images.length === 1 ? 260 : 130,
border: '1px solid',
borderColor: isUser ? 'rgba(255,255,255,0.25)' : 'divider',
bgcolor: 'action.hover',
transition: 'transform 0.15s, box-shadow 0.15s',
'&:hover': {
transform: 'scale(1.02)',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
},
}}
>
<Box
component="img"
src={src}
alt={img.name || 'Bildanhang'}
sx={{
width: '100%',
height: '100%',
maxHeight: images.length === 1 ? 260 : 130,
objectFit: 'cover',
display: 'block',
}}
/>
</Box>
);
})}
</Box>
)}
{/* Documents Cards */}
{documents.length > 0 && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
{documents.map((doc, idx) => {
return (
<Box
key={doc.id || `doc-${idx}`}
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.2,
px: 1.25,
py: 0.85,
borderRadius: 2,
bgcolor: isUser
? 'rgba(255, 255, 255, 0.16)'
: (theme) => (theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.05)' : 'rgba(0, 0, 0, 0.03)'),
border: '1px solid',
borderColor: isUser ? 'rgba(255, 255, 255, 0.25)' : 'divider',
color: isUser ? 'inherit' : 'text.primary',
maxWidth: 340,
transition: 'background-color 0.15s',
'&:hover': {
bgcolor: isUser
? 'rgba(255, 255, 255, 0.24)'
: 'action.hover',
},
}}
>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
{getCategoryIcon(doc.category, {
size: 24,
color: isUser ? 'inherit' : undefined,
})}
</Box>
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography
variant="body2"
sx={{
fontWeight: 600,
fontSize: '0.8rem',
lineHeight: 1.25,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
title={doc.name}
>
{doc.name}
</Typography>
<Typography
variant="caption"
sx={{
fontSize: '0.68rem',
opacity: 0.85,
}}
>
{formatFileSize(doc.size)}
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
<Tooltip title="Vorschau / Öffnen">
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
onPreview?.(doc);
}}
sx={{ color: 'inherit', p: 0.5 }}
>
<VisibilityRoundedIcon sx={{ fontSize: 17 }} />
</IconButton>
</Tooltip>
<Tooltip title="Herunterladen">
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
downloadAttachment(doc);
}}
sx={{ color: 'inherit', p: 0.5 }}
>
<DownloadRoundedIcon sx={{ fontSize: 17 }} />
</IconButton>
</Tooltip>
</Box>
</Box>
);
})}
</Box>
)}
</Box>
);
}
}

View File

@@ -1,282 +0,0 @@
/**
* @file ChatAttachmentPreviewModal.jsx
* @description Modal dialog for inspecting chat attachments (Images, PDFs, CSV, Text, XML, HTML).
*/
import React, { PureComponent } from 'react';
import Dialog from '@mui/material/Dialog';
import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
import DialogActions from '@mui/material/DialogActions';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
import Button from '@mui/material/Button';
import Tooltip from '@mui/material/Tooltip';
import Chip from '@mui/material/Chip';
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
import DownloadRoundedIcon from '@mui/icons-material/DownloadRounded';
import ContentCopyRoundedIcon from '@mui/icons-material/ContentCopyRounded';
import CheckRoundedIcon from '@mui/icons-material/CheckRounded';
import PictureAsPdfRoundedIcon from '@mui/icons-material/PictureAsPdfRounded';
import TableChartRoundedIcon from '@mui/icons-material/TableChartRounded';
import CodeRoundedIcon from '@mui/icons-material/CodeRounded';
import DescriptionRoundedIcon from '@mui/icons-material/DescriptionRounded';
import InsertDriveFileRoundedIcon from '@mui/icons-material/InsertDriveFileRounded';
import {
SUPPORTED_CATEGORIES,
formatFileSize,
downloadAttachment,
} from './chatAttachments';
export default class ChatAttachmentPreviewModal extends PureComponent {
state = {
copied: false,
};
handleCopyText = () => {
const { attachment } = this.props;
const text = attachment?.textContent || attachment?.previewSnippet || '';
if (!text) return;
navigator.clipboard?.writeText(text).then(() => {
this.setState({ copied: true });
setTimeout(() => this.setState({ copied: false }), 2000);
});
};
handleDownload = () => {
const { attachment } = this.props;
if (attachment) {
downloadAttachment(attachment);
}
};
renderIcon(category) {
switch (category) {
case SUPPORTED_CATEGORIES.IMAGE:
return null;
case SUPPORTED_CATEGORIES.PDF:
return <PictureAsPdfRoundedIcon sx={{ color: '#e53935' }} />;
case SUPPORTED_CATEGORIES.CSV:
return <TableChartRoundedIcon sx={{ color: '#43a047' }} />;
case SUPPORTED_CATEGORIES.XML:
case SUPPORTED_CATEGORIES.HTML:
return <CodeRoundedIcon sx={{ color: '#fb8c00' }} />;
case SUPPORTED_CATEGORIES.TEXT:
return <DescriptionRoundedIcon sx={{ color: '#0288d1' }} />;
default:
return <InsertDriveFileRoundedIcon sx={{ color: 'text.secondary' }} />;
}
}
renderContent() {
const { attachment } = this.props;
if (!attachment) return null;
const { category, dataUrl, url, textContent, previewSnippet, name } = attachment;
const sourceUrl = dataUrl || url;
const effectiveText = textContent || previewSnippet;
if (category === SUPPORTED_CATEGORIES.IMAGE) {
return (
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
p: 1,
bgcolor: (theme) => (theme.palette.mode === 'dark' ? 'rgba(0,0,0,0.4)' : 'rgba(0,0,0,0.03)'),
borderRadius: 2,
minHeight: 200,
}}
>
<Box
component="img"
src={sourceUrl}
alt={name || 'Anhang'}
sx={{
maxWidth: '100%',
maxHeight: '70vh',
objectFit: 'contain',
borderRadius: 1.5,
boxShadow: (theme) =>
theme.palette.mode === 'dark'
? '0 4px 20px rgba(0,0,0,0.7)'
: '0 4px 20px rgba(0,0,0,0.15)',
}}
/>
</Box>
);
}
if (category === SUPPORTED_CATEGORIES.PDF) {
return (
<Box sx={{ width: '100%', height: '70vh', minHeight: 400 }}>
<Box
component="iframe"
src={sourceUrl}
title={name || 'PDF Vorschau'}
sx={{
width: '100%',
height: '100%',
border: 'none',
borderRadius: 1.5,
bgcolor: 'background.paper',
}}
/>
</Box>
);
}
if (effectiveText !== undefined) {
return (
<Box
sx={{
maxHeight: '65vh',
overflow: 'auto',
p: 2,
borderRadius: 2,
bgcolor: (theme) => (theme.palette.mode === 'dark' ? 'rgba(0,0,0,0.5)' : '#f8f9fa'),
border: '1px solid',
borderColor: 'divider',
}}
>
<Typography
component="pre"
sx={{
m: 0,
fontFamily: 'monospace',
fontSize: '0.82rem',
lineHeight: 1.5,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
color: 'text.primary',
}}
>
{effectiveText}
</Typography>
</Box>
);
}
return (
<Box sx={{ p: 4, textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary">
Keine Direktvorschau verfügbar. Sie können die Datei herunterladen.
</Typography>
</Box>
);
}
render() {
const { open, onClose, attachment } = this.props;
const { copied } = this.state;
if (!attachment) return null;
const { name, size, category } = attachment;
const hasText = Boolean(attachment.textContent || attachment.previewSnippet);
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="md"
fullWidth
sx={{
zIndex: 14000,
'& .MuiDialog-paper': {
borderRadius: 3,
overflow: 'hidden',
},
}}
>
<DialogTitle
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 1.5,
py: 1.5,
px: 2.5,
borderBottom: '1px solid',
borderColor: 'divider',
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.25, minWidth: 0 }}>
{this.renderIcon(category)}
<Typography
variant="subtitle1"
sx={{
fontWeight: 600,
fontSize: '0.95rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{name || 'Anhang'}
</Typography>
<Chip
size="small"
label={formatFileSize(size)}
sx={{ height: 20, fontSize: '0.7rem' }}
/>
</Box>
<IconButton size="small" onClick={onClose} aria-label="Schließen">
<CloseRoundedIcon sx={{ fontSize: 20 }} />
</IconButton>
</DialogTitle>
<DialogContent sx={{ p: 2.5 }}>
{this.renderContent()}
</DialogContent>
<DialogActions
sx={{
px: 2.5,
py: 1.5,
borderTop: '1px solid',
borderColor: 'divider',
justifyContent: 'space-between',
}}
>
<Box>
{hasText && (
<Button
size="small"
variant="outlined"
startIcon={copied ? <CheckRoundedIcon /> : <ContentCopyRoundedIcon />}
onClick={this.handleCopyText}
color={copied ? 'success' : 'inherit'}
sx={{ textTransform: 'none', borderRadius: 2 }}
>
{copied ? 'Kopiert' : 'Text kopieren'}
</Button>
)}
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
size="small"
variant="contained"
startIcon={<DownloadRoundedIcon />}
onClick={this.handleDownload}
sx={{ textTransform: 'none', borderRadius: 2 }}
>
Herunterladen
</Button>
<Button
size="small"
onClick={onClose}
sx={{ textTransform: 'none' }}
>
Schließen
</Button>
</Box>
</DialogActions>
</Dialog>
);
}
}

View File

@@ -26,7 +26,6 @@ import ChatFlowList from './ChatFlowList';
import AskUserPrompt from './AskUserPrompt';
import { ACCEPT_STRING, readFileAttachment } from './chatAttachments';
import { ChatStagedAttachmentsTray } from './ChatAttachmentItems';
import ChatAttachmentPreviewModal from './ChatAttachmentPreviewModal';
import {
BLUE_GLOW,
SHADOW_DRAWER,
@@ -61,7 +60,6 @@ export default class ChatDrawer extends PureComponent {
isProcessingFiles: false,
isDraggingFiles: false,
attachmentError: null,
previewAttachment: null,
drawerWidth: settings?.chatDrawerWidth || DEFAULT_DRAWER_WIDTH,
isResizing: false,
};
@@ -273,14 +271,6 @@ export default class ChatDrawer extends PureComponent {
}));
};
handlePreviewAttachment = (attachment) => {
this.setState({ previewAttachment: attachment });
};
handleClosePreview = () => {
this.setState({ previewAttachment: null });
};
handlePaste = (e) => {
const items = e.clipboardData?.items;
if (!items || items.length === 0) return;
@@ -571,7 +561,6 @@ export default class ChatDrawer extends PureComponent {
isProcessingFiles,
isDraggingFiles,
attachmentError,
previewAttachment,
} = this.state;
const pendingAsk = this.getPendingAskUser();
@@ -652,7 +641,6 @@ export default class ChatDrawer extends PureComponent {
<ChatStagedAttachmentsTray
attachments={stagedAttachments}
onRemove={this.handleRemoveAttachment}
onPreview={this.handlePreviewAttachment}
/>
{isProcessingFiles && (
@@ -775,13 +763,6 @@ export default class ChatDrawer extends PureComponent {
)}
</IconButton>
</Box>
{/* Modal for previewing staged attachments */}
<ChatAttachmentPreviewModal
open={Boolean(previewAttachment)}
attachment={previewAttachment}
onClose={this.handleClosePreview}
/>
</Box>
);
}

View File

@@ -23,8 +23,7 @@ import EscalatorWarningIcon from '@mui/icons-material/EscalatorWarning';
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
import ManageSearchIcon from '@mui/icons-material/ManageSearch';
import HealingMarkdown from './HealingMarkdown';
import { ChatMessageAttachments } from './ChatAttachmentItems';
import ChatAttachmentPreviewModal from './ChatAttachmentPreviewModal';
import { VfsAttachmentLink, parseAttachmentsFromText } from './ChatAttachmentItems';
import {
CHROMA,
SHADOW_BUBBLE_ASSISTANT,
@@ -727,30 +726,13 @@ export function normalizeChatMessages(rawMessages = []) {
}
const payload = parseMessagePayload(msg.message);
let attachments = Array.isArray(msg.attachments) ? [...msg.attachments] : [];
if (attachments.length === 0 && Array.isArray(payload.attachments)) {
attachments = [...payload.attachments];
}
let userText = payload.content !== undefined ? payload.content : msg.message;
if (typeof userText === 'string' && userText.startsWith('{')) {
try {
const nested = JSON.parse(userText);
if (nested && typeof nested === 'object') {
if (nested.content !== undefined) userText = nested.content;
if (Array.isArray(nested.attachments) && attachments.length === 0) {
attachments = [...nested.attachments];
}
}
} catch {}
}
const userText = payload.content !== undefined ? payload.content : msg.message;
if (msg.role === 'user') {
flushAssistant();
result.push({
...msg,
message: userText,
attachments,
});
continue;
}
@@ -846,7 +828,7 @@ export function normalizeChatMessages(rawMessages = []) {
return result;
}
function MessageRow({ msg, showTime = true, onPreviewAttachment }) {
function MessageRow({ msg, showTime = true }) {
// If the message has structured turns, render each turn
if (msg.turns && Array.isArray(msg.turns) && msg.turns.length > 0) {
return (
@@ -860,22 +842,17 @@ function MessageRow({ msg, showTime = true, onPreviewAttachment }) {
const payload = parseMessagePayload(msg.message);
let textContent = payload.content !== undefined ? payload.content : msg.message;
let attachments = Array.isArray(msg.attachments) ? msg.attachments : [];
if (attachments.length === 0 && Array.isArray(payload.attachments)) {
attachments = payload.attachments;
}
if (typeof textContent === 'string' && textContent.startsWith('{')) {
try {
const nested = JSON.parse(textContent);
if (nested && typeof nested === 'object') {
if (nested.content !== undefined) textContent = nested.content;
if (Array.isArray(nested.attachments) && attachments.length === 0) {
attachments = nested.attachments;
}
if (nested && typeof nested === 'object' && nested.content !== undefined) {
textContent = nested.content;
}
} catch {}
}
const { attachments: vfsAttachments, cleanText } = parseAttachmentsFromText(textContent);
if (msg.role === 'system') {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 0.75, px: 2 }}>
@@ -952,19 +929,32 @@ function MessageRow({ msg, showTime = true, onPreviewAttachment }) {
lineHeight: 1.5,
})}
>
{attachments && attachments.length > 0 && (
<ChatMessageAttachments
attachments={attachments}
isUser={isUser}
onPreview={onPreviewAttachment}
/>
{vfsAttachments.length > 0 && (
<Box
sx={{
display: 'flex',
flexWrap: 'wrap',
gap: 0.75,
mb: cleanText ? 0.75 : 0,
}}
>
{vfsAttachments.map((att, idx) => (
<VfsAttachmentLink
key={att.path || idx}
name={att.name}
path={att.path}
type={att.type}
isUser={isUser}
/>
))}
</Box>
)}
{textContent ? (
{cleanText ? (
isUser ? (
textContent
cleanText
) : (
<HealingMarkdown text={textContent} streaming={msg.streaming} />
<HealingMarkdown text={cleanText} streaming={msg.streaming} />
)
) : null}
</Box>
@@ -978,18 +968,6 @@ export default class ChatFlowList extends PureComponent {
bottomRef = createRef();
scrollTimeout = null;
state = {
previewAttachment: null,
};
handlePreviewAttachment = (attachment) => {
this.setState({ previewAttachment: attachment });
};
handleClosePreview = () => {
this.setState({ previewAttachment: null });
};
componentDidMount() {
this.scrollToBottom('auto');
}
@@ -1031,7 +1009,6 @@ export default class ChatFlowList extends PureComponent {
render() {
const { messages = [], activeTurns = [] } = this.props;
const { previewAttachment } = this.state;
const normalizedMessages = normalizeChatMessages(messages);
if (normalizedMessages.length === 0 && activeTurns.length === 0) {
@@ -1065,7 +1042,6 @@ export default class ChatFlowList extends PureComponent {
key={msg.id || index}
msg={msg}
showTime={showTime}
onPreviewAttachment={this.handlePreviewAttachment}
/>
);
})}
@@ -1078,12 +1054,6 @@ export default class ChatFlowList extends PureComponent {
: null}
<div ref={this.bottomRef} style={{ height: 1, flexShrink: 0 }} />
<ChatAttachmentPreviewModal
open={Boolean(previewAttachment)}
attachment={previewAttachment}
onClose={this.handleClosePreview}
/>
</Box>
);
}

View File

@@ -9,6 +9,7 @@ import {
TABLE_HEADER_PAIR,
byMode,
} from '../../colors';
import { VfsAttachmentLink, parseAttachmentTag } from './ChatAttachmentItems';
/**
* Healing markdown renderer.
@@ -522,6 +523,28 @@ function parseInline(src, keyPrefix = 'i') {
}
if (c === '[') {
// Attachment tag: [ATTACHMENT name="..." path="..." type=...]
if (src.startsWith('[ATTACHMENT', i)) {
const closeTag = src.indexOf(']', i + 11);
if (closeTag !== -1) {
const attrStr = src.slice(i + 11, closeTag);
const parsed = parseAttachmentTag(attrStr);
if (parsed.name || parsed.path) {
push(
<VfsAttachmentLink
key={key()}
name={parsed.name}
path={parsed.path}
type={parsed.type}
isUser={false}
/>,
);
i = closeTag + 1;
continue;
}
}
}
const close = src.indexOf(']', i + 1);
if (close !== -1) {
const label = src.slice(i + 1, close);