This commit is contained in:
seb
2026-10-04 02:13:50 +02:00
parent 0d0c5dfdfb
commit f2fce9097b
19 changed files with 3087 additions and 9 deletions

View File

@@ -0,0 +1,505 @@
import React, { memo, useState } from 'react';
import Box from '@mui/material/Box';
/**
* 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).
*/
const SAFE_URL = /^(https?:\/\/|mailto:|\/|#)/i;
/**
* 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;
if (/^https?:\/\//i.test(url) || url.startsWith('/')) return url;
// Everything else is a VFS name: strip a leading `./`, encode each segment, keep subdirs.
const segments = url
.replace(/^\.\//, '')
.split('/')
.filter((s) => s && s !== '.')
.map((s) => encodeURIComponent(s));
if (segments.length === 0 || segments.includes('..')) return null;
return `/api/vfs/${segments.join('/')}`;
}
/**
* 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 <span className="md-image-fallback">{alt || '🖼'}</span>;
}
return (
<img
className="md-image"
src={src}
alt={alt || ''}
title={alt || undefined}
loading="lazy"
decoding="async"
onError={() => setFailed(true)}
/>
);
}
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 key={key()}>{code}</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 === '**' ? <strong key={k}>{children}</strong> : <del key={k}>{children}</del>);
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(<em key={k}>{parseInline(inner, k)}</em>);
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(
<MarkdownImage key={k} src={resolved} alt={alt.trim()} />,
);
i = endUrl + 1;
continue;
}
// Unsafe/unresolvable target: fall through and show the alt text only.
push(<span key={k}>{parseInline(alt, k)}</span>);
i = endUrl + 1;
continue;
}
push(<span key={k}>{parseInline(alt, k)}</span>);
i = src.length;
continue;
}
}
if (c === '[') {
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 url = (endUrl === -1 ? src.slice(close + 2) : src.slice(close + 2, endUrl)).trim();
const k = key();
if (endUrl !== -1 && SAFE_URL.test(url)) {
push(
<a key={k} href={url} target="_blank" rel="noopener noreferrer">
{parseInline(label, k)}
</a>,
);
} else {
// Half-typed (or unsafe) link: show the label only.
push(<span key={k}>{parseInline(label, k)}</span>);
}
i = endUrl === -1 ? src.length : endUrl + 1;
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': { 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,
bgcolor: theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)',
},
'& pre': {
m: 0,
mb: 1,
p: 1.25,
borderRadius: 1.5,
overflowX: 'auto',
bgcolor: theme.palette.mode === 'dark' ? 'rgba(0,0,0,0.35)' : 'rgba(0,0,0,0.06)',
},
'& 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}`,
bgcolor: theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.03)',
},
'& .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': {
bgcolor: theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.05)' : 'rgba(0, 0, 0, 0.04)',
fontWeight: 600,
},
'& tr:last-child td': {
borderBottom: 'none',
},
});
function HealingMarkdown({ text, streaming = false }) {
const blocks = parseBlocks(text);
const cursor = streaming ? ' ▍' : null;
return (
<Box sx={rootSx}>
{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 (
<Box key={k} className="table-container">
<table>
<thead>
<tr>
{b.headers.map((h, colIdx) => (
<th
key={`${k}-th-${colIdx}`}
style={{ textAlign: b.alignments[colIdx] || 'left' }}
>
{parseInline(h, `${k}-th-${colIdx}`)}
</th>
))}
</tr>
</thead>
<tbody>
{b.rows.map((row, rowIdx) => {
const isLastRow = last && rowIdx === b.rows.length - 1;
return (
<tr key={`${k}-tr-${rowIdx}`}>
{b.headers.map((_, colIdx) => {
const isLastCell = isLastRow && colIdx === b.headers.length - 1;
const cellValue = row[colIdx] ?? '';
return (
<td
key={`${k}-td-${rowIdx}-${colIdx}`}
style={{ textAlign: b.alignments[colIdx] || 'left' }}
>
{parseInline(cellValue, `${k}-td-${rowIdx}-${colIdx}`)}
{isLastCell ? tail : null}
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
</Box>
);
case 'code':
return (
<pre key={k}>
<code>
{b.text}
{tail}
</code>
</pre>
);
case 'heading': {
const Tag = `h${b.level}`;
return (
<Tag key={k}>
{parseInline(b.text, k)}
{tail}
</Tag>
);
}
case 'hr':
return <hr key={k} />;
case 'quote':
return (
<blockquote key={k}>
{parseInline(b.text, k)}
{tail}
</blockquote>
);
case 'list': {
const Tag = b.ordered ? 'ol' : 'ul';
return (
<Tag key={k}>
{b.items.map((it, j) => (
<li key={j}>
{parseInline(it, `${k}-${j}`)}
{last && j === b.items.length - 1 ? tail : null}
</li>
))}
</Tag>
);
}
default:
return (
<p key={k}>
{parseInline(b.text, k)}
{tail}
</p>
);
}
})}
{streaming && blocks.length === 0 ? cursor : null}
</Box>
);
}
export default memo(HealingMarkdown);