import React, { memo, useState, PureComponent } from 'react'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import LanguageRoundedIcon from '@mui/icons-material/LanguageRounded'; import { CODE_BLOCK_PAIR, CODE_INLINE_PAIR, SURFACE_SUBTLE_PAIR, TABLE_HEADER_PAIR, byMode, } from '../../colors'; import { VfsAttachmentLink, parseAttachmentTag, classifyLinkTarget, resolveVfsUrl } from './ChatAttachmentItems'; /** * Healing markdown renderer. * * Markdown arrives token by token, so at any moment the text may end inside an * unclosed code fence, bold/italic span, inline code or half-typed link. This * renderer is tolerant: every unterminated construct is rendered as if it had * been closed right at the end of the text, and a half-typed link shows only * its label. It renders React elements only (no innerHTML), so it is XSS-safe. * * Supported: headings, paragraphs, (nested-less) ordered/unordered lists, * blockquotes, fenced code, horizontal rules, `code`, **bold**, *italic*, * ~~strike~~, [links](url) and ![images](url). */ /** * Resolve an image target to something the browser may load. * * Bare/relative names are artifacts of the model sandbox virtual filesystem and are * served by `GET /api/vfs/{*path}` — so `![plot](in_vfs.png)` and * `![plot](labels/a.png)` resolve to `/api/vfs/in_vfs.png` and `/api/vfs/labels/a.png`. * Absolute app paths, http(s) URLs and inline data URIs are passed through unchanged. * Anything else (javascript:, vbscript:, …) yields `null` so it is never rendered. * * @param {string} rawUrl * @returns {string | null} */ function resolveImageUrl(rawUrl) { const url = String(rawUrl ?? '').trim(); if (!url) return null; if (/^data:image\//i.test(url)) return url; const classified = classifyLinkTarget(url); if (classified.kind === 'web') return classified.url; if (classified.kind === 'vfs') { if (classified.path.includes('..')) return null; return resolveVfsUrl(classified.path); } if (classified.kind === 'nav') return classified.url; return null; } /** * Strip an optional markdown link title: `path "Title"` -> `path`. * @param {string} raw * @returns {string} */ function stripImageTitle(raw) { const trimmed = raw.trim(); const m = trimmed.match(/^(\S*)\s+["'(].*$/); return (m ? m[1] : trimmed).trim(); } /** * Image element that degrades to its alt text when the resource cannot be loaded * (404 while the model is still generating the file, revoked sandbox file, …). */ function MarkdownImage({ src, alt }) { const [failed, setFailed] = useState(false); if (!src || failed) { return {alt || '🖼'}; } return ( {alt setFailed(true)} /> ); } function extractDomain(urlStr) { if (!urlStr || typeof urlStr !== 'string') return ''; try { const u = new URL(urlStr.startsWith('http') ? urlStr : `https://${urlStr}`); return u.hostname.replace(/^www\./, ''); } catch { return ''; } } class FaviconImg extends PureComponent { state = { srcIndex: 0, hasError: false, }; sources = []; constructor(props) { super(props); this.sources = this.buildSources(props.url, props.domain); } componentDidUpdate(prevProps) { if (this.props.url !== prevProps.url || this.props.domain !== prevProps.domain) { this.sources = this.buildSources(this.props.url, this.props.domain); this.setState({ srcIndex: 0, hasError: false }); } } buildSources(urlStr, domain) { const list = []; const host = domain || extractDomain(urlStr); if (!host) return list; if (host === 'localhost' || host === '127.0.0.1' || host.endsWith('.local')) { try { const origin = new URL(urlStr.startsWith('http') ? urlStr : `https://${urlStr}`).origin; list.push(`${origin}/favicon.ico`); } catch {} } else { list.push(`https://icons.duckduckgo.com/ip3/${host}.ico`); try { const origin = new URL(urlStr.startsWith('http') ? urlStr : `https://${urlStr}`).origin; list.push(`${origin}/favicon.ico`); } catch {} } return list; } handleError = () => { if (this.state.srcIndex + 1 < this.sources.length) { this.setState((prev) => ({ srcIndex: prev.srcIndex + 1 })); } else { this.setState({ hasError: true }); } }; render() { const { srcIndex, hasError } = this.state; const currentSrc = this.sources[srcIndex]; if (!currentSrc || hasError) { return ( ); } return ( ); } } const metaCache = new Map(); function fetchUrlMetadata(url) { if (!url || typeof url !== 'string' || !/^https?:\/\//i.test(url)) { return Promise.resolve(null); } if (metaCache.has(url)) { return metaCache.get(url); } const promise = (async () => { try { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 4000); const res = await fetch(`https://api.microlink.io/?url=${encodeURIComponent(url)}`, { signal: controller.signal, }); clearTimeout(timer); if (!res.ok) return null; const json = await res.json(); if (json?.status === 'success' && json?.data) { const d = json.data; if (d.title && (d.description || d.image?.url)) { return { title: d.title, description: d.description || '', image: d.image?.url || null, publisher: d.publisher || '', logo: d.logo?.url || null, }; } } return null; } catch { return null; } })(); metaCache.set(url, promise); return promise; } class WebLinkCard extends PureComponent { state = { meta: null, fetched: false, }; _mounted = false; componentDidMount() { this._mounted = true; const { url } = this.props; if (url && /^https?:\/\//i.test(url)) { fetchUrlMetadata(url).then((meta) => { if (this._mounted) { this.setState({ meta, fetched: true }); } }); } } componentDidUpdate(prevProps) { if (this.props.url !== prevProps.url) { this.setState({ meta: null, fetched: false }); const { url } = this.props; if (url && /^https?:\/\//i.test(url)) { fetchUrlMetadata(url).then((meta) => { if (this._mounted) { this.setState({ meta, fetched: true }); } }); } } } componentWillUnmount() { this._mounted = false; } render() { const { url, label, children } = this.props; const { meta } = this.state; const domain = extractDomain(url); // If metadata exists with title and (description or image), render small meta/og box! if (meta && meta.title && (meta.description || meta.image)) { return ( (theme.palette.mode === 'dark' ? 'background.paper' : 'rgba(0, 0, 0, 0.02)'), textDecoration: 'none', color: 'inherit', overflow: 'hidden', cursor: 'pointer', transition: 'all 160ms ease-in-out', '&:hover': { bgcolor: 'action.hover', borderColor: 'primary.main', boxShadow: (theme) => theme.palette.mode === 'dark' ? '0 2px 10px rgba(0,0,0,0.5)' : '0 2px 10px rgba(0,0,0,0.08)', }, }} > {meta.publisher || domain} {meta.title} {meta.description ? ( {meta.description} ) : null} {meta.image ? ( { e.currentTarget.parentElement.style.display = 'none'; }} /> ) : null} ); } // Fallback: render favicon box with domain/label const displayLabel = (typeof label === 'string' && label.trim() && label.trim() !== url) ? label.trim() : (children || domain || url); return ( (theme.palette.mode === 'dark' ? 'background.paper' : 'rgba(0, 0, 0, 0.02)'), textDecoration: 'none', color: 'primary.main', verticalAlign: 'middle', maxWidth: '100%', transition: 'all 150ms ease-in-out', '&:hover': { bgcolor: 'action.hover', borderColor: 'primary.main', textDecoration: 'underline', }, }} > {displayLabel} ); } } function parseInline(src, keyPrefix = 'i') { const out = []; let buf = ''; let i = 0; let n = 0; const flush = () => { if (buf) { out.push(buf); buf = ''; } }; const push = (el) => { flush(); out.push(el); }; const key = () => `${keyPrefix}-${n++}`; while (i < src.length) { const c = src[i]; if (c === '`') { const end = src.indexOf('`', i + 1); const code = end === -1 ? src.slice(i + 1) : src.slice(i + 1, end); if (code) push({code}); i = end === -1 ? src.length : end + 1; continue; } if (src.startsWith('**', i) || src.startsWith('~~', i)) { const marker = src.slice(i, i + 2); const end = src.indexOf(marker, i + 2); const inner = end === -1 ? src.slice(i + 2) : src.slice(i + 2, end); const k = key(); const children = parseInline(inner, k); push(marker === '**' ? {children} : {children}); i = end === -1 ? src.length : end + 2; continue; } if (c === '*' && src[i + 1] && !/\s/.test(src[i + 1])) { let end = i + 1; while (end < src.length && !(src[end] === '*' && src[end + 1] !== '*' && src[end - 1] !== '*')) end++; const closed = end < src.length; const inner = src.slice(i + 1, closed ? end : src.length); const k = key(); push({parseInline(inner, k)}); i = closed ? end + 1 : src.length; continue; } // Images must be tried before links: `![alt](url)` starts with the same `[`. if (c === '!' && src[i + 1] === '[') { const closeLabel = src.indexOf(']', i + 2); if (closeLabel !== -1 && src[closeLabel + 1] === '(') { const endUrl = src.indexOf(')', closeLabel + 2); const alt = src.slice(i + 2, closeLabel); const rawUrl = stripImageTitle( endUrl === -1 ? src.slice(closeLabel + 2) : src.slice(closeLabel + 2, endUrl), ); const k = key(); // While streaming, the URL may still be half-typed: hold the alt text back until closed. if (endUrl !== -1) { const resolved = resolveImageUrl(rawUrl); if (resolved) { push( , ); i = endUrl + 1; continue; } // Unsafe/unresolvable target: fall through and show the alt text only. push({parseInline(alt, k)}); i = endUrl + 1; continue; } push({parseInline(alt, k)}); i = src.length; continue; } } 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); if (src[close + 1] === '(') { const endUrl = src.indexOf(')', close + 2); const rawUrl = (endUrl === -1 ? src.slice(close + 2) : src.slice(close + 2, endUrl)).trim(); const k = key(); if (endUrl !== -1) { const classified = classifyLinkTarget(rawUrl); if (classified.kind === 'web') { push( {parseInline(label, k)} , ); } 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. push({parseInline(label, k)}); } i = endUrl === -1 ? src.length : endUrl + 1; continue; } } } // Bare HTTP(S) URLs in markdown text if ((src.startsWith('http://', i) || src.startsWith('https://', i)) && (i === 0 || /\s|[([<]/.test(src[i - 1]))) { const match = src.slice(i).match(/^(https?:\/\/[^\s<>'")\]]+)/); if (match) { const rawUrl = match[1].replace(/[.,;:)]+$/, ''); const k = key(); push(); i += rawUrl.length; continue; } } buf += c; i++; } flush(); return out; } function isTableDelimiter(line) { const trimmed = line.trim(); if (!trimmed.includes('-')) return false; const parts = trimmed.replace(/^\|/, '').replace(/\|$/, '').split('|'); return parts.length > 0 && parts.every((p) => /^\s*:?-+:?\s*$/.test(p)); } function parseTableAlignments(line) { const parts = line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|'); return parts.map((p) => { const s = p.trim(); const left = s.startsWith(':'); const right = s.endsWith(':'); if (left && right) return 'center'; if (right) return 'right'; if (left) return 'left'; return 'left'; }); } function parseTableRowCells(line) { const trimmed = line.trim(); // Strip outer pipes if present const content = trimmed.replace(/^\|/, '').replace(/\|$/, ''); return content.split('|').map((c) => c.trim()); } function parseBlocks(text) { const lines = String(text ?? '').replace(/\r\n?/g, '\n').split('\n'); const blocks = []; let i = 0; const isBlockStart = (l) => /^```/.test(l) || /^#{1,6}\s/.test(l) || /^\s*([-*+]|\d+\.)\s+/.test(l) || /^>/.test(l) || /^(-{3,}|\*{3,}|_{3,})\s*$/.test(l) || (l.includes('|') && i + 1 < lines.length && isTableDelimiter(lines[i + 1])); while (i < lines.length) { const line = lines[i]; if (!line.trim()) { i++; continue; } const fence = line.match(/^```\s*([\w+-]*)/); if (fence) { const code = []; i++; while (i < lines.length && !/^```\s*$/.test(lines[i])) code.push(lines[i++]); i++; // closing fence (or end of stream: healed implicitly) blocks.push({ type: 'code', lang: fence[1], text: code.join('\n') }); continue; } // Markdown Table check: line has '|' and next line is table delimiter or end of text during stream if (line.includes('|') && i + 1 < lines.length && isTableDelimiter(lines[i + 1])) { const headerCells = parseTableRowCells(line); const alignments = parseTableAlignments(lines[i + 1]); i += 2; // skip header and delimiter const rows = []; while (i < lines.length && lines[i].trim() && lines[i].includes('|') && !isBlockStart(lines[i])) { rows.push(parseTableRowCells(lines[i])); i++; } blocks.push({ type: 'table', headers: headerCells, alignments, rows, }); continue; } const heading = line.match(/^(#{1,6})\s+(.*)$/); if (heading) { blocks.push({ type: 'heading', level: heading[1].length, text: heading[2] }); i++; continue; } if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) { blocks.push({ type: 'hr' }); i++; continue; } if (/^>/.test(line)) { const quote = []; while (i < lines.length && /^>/.test(lines[i])) quote.push(lines[i++].replace(/^>\s?/, '')); blocks.push({ type: 'quote', text: quote.join('\n') }); continue; } const item = line.match(/^\s*([-*+]|\d+\.)\s+(.*)$/); if (item) { const ordered = /\d/.test(item[1]); const items = []; while (i < lines.length) { const m = lines[i].match(/^\s*([-*+]|\d+\.)\s+(.*)$/); if (!m || /\d/.test(m[1]) !== ordered) break; items.push(m[2]); i++; } blocks.push({ type: 'list', ordered, items }); continue; } const para = []; while (i < lines.length && lines[i].trim() && !(para.length && isBlockStart(lines[i]))) para.push(lines[i++]); blocks.push({ type: 'p', text: para.join('\n') }); } return blocks; } const rootSx = (theme) => ({ whiteSpace: 'normal', '& > *:first-of-type': { mt: 0 }, '& > *:last-child': { mb: 0 }, '& p, & .md-p': { m: 0, mb: 1, whiteSpace: 'pre-line' }, '& h1, & h2, & h3, & h4, & h5, & h6': { m: 0, mt: 1.25, mb: 0.75, lineHeight: 1.3, fontWeight: 600 }, '& h1': { fontSize: '1.2rem' }, '& h2': { fontSize: '1.1rem' }, '& h3': { fontSize: '1rem' }, '& h4, & h5, & h6': { fontSize: '0.9rem' }, '& ul, & ol': { m: 0, mb: 1, pl: 3 }, '& li': { mb: 0.25 }, '& blockquote': { m: 0, mb: 1, pl: 1.5, borderLeft: `3px solid ${theme.palette.divider}`, color: 'text.secondary', whiteSpace: 'pre-line', }, '& code': { fontFamily: 'monospace', fontSize: '0.82em', px: 0.5, borderRadius: 0.75, bbox: byMode(theme, CODE_INLINE_PAIR), }, '& pre': { m: 0, mb: 1, p: 1.25, borderRadius: 1.5, overflowX: 'auto', bgcolor: byMode(theme, CODE_BLOCK_PAIR), }, '& pre code': { p: 0, bgcolor: 'transparent', whiteSpace: 'pre' }, '& hr': { border: 0, borderTop: `1px solid ${theme.palette.divider}`, my: 1.5 }, '& a': { color: 'primary.main' }, '& .md-image': { display: 'block', my: 1, maxWidth: '100%', maxHeight: 360, objectFit: 'contain', borderRadius: 1.5, border: `1px solid ${theme.palette.divider}`, bbox: byMode(theme, SURFACE_SUBTLE_PAIR), }, '& .md-image-fallback': { display: 'inline-block', px: 0.5, borderRadius: 0.75, fontStyle: 'italic', color: 'text.disabled', border: `1px dashed ${theme.palette.divider}`, }, '& .table-container': { width: '100%', overflowX: 'auto', my: 1.5, borderRadius: 1.5, border: `1px solid ${theme.palette.divider}`, }, '& table': { width: '100%', borderCollapse: 'collapse', fontSize: '0.82rem', textAlign: 'left', }, '& th, & td': { py: 0.75, px: 1.25, borderBottom: `1px solid ${theme.palette.divider}`, borderRight: `1px solid ${theme.palette.divider}`, '&:last-child': { borderRight: 'none' }, }, '& th': { bbox: byMode(theme, TABLE_HEADER_PAIR), fontWeight: 600, }, '& tr:last-child td': { borderBottom: 'none', }, }); function HealingMarkdown({ text, streaming = false }) { const blocks = parseBlocks(text); const cursor = streaming ? ' ▍' : null; return ( {blocks.map((b, idx) => { const last = idx === blocks.length - 1; const tail = last ? cursor : null; const k = `b${idx}`; switch (b.type) { case 'table': return ( {b.headers.map((h, colIdx) => ( ))} {b.rows.map((row, rowIdx) => { const isLastRow = last && rowIdx === b.rows.length - 1; return ( {b.headers.map((_, colIdx) => { const isLastCell = isLastRow && colIdx === b.headers.length - 1; const cellValue = row[colIdx] ?? ''; return ( ); })} ); })}
{parseInline(h, `${k}-th-${colIdx}`)}
{parseInline(cellValue, `${k}-td-${rowIdx}-${colIdx}`)} {isLastCell ? tail : null}
); case 'code': return (
                
                  {b.text}
                  {tail}
                
              
); case 'heading': { const Tag = `h${b.level}`; return ( {parseInline(b.text, k)} {tail} ); } case 'hr': return
; case 'quote': return (
{parseInline(b.text, k)} {tail}
); case 'list': { const Tag = b.ordered ? 'ol' : 'ul'; return ( {b.items.map((it, j) => (
  • {parseInline(it, `${k}-${j}`)} {last && j === b.items.length - 1 ? tail : null}
  • ))}
    ); } default: return ( {parseInline(b.text, k)} {tail} ); } })} {streaming && blocks.length === 0 ? cursor : null}
    ); } export default memo(HealingMarkdown);