/** * @file ChatAttachmentItems.jsx * @description UI components for rendering staged attachments in the input tray, * and safe direct VFS links for `[ATTACHMENT name="..." path="..." type=...]` and markdown relative links. */ import React, { PureComponent } from 'react'; 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 Dialog from '@mui/material/Dialog'; import DialogTitle from '@mui/material/DialogTitle'; import DialogContent from '@mui/material/DialogContent'; import DialogActions from '@mui/material/DialogActions'; import CircularProgress from '@mui/material/CircularProgress'; import CloseRoundedIcon from '@mui/icons-material/CloseRounded'; import ContentCopyRoundedIcon from '@mui/icons-material/ContentCopyRounded'; 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 InsertPhotoRoundedIcon from '@mui/icons-material/InsertPhotoRounded'; import OpenInNewRoundedIcon from '@mui/icons-material/OpenInNewRounded'; import DownloadRoundedIcon from '@mui/icons-material/DownloadRounded'; import { enqueueSnackbar } from 'notistack'; import { SUPPORTED_CATEGORIES, formatFileSize, } from './chatAttachments'; import { stripUserEnv } from './userEnv'; const KNOWN_FILE_EXTENSIONS = new Set([ 'html', 'htm', 'csv', 'tsv', 'pdf', 'txt', 'text', 'md', 'markdown', 'log', 'json', 'xml', 'yaml', 'yml', 'png', 'jpg', 'jpeg', 'webp', 'gif', 'svg', 'avif', 'bmp', 'ico', 'zip', 'tar', 'gz', 'bz2', '7z', 'rar', 'js', 'ts', 'jsx', 'tsx', 'css', 'scss', 'sql', 'sh', 'py', 'wasm', 'xlsx', 'xls', 'docx', 'doc', 'pptx', 'ppt', 'odt', 'ods', ]); /** * Invalidate/infer a MIME type from a filename or path. * @param {string} filename * @returns {string} */ export function inferMimeType(filename) { const ext = (String(filename || '').split('.').pop() || '').toLowerCase(); switch (ext) { case 'html': case 'htm': return 'text/html'; case 'pdf': return 'application/pdf'; case 'csv': return 'text/csv'; case 'tsv': return 'text/tab-separated-values'; case 'xml': return 'application/xml'; case 'txt': case 'log': case 'md': return 'text/plain'; case 'json': return 'application/json'; case 'png': return 'image/png'; case 'jpg': case 'jpeg': return 'image/jpeg'; case 'webp': return 'image/webp'; case 'svg': return 'image/svg+xml'; case 'gif': return 'image/gif'; default: return ''; } } /** * Checks whether a given file can be displayed natively inside a browser tab * (HTML pages, PDFs, images, text, json, xml, csv, logs, markdown). * @param {string} filename * @param {string} [mimeType] * @returns {boolean} */ export function isBrowserViewable(filename, mimeType) { const type = String(mimeType || inferMimeType(filename) || '').toLowerCase(); const ext = (String(filename || '').split('.').pop() || '').toLowerCase(); if (type.startsWith('image/') || type === 'application/pdf' || type === 'text/html') { return true; } if (['html', 'htm', 'pdf', 'png', 'jpg', 'jpeg', 'webp', 'gif', 'svg', 'avif', 'bmp', 'ico'].includes(ext)) { return true; } if (['txt', 'log', 'json', 'xml', 'md', 'markdown', 'csv', 'tsv', 'js', 'ts', 'jsx', 'tsx', 'css', 'sql', 'sh', 'py'].includes(ext) || type.startsWith('text/')) { return true; } return false; } /** * Resolve a relative VFS path (e.g. `attachments/Rechnung 89589.txt` or `dummy-report.html`) * 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('/api/vfs/')) return p; if (p.startsWith('/vfs/')) return `/api${p}`; if (p.startsWith('/')) return p; const segments = p .replace(/^\.\//, '') .split('/') .filter((s) => s && s !== '.') .map(encodeURIComponent); return `/api/vfs/${segments.join('/')}`; } /** * Classifies a markdown link target according to the rule: * - Internet links are "https://..." or simply "hostname.com" / "hostname.com/filename". * - A pure relative path with no dotted hostname is always a VFS relative path (e.g. `[download](report.csv)` or `![preview](images/photo.png)`). * @param {string} rawUrl * @returns {{ kind: 'web'|'vfs'|'nav'|'none', url?: string, path?: string }} */ export function classifyLinkTarget(rawUrl) { const url = String(rawUrl ?? '').trim(); if (!url) return { kind: 'none' }; if (/^https?:\/\//i.test(url)) { return { kind: 'web', url }; } if (/^(mailto:|#)/i.test(url)) { return { kind: 'nav', url }; } if (url.startsWith('/')) { if (url.startsWith('/api/vfs/') || url.startsWith('/vfs/')) { const vfsPath = url.replace(/^\/api\/vfs\//, '').replace(/^\/vfs\//, ''); return { kind: 'vfs', path: vfsPath }; } return { kind: 'nav', url }; } // Strip leading ./ const clean = url.replace(/^\.\//, ''); const firstSlash = clean.indexOf('/'); const firstSegment = firstSlash === -1 ? clean : clean.slice(0, firstSlash); // If starts with www. if (/^www\./i.test(firstSegment)) { return { kind: 'web', url: `https://${clean}` }; } // If there's a slash and the first segment contains a dot: // e.g. "hostname.com/filename" or "sub.domain.org/path" if (firstSlash !== -1 && firstSegment.includes('.')) { const ext = firstSegment.split('.').pop().toLowerCase(); if (!KNOWN_FILE_EXTENSIONS.has(ext)) { return { kind: 'web', url: `https://${clean}` }; } } // If there's no slash (e.g. "report.csv" or "hostname.com"): if (firstSlash === -1 && firstSegment.includes('.')) { const ext = firstSegment.split('.').pop().toLowerCase(); if (KNOWN_FILE_EXTENSIONS.has(ext)) { // It's a file! e.g. report.csv, dummy-report.html -> VFS relative path return { kind: 'vfs', path: clean }; } // Dotted hostname without path, e.g. "example.com" return { kind: 'web', url: `https://${clean}` }; } // Pure relative path with no dotted hostname (e.g. "images/photo.png", "report.csv", "attachments/file.txt"): return { kind: 'vfs', path: clean }; } /** * 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 || '', }; } export { stripUserEnv }; /** * Extract all `[EXTRACTION ...]` blocks (or legacy model instruction code blocks) * and return structured extraction objects along with clean non-invoice text. * @param {string} rawText * @returns {{ extractions: Array<{ label: string, text: string }>, cleanText: string }} */ export function parseExtractionsFromText(rawText) { if (typeof rawText !== 'string' || !rawText) { return { extractions: [], cleanText: '' }; } const extractions = []; let cleanText = rawText; // 1. Explicit [EXTRACTION label="..."]...[/EXTRACTION] or ... const tagRegex = /(?:\[EXTRACTION(?:\s+label="([^"]*)")?\]|)([\s\S]*?)(?:\[\/EXTRACTION\]|<\/extraction>)/gi; let match; while ((match = tagRegex.exec(rawText)) !== null) { const label = match[1] || match[2] || 'parsed invoice'; let text = match[3] || ''; text = text.replace(/^```[a-z]*\s*\n?|\s*```$/gi, '').trim(); extractions.push({ label, text }); } if (extractions.length > 0) { cleanText = cleanText.replace(tagRegex, '').trim(); return { extractions, cleanText }; } // 2. Legacy / fallback: message contains "use 'Process Incoming Product and Invoices' SKILL" + code block const instructionPhrase = "use 'Process Incoming Product and Invoices' SKILL to fill drafts/delivery vfs file"; if (cleanText.includes(instructionPhrase)) { const codeBlockRegex = /```[a-z]*\s*\n([\s\S]*?)```/g; let cbMatch; while ((cbMatch = codeBlockRegex.exec(cleanText)) !== null) { const code = (cbMatch[1] || '').trim(); if (code.length > 20) { extractions.push({ label: 'parsed invoice', text: code }); } } if (extractions.length > 0) { cleanText = cleanText.replace(codeBlockRegex, '').trim(); return { extractions, cleanText }; } } // 3. Standalone code block (e.g. ```\n...invoice...```) const soleCodeBlockMatch = cleanText.match(/^\s*```[a-z]*\s*\n([\s\S]{40,})```\s*$/); if (soleCodeBlockMatch) { const code = soleCodeBlockMatch[1].trim(); extractions.push({ label: 'parsed invoice', text: code }); cleanText = ''; return { extractions, cleanText }; } return { extractions: [], cleanText }; } /** * Clickable pill representing an extracted invoice/data snippet inside a message bubble. * Clicking opens a modal preview to inspect and copy the full text. */ export class ExtractionPill extends PureComponent { state = { openPreview: false, }; handleClick = (e) => { e?.preventDefault?.(); e?.stopPropagation?.(); this.setState({ openPreview: true }); }; handleClose = () => { this.setState({ openPreview: false }); }; handleCopy = () => { const { text } = this.props; if (text && navigator?.clipboard?.writeText) { navigator.clipboard.writeText(text); enqueueSnackbar('Extrahierter Text kopiert', { variant: 'success' }); } }; render() { const { label = 'parsed invoice', text = '', isUser = false } = this.props; const { openPreview } = this.state; return ( <> (theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.05)'), color: isUser ? '#fff' : 'text.primary', cursor: 'pointer', verticalAlign: 'middle', maxWidth: '100%', outline: 'none', fontFamily: 'inherit', transition: 'all 0.15s ease', '&:hover': { bgcolor: isUser ? 'rgba(255, 255, 255, 0.3)' : 'action.hover', borderColor: isUser ? '#fff' : 'primary.main', boxShadow: '0 2px 8px rgba(0,0,0,0.1)', }, }} > {label || 'parsed invoice'} ({ zIndex: theme.zIndex.modal + 50, })} slotProps={{ paper: { sx: { borderRadius: 2.5, bgcolor: 'background.paper', backgroundImage: 'none', }, }, }} > {label || 'parsed invoice'} theme.palette.mode === 'dark' ? 'rgba(0,0,0,0.2)' : 'rgba(0,0,0,0.02)' }}> {text} ); } } /** * Extract all `[ATTACHMENT ...]` markers and return clean text plus attachment metadata. * Also strips tags so route context is never rendered in chat views. * @param {string} rawText * @returns {{ attachments: Array<{ name: string, path: string, type: string, url: string }>, cleanText: string }} */ export function parseAttachmentsFromText(rawText) { if (typeof rawText !== 'string') { return { attachments: [], cleanText: '' }; } const textWithoutUserEnv = stripUserEnv(rawText); if (!textWithoutUserEnv.includes('[ATTACHMENT')) { return { attachments: [], cleanText: textWithoutUserEnv }; } const attachments = []; const tagRegex = /\[ATTACHMENT\s+([^\]]+)\]/g; let match; while ((match = tagRegex.exec(textWithoutUserEnv)) !== null) { const parsed = parseAttachmentTag(match[1]); if (parsed.name || parsed.path) { attachments.push({ ...parsed, url: resolveVfsUrl(parsed.path), }); } } const cleanText = textWithoutUserEnv.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=...]` and markdown relative links. * Safely verifies HTTP response before triggering download, preventing saving 404 JSON error responses. */ export class VfsAttachmentLink extends PureComponent { state = { isDownloading: false, isOpening: false, notFound: false, }; handleOpenInNewTab = async (e) => { e?.preventDefault?.(); e?.stopPropagation?.(); const { name, path } = this.props; const downloadName = name || (path ? path.split('/').pop() : 'datei'); const { isOpening, isDownloading, notFound } = this.state; if (isOpening || isDownloading) return; if (notFound) { enqueueSnackbar(`Datei "${downloadName}" ist nicht mehr im VFS vorhanden (404).`, { variant: 'warning' }); return; } const vfsUrl = resolveVfsUrl(path); this.setState({ isOpening: true }); try { const res = await fetch(vfsUrl, { credentials: 'include' }); if (!res.ok) { if (res.status === 404) { this.setState({ notFound: true, isOpening: false }); enqueueSnackbar(`Datei "${downloadName}" nicht mehr im VFS vorhanden (404).`, { variant: 'warning' }); } else { this.setState({ isOpening: false }); enqueueSnackbar(`Fehler beim Öffnen von "${downloadName}" (${res.status}).`, { variant: 'error' }); } return; } this.setState({ isOpening: false }); window.open(vfsUrl, '_blank', 'noopener,noreferrer'); } catch (err) { this.setState({ isOpening: false }); enqueueSnackbar(`Netzwerkfehler beim Öffnen: ${err?.message || err}`, { variant: 'error' }); } }; handleDownload = async (e) => { e?.preventDefault?.(); e?.stopPropagation?.(); const { name, path } = this.props; const downloadName = name || (path ? path.split('/').pop() : 'datei'); const { isDownloading, isOpening, notFound } = this.state; if (isDownloading || isOpening) return; if (notFound) { enqueueSnackbar(`Datei "${downloadName}" 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 "${downloadName}" nicht mehr im VFS vorhanden (404).`, { variant: 'warning' }); } else { this.setState({ isDownloading: false }); enqueueSnackbar(`Fehler beim Herunterladen von "${downloadName}" (${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 = downloadName; 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' }); } }; handleClick = (e) => { // If modifier keys are pressed (e.g. Ctrl/Cmd/Shift) or middle mouse click, let browser handle natively if (e.ctrlKey || e.metaKey || e.shiftKey || e.button === 1) return; e.preventDefault(); const { name, path, type } = this.props; const downloadName = name || (path ? path.split('/').pop() : 'datei'); const effectiveType = type || inferMimeType(downloadName); const viewable = isBrowserViewable(downloadName, effectiveType); if (viewable) { this.handleOpenInNewTab(e); } else { this.handleDownload(e); } }; handleImageError = () => { this.setState({ notFound: true }); }; render() { const { name, path, type, label, children, isUser = false } = this.props; const { isDownloading, isOpening, notFound } = this.state; const downloadName = name || (path ? path.split('/').pop() : 'datei'); const effectiveType = type || inferMimeType(downloadName); const vfsUrl = resolveVfsUrl(path); const canView = isBrowserViewable(downloadName, effectiveType); const isImage = !notFound && ((effectiveType && effectiveType.startsWith('image/')) || /\.(png|jpe?g|webp|gif|svg|avif|bmp|ico)$/i.test(downloadName || path)); if (isImage && !label && !children) { return ( (theme.palette.mode === 'dark' ? 'background.paper' : 'rgba(0,0,0,0.03)'), transition: 'transform 0.15s, box-shadow 0.15s', '&:hover': { boxShadow: '0 4px 12px rgba(0,0,0,0.18)', }, }} > {downloadName} - {effectiveType || 'image'} {!notFound && ( <> {isOpening ? ( ) : ( )} {isDownloading ? ( ) : ( )} )} ); } 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 : 1, cursor: notFound ? 'not-allowed' : (isOpening || isDownloading ? 'wait' : 'pointer'), verticalAlign: 'middle', maxWidth: '100%', '&: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)', }, }} > {downloadName} - {effectiveType || 'VFS'} e.stopPropagation()} > {canView && ( )} ); } } /** * Tray showing files staged for posting above the chat input field. */ export class ChatStagedAttachmentsTray extends PureComponent { render() { const { attachments, onRemove } = 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 ( {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 ( {getAttachmentIcon(att.type, att.name, false)} {att.name} {formatFileSize(att.size)} {onRemove && ( { e.stopPropagation(); onRemove(att.id || idx); }} sx={{ p: 0.2, ml: 0.25 }} aria-label="Anhang entfernen" > )} ); })} ); } }