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 ( - - - - {this.renderIcon(category)} - - {name || 'Anhang'} - - - - - - - - - - - {this.renderContent()} - - - - - {hasText && ( - - )} - - - - - - - - - ); - } -} 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);