Files
dashfront/src/components/chat/HealingMarkdown.jsx
2026-10-07 13:36:16 +02:00

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 ![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 <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: `![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 === '[') {
// 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);