diff --git a/src/automats/chatAutomat.js b/src/automats/chatAutomat.js
index 764db82..bd6beee 100644
--- a/src/automats/chatAutomat.js
+++ b/src/automats/chatAutomat.js
@@ -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) {
diff --git a/src/components/chat/ChatAttachmentItems.jsx b/src/components/chat/ChatAttachmentItems.jsx
index 0ecaa69..6dc8b08 100644
--- a/src/components/chat/ChatAttachmentItems.jsx
+++ b/src/components/chat/ChatAttachmentItems.jsx
@@ -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 ;
- case SUPPORTED_CATEGORIES.CSV:
- return ;
- case SUPPORTED_CATEGORIES.XML:
- case SUPPORTED_CATEGORIES.HTML:
- return ;
- case SUPPORTED_CATEGORIES.TEXT:
- return ;
- default:
- return ;
+/**
+ * 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 ;
+ }
+ if (t.includes('csv') || n.endsWith('.csv')) {
+ return ;
+ }
+ if (t.includes('xml') || t.includes('html') || n.endsWith('.xml') || n.endsWith('.html') || n.endsWith('.htm')) {
+ return ;
+ }
+ if (t.startsWith('text/') || n.endsWith('.txt') || n.endsWith('.md') || n.endsWith('.log')) {
+ return ;
+ }
+ if (t.startsWith('image/') || /\.(png|jpe?g|webp|gif|svg|avif|bmp|ico)$/i.test(n)) {
+ return ;
+ }
+ return ;
+}
+
+/**
+ * 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 (
+
+
+ (theme.palette.mode === 'dark' ? 'background.paper' : 'rgba(0,0,0,0.03)'),
+ color: isUser ? '#fff' : 'text.primary',
+ }}
+ >
+
+
+
+ {name}
+
+
+ {isDownloading && }
+
+
+ );
+ }
+
+ const titleText = notFound
+ ? `Datei nicht mehr im VFS vorhanden (404): ${name}`
+ : isDownloading
+ ? `Wird heruntergeladen…`
+ : `VFS Datei herunterladen / öffnen: ${name}`;
+
+ return (
+
+ (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 ? (
+
+ ) : notFound ? (
+
+ ) : (
+ getAttachmentIcon(type, name, isUser)
+ )}
+
+
+ {name}
+
+
+ {notFound ? 'nicht mehr vorhanden (404)' : (type || 'VFS Datei')}
+
+
+
+
+ );
}
}
@@ -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)}
>
onPreview?.(att)}
>
- {getCategoryIcon(att.category, { size: 18 })}
+ {getAttachmentIcon(att.type, att.name, false)}
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
deleted file mode 100644
index d7454b5..0000000
--- a/src/components/chat/ChatAttachmentPreviewModal.jsx
+++ /dev/null
@@ -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 ;
- 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 561356d..dec7408 100644
--- a/src/components/chat/ChatDrawer.jsx
+++ b/src/components/chat/ChatDrawer.jsx
@@ -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 {
{isProcessingFiles && (
@@ -775,13 +763,6 @@ 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 2fc5860..7a10a1d 100644
--- a/src/components/chat/ChatFlowList.jsx
+++ b/src/components/chat/ChatFlowList.jsx
@@ -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 (
@@ -952,19 +929,32 @@ function MessageRow({ msg, showTime = true, onPreviewAttachment }) {
lineHeight: 1.5,
})}
>
- {attachments && attachments.length > 0 && (
-
+ {vfsAttachments.length > 0 && (
+
+ {vfsAttachments.map((att, idx) => (
+
+ ))}
+
)}
- {textContent ? (
+ {cleanText ? (
isUser ? (
- textContent
+ cleanText
) : (
-
+
)
) : null}
@@ -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}
-
-
);
}
diff --git a/src/components/chat/HealingMarkdown.jsx b/src/components/chat/HealingMarkdown.jsx
index ae74675..e73ac94 100644
--- a/src/components/chat/HealingMarkdown.jsx
+++ b/src/components/chat/HealingMarkdown.jsx
@@ -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(
+ ,
+ );
+ i = closeTag + 1;
+ continue;
+ }
+ }
+ }
+
const close = src.indexOf(']', i + 1);
if (close !== -1) {
const label = src.slice(i + 1, close);