This commit is contained in:
seb
2026-10-06 11:40:02 +02:00
parent 03608017f3
commit 4769a87c54
6 changed files with 1197 additions and 20 deletions

View File

@@ -962,11 +962,22 @@ const actions = {
/** /**
* Send a user message via POST /api/modelconversation/:id/message. * Send a user message via POST /api/modelconversation/:id/message.
* Rejects immediately if conversation is busy (409 Conflict avoidance). * Rejects immediately if conversation is busy (409 Conflict avoidance).
* @param {string} text * @param {string|{ text?: string, attachments?: object[] }} textOrPayload
* @param {object[]} [maybeAttachments]
*/ */
async sendMessage(text) { async sendMessage(textOrPayload, maybeAttachments = []) {
const trimmed = text?.trim(); let trimmed = '';
if (!trimmed) return; let fileAttachments = [];
if (typeof textOrPayload === 'object' && textOrPayload !== null) {
trimmed = (textOrPayload.text || '').trim();
fileAttachments = Array.isArray(textOrPayload.attachments) ? textOrPayload.attachments : [];
} else {
trimmed = (textOrPayload || '').trim();
fileAttachments = Array.isArray(maybeAttachments) ? maybeAttachments : [];
}
if (!trimmed && fileAttachments.length === 0) return;
if (!chatAutomat.state.selectedConversationId && chatAutomat.state.draftConversation) { if (!chatAutomat.state.selectedConversationId && chatAutomat.state.draftConversation) {
// Draft: the conversation really starts now, with the first message. // Draft: the conversation really starts now, with the first message.
@@ -983,11 +994,17 @@ const actions = {
} }
const tempId = `temp-${Date.now()}`; const tempId = `temp-${Date.now()}`;
const payloadMessage = fileAttachments.length > 0
? JSON.stringify({ content: trimmed, attachments: fileAttachments })
: trimmed;
const optimisticMsg = { const optimisticMsg = {
id: tempId, id: tempId,
conversation_id: selectedConversationId, conversation_id: selectedConversationId,
role: 'user', role: 'user',
message: trimmed, message: payloadMessage,
content: trimmed,
attachments: fileAttachments,
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
}; };
@@ -1014,8 +1031,14 @@ const actions = {
})); }));
try { try {
const res = await modelconversationApi.addMessage(selectedConversationId, { message: trimmed }); const res = await modelconversationApi.addMessage(selectedConversationId, {
message: payloadMessage,
attachments: fileAttachments.length > 0 ? fileAttachments : undefined,
});
const createdMsg = res?.message || optimisticMsg; const createdMsg = res?.message || optimisticMsg;
if (!createdMsg.attachments && fileAttachments.length > 0) {
createdMsg.attachments = fileAttachments;
}
chatAutomat.setState((prev) => { chatAutomat.setState((prev) => {
const alreadyHasRealId = prev.messages.some((m) => m.id === createdMsg.id && m.id !== tempId); const alreadyHasRealId = prev.messages.some((m) => m.id === createdMsg.id && m.id !== tempId);
if (alreadyHasRealId) { if (alreadyHasRealId) {

View File

@@ -0,0 +1,371 @@
/**
* @file ChatAttachmentItems.jsx
* @description UI components for rendering staged attachments in the input tray,
* and attached files inside message bubbles.
*/
import React, { PureComponent } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
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 {
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} />;
}
}
/**
* Tray showing files staged for posting above the chat input field.
*/
export class ChatStagedAttachmentsTray extends PureComponent {
render() {
const { attachments, onRemove, onPreview } = this.props;
if (!attachments || attachments.length === 0) return null;
return (
<Box
sx={{
display: 'flex',
flexWrap: 'wrap',
gap: 1,
p: 1,
mb: 0.5,
borderRadius: 2,
bgcolor: (theme) => (theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.03)'),
border: '1px dashed',
borderColor: 'divider',
maxHeight: 140,
overflowY: 'auto',
}}
>
{attachments.map((att, idx) => {
const isImage = att.category === SUPPORTED_CATEGORIES.IMAGE;
const key = att.id || `staged-${idx}`;
if (isImage) {
return (
<Box
key={key}
sx={{
position: 'relative',
width: 54,
height: 54,
borderRadius: 1.5,
overflow: 'hidden',
border: '1px solid',
borderColor: 'divider',
cursor: onPreview ? 'pointer' : 'default',
flexShrink: 0,
'&:hover .remove-btn': {
opacity: 1,
},
}}
onClick={() => onPreview?.(att)}
>
<Box
component="img"
src={att.dataUrl || att.url}
alt={att.name}
sx={{
width: '100%',
height: '100%',
objectFit: 'cover',
display: 'block',
}}
/>
{onRemove && (
<IconButton
size="small"
className="remove-btn"
onClick={(e) => {
e.stopPropagation();
onRemove(att.id || idx);
}}
sx={{
position: 'absolute',
top: 2,
right: 2,
width: 18,
height: 18,
bgcolor: 'rgba(0,0,0,0.65)',
color: '#fff',
opacity: 0.8,
p: 0,
transition: 'opacity 0.15s',
'&:hover': {
bgcolor: 'rgba(0,0,0,0.9)',
},
}}
aria-label="Anhang entfernen"
>
<CloseRoundedIcon sx={{ fontSize: 13 }} />
</IconButton>
)}
</Box>
);
}
// Document / file chip
return (
<Box
key={key}
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.75,
px: 1,
py: 0.5,
borderRadius: 2,
bgcolor: 'background.paper',
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 })}
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography
variant="caption"
sx={{
fontWeight: 600,
fontSize: '0.72rem',
lineHeight: 1.2,
display: 'block',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
title={att.name}
>
{att.name}
</Typography>
<Typography
variant="caption"
sx={{
color: 'text.secondary',
fontSize: '0.65rem',
lineHeight: 1,
}}
>
{formatFileSize(att.size)}
</Typography>
</Box>
{onRemove && (
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
onRemove(att.id || idx);
}}
sx={{ p: 0.2, ml: 0.25 }}
aria-label="Anhang entfernen"
>
<CloseRoundedIcon sx={{ fontSize: 14 }} />
</IconButton>
)}
</Box>
);
})}
</Box>
);
}
}
/**
* 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

@@ -0,0 +1,282 @@
/**
* @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

@@ -16,10 +16,17 @@ import AddRoundedIcon from '@mui/icons-material/AddRounded';
import AutoAwesomeRoundedIcon from '@mui/icons-material/AutoAwesomeRounded'; import AutoAwesomeRoundedIcon from '@mui/icons-material/AutoAwesomeRounded';
import DeleteOutlineRoundedIcon from '@mui/icons-material/DeleteOutlineRounded'; import DeleteOutlineRoundedIcon from '@mui/icons-material/DeleteOutlineRounded';
import Tooltip from '@mui/material/Tooltip';
import Badge from '@mui/material/Badge';
import AttachFileRoundedIcon from '@mui/icons-material/AttachFileRounded';
import chatAutomat, { selectPendingAskUser } from '../../automats/chatAutomat'; import chatAutomat, { selectPendingAskUser } from '../../automats/chatAutomat';
import userSettingsAutomat from '../../automats/userSettingsAutomat'; import userSettingsAutomat from '../../automats/userSettingsAutomat';
import ChatFlowList from './ChatFlowList'; import ChatFlowList from './ChatFlowList';
import AskUserPrompt from './AskUserPrompt'; import AskUserPrompt from './AskUserPrompt';
import { ACCEPT_STRING, readFileAttachment } from './chatAttachments';
import { ChatStagedAttachmentsTray } from './ChatAttachmentItems';
import ChatAttachmentPreviewModal from './ChatAttachmentPreviewModal';
import { import {
BLUE_GLOW, BLUE_GLOW,
SHADOW_DRAWER, SHADOW_DRAWER,
@@ -50,10 +57,16 @@ export default class ChatDrawer extends PureComponent {
sendError: s.sendError, sendError: s.sendError,
activeTurns: s.activeTurns || [], activeTurns: s.activeTurns || [],
inputText: '', inputText: '',
stagedAttachments: [],
isProcessingFiles: false,
isDraggingFiles: false,
attachmentError: null,
previewAttachment: null,
drawerWidth: settings?.chatDrawerWidth || DEFAULT_DRAWER_WIDTH, drawerWidth: settings?.chatDrawerWidth || DEFAULT_DRAWER_WIDTH,
isResizing: false, isResizing: false,
}; };
this.inputRef = React.createRef(); this.inputRef = React.createRef();
this.fileInputRef = React.createRef();
this.focusTimeout = null; this.focusTimeout = null;
} }
@@ -204,17 +217,122 @@ export default class ChatDrawer extends PureComponent {
handleSend = async (e) => { handleSend = async (e) => {
e?.preventDefault(); e?.preventDefault();
const text = this.state.inputText.trim(); const text = this.state.inputText.trim();
if (!text || this.state.conversationState === 'busy' || this.state.isSending) { const attachments = this.state.stagedAttachments || [];
if ((!text && attachments.length === 0) || this.state.conversationState === 'busy' || this.state.isSending) {
return; return;
} }
this.setState({ inputText: '' }); this.setState({ inputText: '', stagedAttachments: [], attachmentError: null });
try { try {
await chatAutomat.actions.sendMessage(text); await chatAutomat.actions.sendMessage(text, attachments);
} catch { } catch {
// Error is tracked in chatAutomat state (sendError) // Error is tracked in chatAutomat state (sendError)
} }
}; };
handleOpenFileDialog = () => {
this.fileInputRef.current?.click();
};
handleFileSelect = (e) => {
const files = e.target?.files;
if (files && files.length > 0) {
void this.handleFiles(Array.from(files));
}
if (e.target) {
e.target.value = '';
}
};
handleFiles = async (fileList) => {
if (!fileList || fileList.length === 0) return;
this.setState({ isProcessingFiles: true, attachmentError: null });
const newAttachments = [];
const errors = [];
for (const file of fileList) {
try {
const att = await readFileAttachment(file);
newAttachments.push(att);
} catch (err) {
errors.push(err.message || `Datei "${file.name}" konnte nicht verarbeitet werden.`);
}
}
this.setState((prev) => ({
stagedAttachments: [...prev.stagedAttachments, ...newAttachments],
isProcessingFiles: false,
attachmentError: errors.length > 0 ? errors.join(' ') : null,
}));
this.focusInput(50);
};
handleRemoveAttachment = (idOrIdx) => {
this.setState((prev) => ({
stagedAttachments: prev.stagedAttachments.filter((a, idx) => (a.id ? a.id !== idOrIdx : idx !== idOrIdx)),
}));
};
handlePreviewAttachment = (attachment) => {
this.setState({ previewAttachment: attachment });
};
handleClosePreview = () => {
this.setState({ previewAttachment: null });
};
handlePaste = (e) => {
const items = e.clipboardData?.items;
if (!items || items.length === 0) return;
const files = [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.kind === 'file') {
const file = item.getAsFile();
if (file) files.push(file);
}
}
if (files.length > 0) {
e.preventDefault();
void this.handleFiles(files);
}
};
handleDragEnter = (e) => {
e.preventDefault();
e.stopPropagation();
if (e.dataTransfer?.types?.includes('Files')) {
this.setState({ isDraggingFiles: true });
}
};
handleDragOver = (e) => {
e.preventDefault();
e.stopPropagation();
if (!this.state.isDraggingFiles && e.dataTransfer?.types?.includes('Files')) {
this.setState({ isDraggingFiles: true });
}
};
handleDragLeave = (e) => {
e.preventDefault();
e.stopPropagation();
if (e.currentTarget.contains(e.relatedTarget)) return;
this.setState({ isDraggingFiles: false });
};
handleDrop = (e) => {
e.preventDefault();
e.stopPropagation();
this.setState({ isDraggingFiles: false });
const files = e.dataTransfer?.files;
if (files && files.length > 0) {
void this.handleFiles(Array.from(files));
}
};
handleKeyDown = (e) => { handleKeyDown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) { if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
@@ -449,26 +567,37 @@ export default class ChatDrawer extends PureComponent {
draftConversation, draftConversation,
inputText, inputText,
sendError, sendError,
stagedAttachments,
isProcessingFiles,
isDraggingFiles,
attachmentError,
previewAttachment,
} = this.state; } = this.state;
const pendingAsk = this.getPendingAskUser(); const pendingAsk = this.getPendingAskUser();
const isBusy = conversationState === 'busy'; const isBusy = conversationState === 'busy';
const isWorking = isBusy || isSending; const isWorking = isBusy || isSending;
const canChat = (Boolean(selectedConversationId) || draftConversation) && !pendingAsk; const canChat = (Boolean(selectedConversationId) || draftConversation) && !pendingAsk;
const canSend = !isWorking && canChat && Boolean(inputText.trim()); const canSend = !isWorking && canChat && (Boolean(inputText.trim()) || stagedAttachments.length > 0);
return ( return (
<Box <Box
component="form" component="form"
onSubmit={this.handleSend} onSubmit={this.handleSend}
onDragEnter={this.handleDragEnter}
onDragOver={this.handleDragOver}
onDragLeave={this.handleDragLeave}
onDrop={this.handleDrop}
sx={{ sx={{
p: 2, p: 2,
borderTop: 1, borderTop: 1,
borderColor: 'divider', borderColor: isDraggingFiles ? 'primary.main' : 'divider',
bgcolor: 'background.paper', bgcolor: isDraggingFiles ? 'action.hover' : 'background.paper',
transition: 'border-color 0.15s, background-color 0.15s',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
gap: 1, gap: 1,
position: 'relative',
}} }}
> >
{sendError && ( {sendError && (
@@ -495,7 +624,105 @@ export default class ChatDrawer extends PureComponent {
</Box> </Box>
)} )}
{attachmentError && (
<Box
sx={{
p: 1,
borderRadius: 1.5,
bgcolor: 'warning.light',
color: 'warning.contrastText',
fontSize: '0.75rem',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<Typography variant="caption">{attachmentError}</Typography>
<IconButton
size="small"
onClick={() => this.setState({ attachmentError: null })}
sx={{ color: 'inherit', p: 0.2 }}
>
<CloseRoundedIcon sx={{ fontSize: 14 }} />
</IconButton>
</Box>
)}
{/* Staged attachments tray */}
<ChatStagedAttachmentsTray
attachments={stagedAttachments}
onRemove={this.handleRemoveAttachment}
onPreview={this.handlePreviewAttachment}
/>
{isProcessingFiles && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.5, px: 1 }}>
<CircularProgress size={14} thickness={5} />
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: '0.72rem' }}>
Dateien werden eingelesen…
</Typography>
</Box>
)}
{isDraggingFiles && (
<Box
sx={{
p: 1.5,
border: '2px dashed',
borderColor: 'primary.main',
borderRadius: 2,
textAlign: 'center',
bgcolor: 'action.selected',
color: 'primary.main',
fontWeight: 500,
fontSize: '0.85rem',
}}
>
Dateien hier ablegen (Bilder, PDFs, CSV, Text, XML, HTML)
</Box>
)}
<Box sx={{ display: 'flex', alignItems: 'flex-end', gap: 1 }}> <Box sx={{ display: 'flex', alignItems: 'flex-end', gap: 1 }}>
{/* Attachment Icon Button */}
<Tooltip title="Dateien anhängen (Bilder, PDFs, CSV, Text, XML, HTML)">
<span>
<IconButton
size="small"
onClick={this.handleOpenFileDialog}
disabled={!canChat || isWorking}
aria-label="Dateien anhängen"
sx={{
width: 40,
height: 40,
borderRadius: 2.5,
color: stagedAttachments.length > 0 ? 'primary.main' : 'text.secondary',
bgcolor: stagedAttachments.length > 0 ? 'action.selected' : 'transparent',
border: '1px solid',
borderColor: stagedAttachments.length > 0 ? 'primary.main' : 'transparent',
flexShrink: 0,
'&:hover': {
bgcolor: 'action.hover',
color: 'primary.main',
},
}}
>
<Badge badgeContent={stagedAttachments.length} color="primary">
<AttachFileRoundedIcon sx={{ fontSize: 22 }} />
</Badge>
</IconButton>
</span>
</Tooltip>
{/* Hidden File Input */}
<input
ref={this.fileInputRef}
type="file"
multiple
accept={ACCEPT_STRING}
onChange={this.handleFileSelect}
style={{ display: 'none' }}
/>
<TextInput <TextInput
inputRef={this.inputRef} inputRef={this.inputRef}
multiline multiline
@@ -504,11 +731,14 @@ export default class ChatDrawer extends PureComponent {
value={inputText} value={inputText}
onChange={(e) => this.setState({ inputText: e.target.value })} onChange={(e) => this.setState({ inputText: e.target.value })}
onKeyDown={this.handleKeyDown} onKeyDown={this.handleKeyDown}
onPaste={this.handlePaste}
disabled={!canChat} disabled={!canChat}
placeholder={ placeholder={
pendingAsk pendingAsk
? 'Bitte Frage oben beantworten…' ? 'Bitte Frage oben beantworten…'
: undefined : stagedAttachments.length > 0
? 'Nachricht hinzufügen oder direkt absenden…'
: undefined
} }
/> />
@@ -527,6 +757,7 @@ export default class ChatDrawer extends PureComponent {
width: 40, width: 40,
height: 40, height: 40,
borderRadius: 2.5, borderRadius: 2.5,
flexShrink: 0,
'&:hover': { '&:hover': {
bgcolor: 'primary.dark', bgcolor: 'primary.dark',
}, },
@@ -544,6 +775,13 @@ export default class ChatDrawer extends PureComponent {
)} )}
</IconButton> </IconButton>
</Box> </Box>
{/* Modal for previewing staged attachments */}
<ChatAttachmentPreviewModal
open={Boolean(previewAttachment)}
attachment={previewAttachment}
onClose={this.handleClosePreview}
/>
</Box> </Box>
); );
} }

