返回 DeepSeek-Reasonix
MarkdownRenderer.tsx
根目录 / desktop / frontend / src / components / MarkdownRenderer.tsx
1 import { lazy, memo, Suspense, useMemo, useRef } from "react";
2 import ReactMarkdown, { defaultUrlTransform } from "react-markdown";
3 import type { Components } from "react-markdown";
4 import "katex/dist/katex.min.css";
5 import { CodeViewer } from "./CodeViewer";
6 import { RichMarkdownLink } from "./githubLink";
7 import { normalizeMath } from "./mathNormalize";
8 import { reasonixRehypePlugins, reasonixRemarkPlugins } from "./markdownRemarkPlugins";
9 import { markdownImageSource } from "../lib/markdownImage";
10
11 const MermaidDiagram = lazy(() => import("./MermaidDiagram"));
12
13 // Markdown rendering via react-markdown + remark-gfm (tables, task lists,
14 // strike, autolinks) and remark-math + rehype-katex for $/$$ KaTeX math.
15 // Fenced code blocks go through CodeViewer for syntax highlighting; inline
16 // code is a styled <code>. Links open in the system browser.
17 //
18 // The math pre-pass repairs LLM-native delimiters and display structure.
19 // remarkMathPolicy then classifies parsed inline-math AST nodes using their
20 // surrounding prose, avoiding false positives on currency and env vars.
21
22 const STATUS_MARKER_RE = /(?:✅|☑|☒|✔️?|✓|\[[xX ]\])/;
23 const STATUS_MARKER_GLOBAL_RE = /(?:✅|☑|☒|✔️?|✓|\[[xX ]\])/g;
24 const BULLET_RE = /^[-*•]\s+\S/;
25 const DIVIDER_RE = /^[\s\-_=─━—]+$/;
26
27 // file:/// hrefs come from local-path linkification (remarkLocalPathLinks)
28 // and must survive react-markdown's default URL sanitisation, which would
29 // otherwise blank them along with javascript: and friends.
30 function markdownUrlTransform(value: string): string {
31 return value.startsWith("file:///") ? value : defaultUrlTransform(value);
32 }
33
34 function splitStatusLine(line: string): string[] {
35 const parts = (line.match(STATUS_MARKER_GLOBAL_RE) ?? []).length > 1
36 ? line.split(/(?=(?:✅|☑|☒|✔️?|✓|\[[xX ]\]))/)
37 : [line];
38 return parts
39 .map((part) => part.replace(/^(?:✅|☑|☒|✔️?|✓|\[[xX ]\]|[-*•])\s*/i, "").trim())
40 .filter(Boolean)
41 .map((part) => part.replace(/\s{2,}/g, " · "));
42 }
43
44 function looksLikeDiagram(text: string): boolean {
45 return /[←→↔]|<{1,2}-{2,}|-{2,}>{1,2}|[-_=─━]{6,}/.test(text);
46 }
47
48 function splitPlainBlock(text: string): { preText: string; statusItems: string[] } {
49 const items: string[] = [];
50 const preLines: string[] = [];
51 const lines = text.split(/\r?\n/);
52 const bulletLines = lines.filter((line) => BULLET_RE.test(line.trim())).length;
53 const collectBulletLines = bulletLines >= 2 && !looksLikeDiagram(text);
54 for (const rawLine of lines) {
55 const line = rawLine.trim();
56 const marked = STATUS_MARKER_RE.test(line) || (collectBulletLines && BULLET_RE.test(line));
57 if (marked) {
58 items.push(...splitStatusLine(line));
59 } else if (DIVIDER_RE.test(line) && items.length > 0 && !looksLikeDiagram(text)) {
60 continue;
61 } else {
62 preLines.push(rawLine);
63 }
64 }
65 while (preLines.length > 0 && preLines[0].trim() === "") preLines.shift();
66 while (preLines.length > 0 && preLines[preLines.length - 1].trim() === "") preLines.pop();
67 return { preText: preLines.join("\n"), statusItems: items };
68 }
69
70 function PlainMarkdownBlock({ text }: { text: string }) {
71 const { preText, statusItems } = splitPlainBlock(text);
72 const asList = statusItems.length >= 2;
73 return (
74 <div className={`md-plain-block${asList ? " md-plain-block--split" : " md-plain-block--pre"}`}>
75 <CodeViewer value={text} maxHeight={360} />
76 {asList && preText && (
77 <div className="md-plain-block__diagram">
78 <CodeViewer value={preText} maxHeight={360} />
79 </div>
80 )}
81 {asList && (
82 <div className="md-status-list">
83 {statusItems.map((item, index) => (
84 <div className="md-status-list__item" key={`${index}-${item}`}>
85 <span className="md-status-list__dot" aria-hidden="true" />
86 <span className="md-status-list__text">{item}</span>
87 </div>
88 ))}
89 </div>
90 )}
91 </div>
92 );
93 }
94
95 function createComponents(plainStatusBlocks: boolean): Components {
96 return {
97 pre: ({ children }) => <>{children}</>,
98 code: ({ className, children }) => {
99 const text = String(children ?? "");
100 const match = /language-([\w-]+)/.exec(className ?? "");
101 const lang = match?.[1];
102 const isBlock = match !== null || text.includes("\n");
103 if (isBlock) {
104 const value = text.replace(/\n$/, "");
105 if (lang === "mermaid") {
106 return (
107 <Suspense fallback={<CodeViewer value={value} language="mermaid" maxHeight={360} />}>
108 <MermaidDiagram definition={value} />
109 </Suspense>
110 );
111 }
112 if (!match && plainStatusBlocks) return <PlainMarkdownBlock text={text.replace(/\n$/, "")} />;
113 return <CodeViewer value={value} language={lang} maxHeight={360} />;
114 }
115 return <code className="md-code">{children}</code>;
116 },
117 a: ({ href, children }) => <RichMarkdownLink href={href}>{children}</RichMarkdownLink>,
118 img: ({ src, alt, title }) => (
119 <img
120 src={markdownImageSource(src)}
121 alt={alt ?? ""}
122 title={title}
123 loading="lazy"
124 referrerPolicy="no-referrer"
125 />
126 ),
127 };
128 }
129
130 const MarkdownRenderer = memo(function MarkdownRenderer({
131 text,
132 plainStatusBlocks = false,
133 bare = false,
134 }: {
135 text: string;
136 plainStatusBlocks?: boolean;
137 bare?: boolean;
138 }) {
139 const containerRef = useRef<HTMLDivElement>(null);
140 const mathContent = useMemo(() => normalizeMath(text), [text]);
141 const components = useMemo(() => createComponents(plainStatusBlocks), [plainStatusBlocks]);
142 const content = (
143 <ReactMarkdown
144 remarkPlugins={reasonixRemarkPlugins}
145 rehypePlugins={reasonixRehypePlugins}
146 components={components}
147 // file:/// anchors (local path linkification) are safe to keep; the
148 // default transform would blank them along with javascript: etc.
149 urlTransform={markdownUrlTransform}
150 >
151 {mathContent}
152 </ReactMarkdown>
153 );
154 return bare ? content : <div className="md" ref={containerRef}>{content}</div>;
155 });
156
157 export default MarkdownRenderer;
158
158 lines Plain Text