919 lines
27 KiB
JavaScript
919 lines
27 KiB
JavaScript
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 .
|
|
*/
|
|
|
|
/**
|
|
* 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 `` and
|
|
* `` 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 <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 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 (
|
|
<LanguageRoundedIcon
|
|
sx={{ fontSize: 16, color: 'text.secondary', flexShrink: 0 }}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Box
|
|
component="img"
|
|
src={currentSrc}
|
|
alt=""
|
|
onError={this.handleError}
|
|
sx={{
|
|
width: 16,
|
|
height: 16,
|
|
borderRadius: '3px',
|
|
objectFit: 'contain',
|
|
flexShrink: 0,
|
|
display: 'inline-block',
|
|
verticalAlign: 'middle',
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
}
|
|
|
|
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 (
|
|
<Box
|
|
component="a"
|
|
href={url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
sx={{
|
|
display: 'flex',
|
|
alignItems: 'stretch',
|
|
my: 1,
|
|
maxWidth: 440,
|
|
borderRadius: 2,
|
|
border: '1px solid',
|
|
borderColor: 'divider',
|
|
bgcolor: (theme) => (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)',
|
|
},
|
|
}}
|
|
>
|
|
<Box
|
|
sx={{
|
|
flex: 1,
|
|
p: 1.2,
|
|
minWidth: 0,
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
justifyContent: 'center',
|
|
gap: 0.35,
|
|
}}
|
|
>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.6 }}>
|
|
<FaviconImg url={url} domain={domain} />
|
|
<Typography
|
|
variant="caption"
|
|
sx={{
|
|
fontSize: '0.68rem',
|
|
color: 'text.secondary',
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
whiteSpace: 'nowrap',
|
|
fontWeight: 500,
|
|
}}
|
|
>
|
|
{meta.publisher || domain}
|
|
</Typography>
|
|
</Box>
|
|
|
|
<Typography
|
|
sx={{
|
|
fontSize: '0.82rem',
|
|
fontWeight: 600,
|
|
lineHeight: 1.3,
|
|
color: 'text.primary',
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
display: '-webkit-box',
|
|
WebkitLineClamp: 2,
|
|
WebkitBoxOrient: 'vertical',
|
|
}}
|
|
>
|
|
{meta.title}
|
|
</Typography>
|
|
|
|
{meta.description ? (
|
|
<Typography
|
|
sx={{
|
|
fontSize: '0.72rem',
|
|
color: 'text.secondary',
|
|
lineHeight: 1.35,
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
display: '-webkit-box',
|
|
WebkitLineClamp: 2,
|
|
WebkitBoxOrient: 'vertical',
|
|
}}
|
|
>
|
|
{meta.description}
|
|
</Typography>
|
|
) : null}
|
|
</Box>
|
|
|
|
{meta.image ? (
|
|
<Box
|
|
sx={{
|
|
width: 84,
|
|
minWidth: 84,
|
|
bgcolor: 'action.hover',
|
|
position: 'relative',
|
|
overflow: 'hidden',
|
|
}}
|
|
>
|
|
<Box
|
|
component="img"
|
|
src={meta.image}
|
|
alt=""
|
|
sx={{
|
|
width: '100%',
|
|
height: '100%',
|
|
objectFit: 'cover',
|
|
display: 'block',
|
|
}}
|
|
onError={(e) => {
|
|
e.currentTarget.parentElement.style.display = 'none';
|
|
}}
|
|
/>
|
|
</Box>
|
|
) : null}
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
// Fallback: render favicon box with domain/label
|
|
const displayLabel = (typeof label === 'string' && label.trim() && label.trim() !== url)
|
|
? label.trim()
|
|
: (children || domain || url);
|
|
|
|
return (
|
|
<Box
|
|
component="a"
|
|
href={url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
sx={{
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
gap: 0.75,
|
|
my: 0.35,
|
|
mx: 0.2,
|
|
px: 1,
|
|
py: 0.35,
|
|
borderRadius: 1.5,
|
|
border: '1px solid',
|
|
borderColor: 'divider',
|
|
bgcolor: (theme) => (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',
|
|
},
|
|
}}
|
|
>
|
|
<FaviconImg url={url} domain={domain} />
|
|
<Typography
|
|
component="span"
|
|
sx={{
|
|
fontSize: '0.8rem',
|
|
fontWeight: 500,
|
|
color: 'inherit',
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
whiteSpace: 'nowrap',
|
|
lineHeight: 1.25,
|
|
}}
|
|
>
|
|
{displayLabel}
|
|
</Typography>
|
|
</Box>
|
|
);
|
|
}
|
|
}
|
|
|
|
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: `` 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 === '[') {
|
|
// 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(
|
|
<VfsAttachmentLink
|
|
key={key()}
|
|
name={parsed.name}
|
|
path={parsed.path}
|
|
type={parsed.type}
|
|
isUser={false}
|
|
/>,
|
|
);
|
|
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(
|
|
<WebLinkCard key={k} url={classified.url} label={label}>
|
|
{parseInline(label, k)}
|
|
</WebLinkCard>,
|
|
);
|
|
} else if (classified.kind === 'nav') {
|
|
push(
|
|
<a key={k} href={classified.url} target="_blank" rel="noopener noreferrer">
|
|
{parseInline(label, k)}
|
|
</a>,
|
|
);
|
|
} else if (classified.kind === 'vfs') {
|
|
const filename = classified.path.split('/').pop() || 'datei';
|
|
push(
|
|
<VfsAttachmentLink
|
|
key={k}
|
|
name={filename}
|
|
path={classified.path}
|
|
label={parseInline(label, k)}
|
|
isUser={false}
|
|
/>,
|
|
);
|
|
} else {
|
|
push(<span key={k}>{parseInline(label, k)}</span>);
|
|
}
|
|
} 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;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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(<WebLinkCard key={k} url={rawUrl} label={rawUrl} />);
|
|
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 (
|
|
<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 (
|
|
<Box component="div" className="md-p" key={k}>
|
|
{parseInline(b.text, k)}
|
|
{tail}
|
|
</Box>
|
|
);
|
|
}
|
|
})}
|
|
{streaming && blocks.length === 0 ? cursor : null}
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
export default memo(HealingMarkdown);
|