File size: 4,792 Bytes
1db9b28
f18b03d
 
30cd0c9
 
 
cac20f9
1db9b28
30cd0c9
f18b03d
 
 
eb3bd3c
 
f18b03d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
587748b
 
f18b03d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
eb3bd3c
 
 
 
f18b03d
eb3bd3c
 
 
f18b03d
 
 
 
 
 
1db9b28
 
 
 
 
 
 
 
 
 
 
f18b03d
 
 
 
 
 
1db9b28
 
 
 
 
 
cac20f9
f18b03d
 
 
 
30cd0c9
f18b03d
 
30cd0c9
587748b
f18b03d
 
30cd0c9
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
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>
  );
}