View File

@@ -23,6 +23,8 @@ import EscalatorWarningIcon from '@mui/icons-material/EscalatorWarning';
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf'; import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
import ManageSearchIcon from '@mui/icons-material/ManageSearch'; import ManageSearchIcon from '@mui/icons-material/ManageSearch';
import HealingMarkdown from './HealingMarkdown'; import HealingMarkdown from './HealingMarkdown';
import { ChatMessageAttachments } from './ChatAttachmentItems';
import ChatAttachmentPreviewModal from './ChatAttachmentPreviewModal';
import { import {
CHROMA, CHROMA,
SHADOW_BUBBLE_ASSISTANT, SHADOW_BUBBLE_ASSISTANT,
@@ -725,12 +727,30 @@ export function normalizeChatMessages(rawMessages = []) {
} }
const payload = parseMessagePayload(msg.message); 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 {}
}
if (msg.role === 'user') { if (msg.role === 'user') {
flushAssistant(); flushAssistant();
result.push({ result.push({
...msg, ...msg,
message: payload.content !== undefined ? payload.content : msg.message, message: userText,
attachments,
}); });
continue; continue;
} }
@@ -826,7 +846,7 @@ export function normalizeChatMessages(rawMessages = []) {
return result; return result;
} }
function MessageRow({ msg, showTime = true }) { function MessageRow({ msg, showTime = true, onPreviewAttachment }) {
// If the message has structured turns, render each turn // If the message has structured turns, render each turn
if (msg.turns && Array.isArray(msg.turns) && msg.turns.length > 0) { if (msg.turns && Array.isArray(msg.turns) && msg.turns.length > 0) {
return ( return (
@@ -839,7 +859,22 @@ function MessageRow({ msg, showTime = true }) {
} }
const payload = parseMessagePayload(msg.message); const payload = parseMessagePayload(msg.message);
const textContent = payload.content !== undefined ? payload.content : 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;
}
}
} catch {}
}
if (msg.role === 'system') { if (msg.role === 'system') {
return ( return (
@@ -917,11 +952,21 @@ function MessageRow({ msg, showTime = true }) {
lineHeight: 1.5, lineHeight: 1.5,
})} })}
> >
{isUser ? ( {attachments && attachments.length > 0 && (
textContent <ChatMessageAttachments
) : ( attachments={attachments}
<HealingMarkdown text={textContent} streaming={msg.streaming} /> isUser={isUser}
onPreview={onPreviewAttachment}
/>
)} )}
{textContent ? (
isUser ? (
textContent
) : (
<HealingMarkdown text={textContent} streaming={msg.streaming} />
)
) : null}
</Box> </Box>
</Box> </Box>
</Box> </Box>
@@ -933,6 +978,18 @@ export default class ChatFlowList extends PureComponent {
bottomRef = createRef(); bottomRef = createRef();
scrollTimeout = null; scrollTimeout = null;
state = {
previewAttachment: null,
};
handlePreviewAttachment = (attachment) => {
this.setState({ previewAttachment: attachment });
};
handleClosePreview = () => {
this.setState({ previewAttachment: null });
};
componentDidMount() { componentDidMount() {
this.scrollToBottom('auto'); this.scrollToBottom('auto');
} }
@@ -974,6 +1031,7 @@ export default class ChatFlowList extends PureComponent {
render() { render() {
const { messages = [], activeTurns = [] } = this.props; const { messages = [], activeTurns = [] } = this.props;
const { previewAttachment } = this.state;
const normalizedMessages = normalizeChatMessages(messages); const normalizedMessages = normalizeChatMessages(messages);
if (normalizedMessages.length === 0 && activeTurns.length === 0) { if (normalizedMessages.length === 0 && activeTurns.length === 0) {
@@ -1007,6 +1065,7 @@ export default class ChatFlowList extends PureComponent {
key={msg.id || index} key={msg.id || index}
msg={msg} msg={msg}
showTime={showTime} showTime={showTime}
onPreviewAttachment={this.handlePreviewAttachment}
/> />
); );
})} })}
@@ -1019,6 +1078,12 @@ export default class ChatFlowList extends PureComponent {
: null} : null}
<div ref={this.bottomRef} style={{ height: 1, flexShrink: 0 }} /> <div ref={this.bottomRef} style={{ height: 1, flexShrink: 0 }} />
<ChatAttachmentPreviewModal
open={Boolean(previewAttachment)}
attachment={previewAttachment}
onClose={this.handleClosePreview}
/>
</Box> </Box>
); );
} }

