diff --git a/src/automats/chatAutomat.js b/src/automats/chatAutomat.js
index 87ccdd1..764db82 100644
--- a/src/automats/chatAutomat.js
+++ b/src/automats/chatAutomat.js
@@ -962,11 +962,22 @@ const actions = {
/**
* Send a user message via POST /api/modelconversation/:id/message.
* 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) {
- const trimmed = text?.trim();
- if (!trimmed) return;
+ async sendMessage(textOrPayload, maybeAttachments = []) {
+ let trimmed = '';
+ 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) {
// Draft: the conversation really starts now, with the first message.
@@ -983,11 +994,17 @@ const actions = {
}
const tempId = `temp-${Date.now()}`;
+ const payloadMessage = fileAttachments.length > 0
+ ? JSON.stringify({ content: trimmed, attachments: fileAttachments })
+ : trimmed;
+
const optimisticMsg = {
id: tempId,
conversation_id: selectedConversationId,
role: 'user',
- message: trimmed,
+ message: payloadMessage,
+ content: trimmed,
+ attachments: fileAttachments,
created_at: new Date().toISOString(),
};
@@ -1014,8 +1031,14 @@ const actions = {
}));
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;
+ 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) {
diff --git a/src/components/chat/ChatAttachmentItems.jsx b/src/components/chat/ChatAttachmentItems.jsx
new file mode 100644
index 0000000..0ecaa69
--- /dev/null
+++ b/src/components/chat/ChatAttachmentItems.jsx
@@ -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 ;
+ case SUPPORTED_CATEGORIES.CSV:
+ return ;
+ case SUPPORTED_CATEGORIES.XML:
+ case SUPPORTED_CATEGORIES.HTML:
+ return ;
+ case SUPPORTED_CATEGORIES.TEXT:
+ return ;
+ default:
+ return ;
+ }
+}
+
+/**
+ * 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 (
+ (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 (
+ onPreview?.(att)}
+ >
+
+ {onRemove && (
+ {
+ 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"
+ >
+
+
+ )}
+
+ );
+ }
+
+ // Document / file chip
+ return (
+ onPreview?.(att)}
+ >
+ {getCategoryIcon(att.category, { size: 18 })}
+
+
+ {att.name}
+
+
+ {formatFileSize(att.size)}
+
+
+
+ {onRemove && (
+ {
+ e.stopPropagation();
+ onRemove(att.id || idx);
+ }}
+ sx={{ p: 0.2, ml: 0.25 }}
+ aria-label="Anhang entfernen"
+ >
+
+
+ )}
+
+ );
+ })}
+
+ );
+ }
+}
+
+/**
+ * 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 (
+
+ {/* Images Grid */}
+ {images.length > 0 && (
+
+ {images.map((img, idx) => {
+ const src = img.dataUrl || img.url;
+ return (
+ 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)',
+ },
+ }}
+ >
+
+
+ );
+ })}
+
+ )}
+
+ {/* Documents Cards */}
+ {documents.length > 0 && (
+
+ {documents.map((doc, idx) => {
+ return (
+ (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',
+ },
+ }}
+ >
+
+ {getCategoryIcon(doc.category, {
+ size: 24,
+ color: isUser ? 'inherit' : undefined,
+ })}
+
+
+
+
+ {doc.name}
+
+
+ {formatFileSize(doc.size)}
+
+
+
+
+
+ {
+ e.stopPropagation();
+ onPreview?.(doc);
+ }}
+ sx={{ color: 'inherit', p: 0.5 }}
+ >
+
+
+
+
+
+ {
+ e.stopPropagation();
+ downloadAttachment(doc);
+ }}
+ sx={{ color: 'inherit', p: 0.5 }}
+ >
+
+
+
+
+
+ );
+ })}
+
+ )}
+
+ );
+ }
+}
diff --git a/src/components/chat/ChatAttachmentPreviewModal.jsx b/src/components/chat/ChatAttachmentPreviewModal.jsx
new file mode 100644
index 0000000..d7454b5
--- /dev/null
+++ b/src/components/chat/ChatAttachmentPreviewModal.jsx
@@ -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 ;
+ case SUPPORTED_CATEGORIES.CSV:
+ return ;
+ case SUPPORTED_CATEGORIES.XML:
+ case SUPPORTED_CATEGORIES.HTML:
+ return ;
+ case SUPPORTED_CATEGORIES.TEXT:
+ return ;
+ default:
+ return ;
+ }
+ }
+
+ 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 (
+ (theme.palette.mode === 'dark' ? 'rgba(0,0,0,0.4)' : 'rgba(0,0,0,0.03)'),
+ borderRadius: 2,
+ minHeight: 200,
+ }}
+ >
+
+ theme.palette.mode === 'dark'
+ ? '0 4px 20px rgba(0,0,0,0.7)'
+ : '0 4px 20px rgba(0,0,0,0.15)',
+ }}
+ />
+
+ );
+ }
+
+ if (category === SUPPORTED_CATEGORIES.PDF) {
+ return (
+
+
+
+ );
+ }
+
+ if (effectiveText !== undefined) {
+ return (
+ (theme.palette.mode === 'dark' ? 'rgba(0,0,0,0.5)' : '#f8f9fa'),
+ border: '1px solid',
+ borderColor: 'divider',
+ }}
+ >
+
+ {effectiveText}
+
+
+ );
+ }
+
+ return (
+
+
+ Keine Direktvorschau verfügbar. Sie können die Datei herunterladen.
+
+
+ );
+ }
+
+ 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 (
+
+ );
+ }
+}
diff --git a/src/components/chat/ChatDrawer.jsx b/src/components/chat/ChatDrawer.jsx
index a3d8fcc..561356d 100644
--- a/src/components/chat/ChatDrawer.jsx
+++ b/src/components/chat/ChatDrawer.jsx
@@ -16,10 +16,17 @@ import AddRoundedIcon from '@mui/icons-material/AddRounded';
import AutoAwesomeRoundedIcon from '@mui/icons-material/AutoAwesomeRounded';
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 userSettingsAutomat from '../../automats/userSettingsAutomat';
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,
@@ -50,10 +57,16 @@ export default class ChatDrawer extends PureComponent {
sendError: s.sendError,
activeTurns: s.activeTurns || [],
inputText: '',
+ stagedAttachments: [],
+ isProcessingFiles: false,
+ isDraggingFiles: false,
+ attachmentError: null,
+ previewAttachment: null,
drawerWidth: settings?.chatDrawerWidth || DEFAULT_DRAWER_WIDTH,
isResizing: false,
};
this.inputRef = React.createRef();
+ this.fileInputRef = React.createRef();
this.focusTimeout = null;
}
@@ -204,17 +217,122 @@ export default class ChatDrawer extends PureComponent {
handleSend = async (e) => {
e?.preventDefault();
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;
}
- this.setState({ inputText: '' });
+ this.setState({ inputText: '', stagedAttachments: [], attachmentError: null });
try {
- await chatAutomat.actions.sendMessage(text);
+ await chatAutomat.actions.sendMessage(text, attachments);
} catch {
// 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) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
@@ -449,26 +567,37 @@ export default class ChatDrawer extends PureComponent {
draftConversation,
inputText,
sendError,
+ stagedAttachments,
+ isProcessingFiles,
+ isDraggingFiles,
+ attachmentError,
+ previewAttachment,
} = this.state;
const pendingAsk = this.getPendingAskUser();
const isBusy = conversationState === 'busy';
const isWorking = isBusy || isSending;
const canChat = (Boolean(selectedConversationId) || draftConversation) && !pendingAsk;
- const canSend = !isWorking && canChat && Boolean(inputText.trim());
+ const canSend = !isWorking && canChat && (Boolean(inputText.trim()) || stagedAttachments.length > 0);
return (
{sendError && (
@@ -495,7 +624,105 @@ export default class ChatDrawer extends PureComponent {
)}
+ {attachmentError && (
+
+ {attachmentError}
+ this.setState({ attachmentError: null })}
+ sx={{ color: 'inherit', p: 0.2 }}
+ >
+
+
+
+ )}
+
+ {/* Staged attachments tray */}
+
+
+ {isProcessingFiles && (
+
+
+
+ Dateien werden eingelesen…
+
+
+ )}
+
+ {isDraggingFiles && (
+
+ Dateien hier ablegen (Bilder, PDFs, CSV, Text, XML, HTML)
+
+ )}
+
+ {/* Attachment Icon Button */}
+
+
+ 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',
+ },
+ }}
+ >
+
+
+
+
+
+
+
+ {/* Hidden File Input */}
+
+
this.setState({ inputText: e.target.value })}
onKeyDown={this.handleKeyDown}
+ onPaste={this.handlePaste}
disabled={!canChat}
placeholder={
pendingAsk
? '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,
height: 40,
borderRadius: 2.5,
+ flexShrink: 0,
'&:hover': {
bgcolor: 'primary.dark',
},
@@ -544,6 +775,13 @@ export default class ChatDrawer extends PureComponent {
)}
+
+ {/* Modal for previewing staged attachments */}
+
);
}
diff --git a/src/components/chat/ChatFlowList.jsx b/src/components/chat/ChatFlowList.jsx
index e280b43..2fc5860 100644
--- a/src/components/chat/ChatFlowList.jsx
+++ b/src/components/chat/ChatFlowList.jsx
@@ -23,6 +23,8 @@ 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 {
CHROMA,
SHADOW_BUBBLE_ASSISTANT,
@@ -725,12 +727,30 @@ 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 {}
+ }
if (msg.role === 'user') {
flushAssistant();
result.push({
...msg,
- message: payload.content !== undefined ? payload.content : msg.message,
+ message: userText,
+ attachments,
});
continue;
}
@@ -826,7 +846,7 @@ export function normalizeChatMessages(rawMessages = []) {
return result;
}
-function MessageRow({ msg, showTime = true }) {
+function MessageRow({ msg, showTime = true, onPreviewAttachment }) {
// If the message has structured turns, render each turn
if (msg.turns && Array.isArray(msg.turns) && msg.turns.length > 0) {
return (
@@ -839,7 +859,22 @@ function MessageRow({ msg, showTime = true }) {
}
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') {
return (
@@ -917,11 +952,21 @@ function MessageRow({ msg, showTime = true }) {
lineHeight: 1.5,
})}
>
- {isUser ? (
- textContent
- ) : (
-
+ {attachments && attachments.length > 0 && (
+
)}
+
+ {textContent ? (
+ isUser ? (
+ textContent
+ ) : (
+
+ )
+ ) : null}
@@ -933,6 +978,18 @@ 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');
}
@@ -974,6 +1031,7 @@ 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) {
@@ -1007,6 +1065,7 @@ export default class ChatFlowList extends PureComponent {
key={msg.id || index}
msg={msg}
showTime={showTime}
+ onPreviewAttachment={this.handlePreviewAttachment}
/>
);
})}
@@ -1019,6 +1078,12 @@ export default class ChatFlowList extends PureComponent {
: null}
+
+
);
}
diff --git a/src/components/chat/chatAttachments.js b/src/components/chat/chatAttachments.js
new file mode 100644
index 0000000..94b9751
--- /dev/null
+++ b/src/components/chat/chatAttachments.js
@@ -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