harryagasi
feat: render plotly charts embedded in report markdown
1db9b28
Raw
History Blame Contribute Delete
4.79 kB
import { isValidElement } from "react";
import ReactMarkdown from "react-markdown";
import type { Components } from "react-markdown";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import rehypeKatex from "rehype-katex";
import { SortableMarkdownTable } from "./SortableMarkdownTable";
import { PlotlyChartBlock, type PlotlyChartSpec } from "./PlotlyChartBlock";
function repairMarkdownMarkers(line: string) {
return line
.replace(/([A-Za-z0-9])(?=\*\*)/g, "$1 ")
.replace(/(\*\*[^*\n]+\*\*)(?=[A-Za-z0-9])/g, "$1 ")
.replace(/\*\*([^*\n]*?)\*\*/g, (_, inner) => `**${inner.trim()}**`);
}
function normalizeReadableMarkdown(content: string) {
const lines = content.replace(/\r\n/g, "\n").split("\n");
let inCodeBlock = false;
const normalizedLines = lines.map((line) => {
if (line.trimStart().startsWith("```")) {
inCodeBlock = !inCodeBlock;
return line;
}
if (inCodeBlock) return line;
return repairMarkdownMarkers(line)
.replace(/([.!?])\s*-\s*(?=[A-Z0-9*])/g, "$1\n\n- ")
.replace(/(^|\n)-(?=\*\*|[A-Z0-9])/g, "$1- ")
.replace(/(?<!\d)([,;:])(?=\S)/g, "$1 ")
.replace(/(?<!\d)([.!?])(?=[A-Z])/g, "$1 ")
.replace(/[ \t]{2,}/g, " ");
});
return normalizedLines
.map((line, index) => {
const trimmed = line.trim();
if (!trimmed || /^[-*+]\s/.test(trimmed) || /^\d+\.\s/.test(trimmed)) return line;
const previous = normalizedLines.slice(0, index).reverse().find((item) => item.trim());
const next = normalizedLines.slice(index + 1).find((item) => item.trim());
const isStandaloneBold = /^\*\*.+\*\*(?:\s+.+)?$/.test(trimmed);
const belongsToBoldList = previous?.trim().endsWith(":") || previous?.trim().startsWith("**") || next?.trim().startsWith("**");
return isStandaloneBold && belongsToBoldList ? `- ${trimmed}` : line;
})
.join("\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
const markdownComponents: Components = {
p: ({ children }) => <p className="my-1.5 leading-6 text-slate-800">{children}</p>,
ul: ({ children }) => <ul className="my-1.5 list-disc space-y-0.5 pl-5">{children}</ul>,
ol: ({ children }) => <ol className="my-1.5 list-decimal space-y-0.5 pl-5">{children}</ol>,
li: ({ children }) => <li className="pl-1 leading-6 text-slate-800">{children}</li>,
strong: ({ children }) => <strong className="font-semibold text-slate-950">{children}</strong>,
h1: ({ children }) => <h1 className="mb-2 mt-4 text-xl font-semibold leading-6 text-slate-950">{children}</h1>,
h2: ({ children }) => <h2 className="mb-2 mt-3 text-lg font-semibold leading-6 text-slate-950">{children}</h2>,
h3: ({ children }) => <h3 className="mb-1.5 mt-3 text-base font-semibold leading-6 text-slate-950">{children}</h3>,
a: ({ children, href }) => (
<a href={href} className="font-medium text-emerald-700 underline decoration-emerald-200 underline-offset-4" target="_blank" rel="noreferrer">
{children}
</a>
),
code: ({ children, className }) => {
if (className === "language-plotly") {
const raw = String(children).replace(/\n$/, "");
try {
const spec = JSON.parse(raw) as PlotlyChartSpec;
if (spec?.plotly?.data) {
return <PlotlyChartBlock spec={spec} />;
}
} catch {
// fall through to raw code rendering below
}
}
const isBlock = className?.includes("language-");
if (isBlock) {
return <code className={className}>{children}</code>;
}
return <code className="rounded bg-slate-100 px-1.5 py-0.5 text-[0.85em] text-slate-900">{children}</code>;
},
pre: ({ children }) => {
const child = Array.isArray(children) ? children[0] : children;
const isChart = isValidElement(child) && child.type === PlotlyChartBlock;
if (isChart) return <>{children}</>;
return <pre className="my-3 max-w-full overflow-x-auto rounded-lg bg-slate-950 p-3 text-xs leading-6 text-slate-50">{children}</pre>;
},
table: ({ children }) => <SortableMarkdownTable>{children}</SortableMarkdownTable>,
th: ({ children }) => <th className="border border-slate-200 bg-slate-50 px-3 py-2 text-left font-semibold text-slate-900">{children}</th>,
td: ({ children }) => <td className="border border-slate-200 px-3 py-2 align-top text-slate-700">{children}</td>,
};
export function MarkdownContent({ content }: { content: string }) {
const readableContent = normalizeReadableMarkdown(content);
return (
<div className="agent-markdown max-w-none text-sm leading-7 text-slate-800 [overflow-wrap:anywhere]">
<ReactMarkdown remarkPlugins={[remarkGfm, remarkMath]} rehypePlugins={[rehypeKatex]} components={markdownComponents}>
{readableContent}
</ReactMarkdown>
</div>
);
}