From b99b39f640ede04e5a732d114c566d2aa204bb8f Mon Sep 17 00:00:00 2001 From: seb Date: Tue, 6 Oct 2026 12:45:50 +0200 Subject: [PATCH] u --- src/components/chat/ChatAttachmentItems.jsx | 399 +++++++++++++++++--- src/components/chat/HealingMarkdown.jsx | 28 +- 2 files changed, 362 insertions(+), 65 deletions(-) diff --git a/src/components/chat/ChatAttachmentItems.jsx b/src/components/chat/ChatAttachmentItems.jsx index 6dc8b08..9932626 100644 --- a/src/components/chat/ChatAttachmentItems.jsx +++ b/src/components/chat/ChatAttachmentItems.jsx @@ -1,7 +1,7 @@ /** * @file ChatAttachmentItems.jsx * @description UI components for rendering staged attachments in the input tray, - * and safe direct VFS links for `[ATTACHMENT name="..." path="..." type=...]` tags in messages. + * and safe direct VFS links for `[ATTACHMENT name="..." path="..." type=...]` and markdown relative links. */ import React, { PureComponent } from 'react'; @@ -18,6 +18,8 @@ import DescriptionRoundedIcon from '@mui/icons-material/DescriptionRounded'; import InsertDriveFileRoundedIcon from '@mui/icons-material/InsertDriveFileRounded'; import InsertPhotoRoundedIcon from '@mui/icons-material/InsertPhotoRounded'; import ErrorOutlineRoundedIcon from '@mui/icons-material/ErrorOutlineRounded'; +import OpenInNewRoundedIcon from '@mui/icons-material/OpenInNewRounded'; +import DownloadRoundedIcon from '@mui/icons-material/DownloadRounded'; import { enqueueSnackbar } from 'notistack'; import { @@ -25,15 +27,89 @@ import { formatFileSize, } from './chatAttachments'; +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', +]); + /** - * Resolve a relative VFS path (e.g. `attachments/Rechnung 89589.txt`) to a full browser URL (`/api/vfs/...`). + * 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('/')) return p; + 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('/') @@ -42,6 +118,67 @@ export function resolveVfsUrl(rawPath) { 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` @@ -115,26 +252,63 @@ function getAttachmentIcon(type, name, isUser) { } /** - * Direct link to VFS rendered for `[ATTACHMENT name="..." path="..." type=...]`. + * 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, }; - 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(); - + handleOpenInNewTab = async (e) => { + e?.preventDefault?.(); + e?.stopPropagation?.(); const { name, path } = this.props; - const { isDownloading, notFound } = this.state; - if (isDownloading) return; + const downloadName = name || (path ? path.split('/').pop() : 'datei'); + const { isOpening, isDownloading, notFound } = this.state; + if (isOpening || isDownloading) return; if (notFound) { - enqueueSnackbar(`Datei "${name}" ist nicht mehr im VFS vorhanden (404).`, { variant: 'warning' }); + 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; } @@ -146,10 +320,10 @@ export class VfsAttachmentLink extends PureComponent { if (!res.ok) { if (res.status === 404) { this.setState({ notFound: true, isDownloading: false }); - enqueueSnackbar(`Datei "${name}" nicht mehr im VFS vorhanden (404).`, { variant: 'warning' }); + enqueueSnackbar(`Datei "${downloadName}" nicht mehr im VFS vorhanden (404).`, { variant: 'warning' }); } else { this.setState({ isDownloading: false }); - enqueueSnackbar(`Fehler beim Herunterladen von "${name}" (${res.status}).`, { variant: 'error' }); + enqueueSnackbar(`Fehler beim Herunterladen von "${downloadName}" (${res.status}).`, { variant: 'error' }); } return; } @@ -158,7 +332,7 @@ export class VfsAttachmentLink extends PureComponent { const objectUrl = URL.createObjectURL(blob); const tempLink = document.createElement('a'); tempLink.href = objectUrl; - tempLink.download = name || 'datei'; + tempLink.download = downloadName; document.body.appendChild(tempLink); tempLink.click(); document.body.removeChild(tempLink); @@ -170,23 +344,40 @@ export class VfsAttachmentLink extends PureComponent { } }; + 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, isUser = false } = this.props; - const { isDownloading, notFound } = this.state; + 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 displayName = label || children || name || downloadName; + const effectiveType = type || inferMimeType(downloadName); const vfsUrl = resolveVfsUrl(path); - const isImage = !notFound && ((type && type.startsWith('image/')) || /\.(png|jpe?g|webp|gif|svg|avif|bmp|ico)$/i.test(name || 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) { + 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': { - transform: 'scale(1.02)', - boxShadow: '0 4px 12px rgba(0,0,0,0.2)', + boxShadow: '0 4px 12px rgba(0,0,0,0.18)', }, }} > + > + + (theme.palette.mode === 'dark' ? 'background.paper' : 'rgba(0,0,0,0.03)'), + py: 0.4, color: isUser ? '#fff' : 'text.primary', }} > - - + + - {name} + {downloadName} - {isDownloading && } + + {(isDownloading || isOpening) && ( + + )} + {!notFound && ( + <> + + + + + + + + + + + + )} + ); } const titleText = notFound - ? `Datei nicht mehr im VFS vorhanden (404): ${name}` - : isDownloading - ? `Wird heruntergeladen…` - : `VFS Datei herunterladen / öffnen: ${name}`; + ? `Datei nicht mehr im VFS vorhanden (404): ${downloadName}` + : isOpening + ? `Wird im neuen Tab geöffnet…` + : isDownloading + ? `Wird heruntergeladen…` + : canView + ? `In neuem Tab öffnen (${downloadName})` + : `Herunterladen (${downloadName})`; return ( - {isDownloading ? ( + {isDownloading || isOpening ? ( ) : notFound ? ( ) : ( - getAttachmentIcon(type, name, isUser) + getAttachmentIcon(effectiveType, downloadName, isUser) )} - {name} + {displayName} - {notFound ? 'nicht mehr vorhanden (404)' : (type || 'VFS Datei')} + {notFound + ? 'nicht mehr vorhanden (404)' + : (label && label !== downloadName ? `${downloadName} · ${effectiveType || 'VFS'}` : (effectiveType || 'VFS Datei'))} + {!notFound && ( + e.stopPropagation()} + > + {canView && ( + + + + + + )} + + + + + + + )} ); diff --git a/src/components/chat/HealingMarkdown.jsx b/src/components/chat/HealingMarkdown.jsx index e73ac94..7b5611d 100644 --- a/src/components/chat/HealingMarkdown.jsx +++ b/src/components/chat/HealingMarkdown.jsx @@ -9,7 +9,7 @@ import { TABLE_HEADER_PAIR, byMode, } from '../../colors'; -import { VfsAttachmentLink, parseAttachmentTag } from './ChatAttachmentItems'; +import { VfsAttachmentLink, parseAttachmentTag, classifyLinkTarget } from './ChatAttachmentItems'; /** * Healing markdown renderer. @@ -550,21 +550,35 @@ function parseInline(src, keyPrefix = 'i') { const label = src.slice(i + 1, close); if (src[close + 1] === '(') { const endUrl = src.indexOf(')', close + 2); - const url = (endUrl === -1 ? src.slice(close + 2) : src.slice(close + 2, endUrl)).trim(); + const rawUrl = (endUrl === -1 ? src.slice(close + 2) : src.slice(close + 2, endUrl)).trim(); const k = key(); - if (endUrl !== -1 && SAFE_URL.test(url)) { - if (/^https?:\/\//i.test(url)) { + if (endUrl !== -1) { + const classified = classifyLinkTarget(rawUrl); + if (classified.kind === 'web') { push( - + {parseInline(label, k)} , ); - } else { + } else if (classified.kind === 'nav') { push( - + {parseInline(label, k)} , ); + } else if (classified.kind === 'vfs') { + const filename = classified.path.split('/').pop() || 'datei'; + push( + , + ); + } else { + push({parseInline(label, k)}); } } else { // Half-typed (or unsafe) link: show the label only.