From 4769a87c54361ab405ab2a04dec58da706fe2605 Mon Sep 17 00:00:00 2001 From: seb Date: Tue, 6 Oct 2026 11:40:02 +0200 Subject: [PATCH] u --- src/automats/chatAutomat.js | 35 +- src/components/chat/ChatAttachmentItems.jsx | 371 ++++++++++++++++++ .../chat/ChatAttachmentPreviewModal.jsx | 282 +++++++++++++ src/components/chat/ChatDrawer.jsx | 252 +++++++++++- src/components/chat/ChatFlowList.jsx | 79 +++- src/components/chat/chatAttachments.js | 198 ++++++++++ 6 files changed, 1197 insertions(+), 20 deletions(-) create mode 100644 src/components/chat/ChatAttachmentItems.jsx create mode 100644 src/components/chat/ChatAttachmentPreviewModal.jsx create mode 100644 src/components/chat/chatAttachments.js 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 ( + + + + {this.renderIcon(category)} + + {name || 'Anhang'} + + + + + + + + + + + {this.renderContent()} + + + + + {hasText && ( + + )} + + + + + + + + + ); + } +} 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} + */ +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); +}