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 ``).
+ * @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.