View File

@@ -0,0 +1,198 @@
/**
* @file chatAttachments.js
* @description Helpers for handling file attachments in the AI chat window.
* Supports: images, PDFs, CSV, plain text, XML, and HTML.
*/
export const SUPPORTED_CATEGORIES = {
IMAGE: 'image',
PDF: 'pdf',
CSV: 'csv',
TEXT: 'text',
XML: 'xml',
HTML: 'html',
};
export const ACCEPT_STRING = [
'image/*',
'application/pdf',
'.pdf',
'text/csv',
'.csv',
'text/plain',
'.txt',
'.md',
'.log',
'text/xml',
'application/xml',
'.xml',
'text/html',
'.html',
'.htm',
].join(',');
export const MAX_FILE_SIZE = 25 * 1024 * 1024; // 25 MB
/**
* Determine the attachment category based on MIME type or file extension.
* @param {File|{ name: string, type?: string }} file
* @returns {'image'|'pdf'|'csv'|'text'|'xml'|'html'|'unknown'}
*/
export function categorizeFile(file) {
const name = (file?.name || '').toLowerCase();
const type = (file?.type || '').toLowerCase();
// Images
if (type.startsWith('image/') || /\.(png|jpe?g|webp|gif|svg|avif|bmp|ico)$/.test(name)) {
return SUPPORTED_CATEGORIES.IMAGE;
}
// PDFs
if (type === 'application/pdf' || name.endsWith('.pdf')) {
return SUPPORTED_CATEGORIES.PDF;
}
// CSV
if (
type === 'text/csv' ||
type === 'application/csv' ||
type === 'text/comma-separated-values' ||
name.endsWith('.csv')
) {
return SUPPORTED_CATEGORIES.CSV;
}
// XML
if (type === 'text/xml' || type === 'application/xml' || name.endsWith('.xml')) {
return SUPPORTED_CATEGORIES.XML;
}
// HTML
if (type === 'text/html' || name.endsWith('.html') || name.endsWith('.htm')) {
return SUPPORTED_CATEGORIES.HTML;
}
// Plain Text / Markdown / Log
if (
type.startsWith('text/') ||
type === 'application/json' ||
/\.(txt|md|log|json|ini|env|yaml|yml)$/.test(name)
) {
return SUPPORTED_CATEGORIES.TEXT;
}
return 'unknown';
}
/**
* Human-readable format of file size in B, KB, or MB.
* @param {number} bytes
* @returns {string}
*/
export function formatFileSize(bytes) {
if (!bytes || Number.isNaN(bytes) || bytes <= 0) return '0 B';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
/**
* Read a browser File object and convert it to a structured attachment object.
* @param {File} file
* @returns {Promise<object>}
*/
export async function readFileAttachment(file) {
const category = categorizeFile(file);
if (category === 'unknown') {
throw new Error(`Das Dateiformat von "${file.name}" wird nicht unterstützt. Erlaubt sind Bilder, PDFs, CSV, Text, XML und HTML.`);
}
if (file.size > MAX_FILE_SIZE) {
throw new Error(`Die Datei "${file.name}" ist mit ${formatFileSize(file.size)} zu groß (maximal 25 MB).`);
}
const id = `att-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
// For text-based formats, read as text and dataUrl
const isTextBased =
category === SUPPORTED_CATEGORIES.CSV ||
category === SUPPORTED_CATEGORIES.TEXT ||
category === SUPPORTED_CATEGORIES.XML ||
category === SUPPORTED_CATEGORIES.HTML;
const dataUrl = await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => reject(new Error(`Fehler beim Lesen der Datei "${file.name}"`));
reader.readAsDataURL(file);
});
let textContent = '';
let previewSnippet = '';
if (isTextBased) {
try {
textContent = await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => reject(new Error(`Fehler beim Lesen von "${file.name}" als Text`));
reader.readAsText(file);
});
// First 25 lines or 2000 chars as preview
previewSnippet = textContent.slice(0, 2000);
} catch {
// Non-fatal if text reading fails
}
}
return {
id,
name: file.name,
size: file.size,
mimeType: file.type || getMimeTypeFallback(category, file.name),
category,
dataUrl,
textContent: textContent || undefined,
previewSnippet: previewSnippet || undefined,
uploadedAt: new Date().toISOString(),
};
}
/**
* Fallback MIME type based on extension/category.
*/
function getMimeTypeFallback(category, filename) {
const ext = (filename.split('.').pop() || '').toLowerCase();
switch (category) {
case SUPPORTED_CATEGORIES.IMAGE:
if (ext === 'png') return 'image/png';
if (ext === 'webp') return 'image/webp';
if (ext === 'svg') return 'image/svg+xml';
if (ext === 'gif') return 'image/gif';
return 'image/jpeg';
case SUPPORTED_CATEGORIES.PDF:
return 'application/pdf';
case SUPPORTED_CATEGORIES.CSV:
return 'text/csv';
case SUPPORTED_CATEGORIES.XML:
return 'application/xml';
case SUPPORTED_CATEGORIES.HTML:
return 'text/html';
default:
return 'text/plain';
}
}
/**
* Trigger download of an attachment in the browser.
* @param {object} attachment
*/
export function downloadAttachment(attachment) {
if (!attachment) return;
const link = document.createElement('a');
link.href = attachment.dataUrl || (attachment.url ? attachment.url : '');
link.download = attachment.name || 'anhang';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}