MNEMO / client /src /components /AnswerRenderer.jsx
SyntaxAdi
feat: add Mermaid diagram support to AnswerRenderer
ada5620
Raw
History Blame Contribute Delete
6.16 kB
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import rehypeHighlight from "rehype-highlight";
import rehypeKatex from "rehype-katex";
import "katex/dist/katex.min.css";
import Mermaid from "./Mermaid";
function preprocessContent(text) {
if (!text) return "";
let processedText = text;
const imageBlockRegex = /((?:!\[.*?\]\(.*?\)(?:\s| )*){2,})/g;
processedText = processedText.replace(imageBlockRegex, (match) => {
return `\n\`\`\`slider\n${match.trim()}\n\`\`\`\n`;
});
const parts = processedText.split(/(```[\s\S]*?```|`[^`\n]+`|!\[[\s\S]*?\]\([\s\S]*?\))/g);
const processedParts = parts.map(part => {
if (part.startsWith("```") || part.startsWith("`") || part.startsWith("![")) return part;
let processed = part;
const mermaidRegex = /^(?:graph|flowchart|classDiagram|stateDiagram|erDiagram|sequenceDiagram|gantt|pie|quadrantChart|xychart|mindmap|timeline)[\s\S]+?(?=\n\n|\n$|$)/gm;
processed = processed.replace(mermaidRegex, (match) => {
if (match.trim().split("\n").length > 1) {
return `\n\`\`\`mermaid\n${match.trim()}\n\`\`\`\n`;
}
return match;
});
const treeRegex = /((?:^[ \t]*[β”‚β”œβ””β”Œβ”β”˜β”€β”Ό|].*$\n?)+)/gm;
processed = processed.replace(treeRegex, (match) => {
const lines = match.trim().split("\n");
const hasStrongTreeChars = /[β”‚β”œβ””β”Œβ”β”˜β”€β”Όβ”œβ”€]/.test(match);
if (lines.length > 1 || hasStrongTreeChars) {
return `\n\`\`\`tree\n${match.trim()}\n\`\`\`\n`;
}
return match;
});
processed = processed.replace(/^[ \t\u00a0]*[‒●○·][ \t\u00a0]*/gm, "- ");
processed = processed.replace(/\\\[([\s\S]*?)\\\]/g, "$$$$1$$");
processed = processed.replace(/\\\(([\s\S]*?)\\\)/g, "$$1$");
processed = processed.replace(/(\((.+?)\)|\[(.+?)\])/g, (match, full, p1, p2) => {
const content = (p1 || p2 || "").trim();
if (!content) return match;
const hasMathOperator = /[\\_^={}+\-*/]/.test(content);
const isSingleVar = /^[a-z]$/i.test(content);
const isExpression = /^[a-z0-9] *[+\-*/=] *[a-z0-9]/i.test(content);
if (hasMathOperator || isSingleVar || isExpression) {
const delim = (match.startsWith("[") || content.length > 50) ? "$$" : "$";
return `${delim}${match}${delim}`;
}
return match;
});
const lines = processed.split("\n");
const resultLines = lines.map(line => {
const trimmed = line.trim();
if (
(trimmed.includes("\\") || /[_^]/.test(trimmed) || /^[a-z] *=/i.test(trimmed)) &&
!trimmed.includes("$") &&
!trimmed.startsWith("#") &&
!trimmed.startsWith("-") &&
!trimmed.startsWith("*") &&
trimmed.length > 0
) {
if (/^[a-zA-Z0-9] *=/.test(trimmed) || /^\\[a-zA-Z]+/.test(trimmed)) {
return `$$${trimmed}$$`;
}
}
return line;
});
return resultLines.join("\n");
});
let finalProcessed = processedParts.join("");
finalProcessed = finalProcessed.replace(/([^\n])\n- /g, "$1\n\n- ");
return finalProcessed;
}
export function AnswerRenderer({ answer }) {
const processedAnswer = preprocessContent(answer);
return (
<div className="answer-body">
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeHighlight, rehypeKatex]}
components={{
code({ node, inline, className, children, ...props }) {
const match = /language-(\w+)/.exec(className || "");
const content = String(children).replace(/\n$/, "");
if (!inline && match?.[1] === "mermaid") {
return <Mermaid chart={content} />;
}
if (!inline && match?.[1] === "slider") {
const imageMatches = content.match(/!\[.*?\]\((.*?)\)/g) || [];
const imageUrls = imageMatches.map(m => {
const urlMatch = m.match(/\((.*?)\)/);
return urlMatch ? urlMatch[1] : null;
}).filter(Boolean);
return (
<div className="image-slider-container">
{imageUrls.map((url, idx) => (
<div key={idx} className="slider-item">
<img src={url} alt={`Image ${idx + 1}`} loading="lazy" />
</div>
))}
</div>
);
}
const isTree = match?.[1] === "tree" ||
/[β”‚β”œβ””β”Œβ”β”˜β”€β”Όβ”œβ”€]/.test(content) ||
(content.includes("|") && /[─━]/.test(content)) ||
/^[ \t]*[|β”‚].*$/m.test(content);
if (!inline && isTree) {
const handleCopy = () => {
navigator.clipboard.writeText(content);
};
return (
<div className="chatgpt-style-block">
<div className="block-header">
<span className="block-title">STRUCTURE</span>
<button className="copy-button" title="Copy code" onClick={handleCopy}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>
</button>
</div>
<pre className="block-content">
<code>{children}</code>
</pre>
</div>
);
}
return !inline && match ? (
<pre className={className}>
<code className={className} {...props}>
{children}
</code>
</pre>
) : (
<code className={className} {...props}>
{children}
</code>
);
},
}}
>
{processedAnswer}
</ReactMarkdown>
</div>
);
}