返回 DeepSeek-Reasonix
markdownComponents.tsx
根目录 / desktop / frontend / src / components / markdownComponents.tsx
1 // markdownComponents — the shared components map for both Markdown render
2 // paths: react-markdown (streaming) and the worker-parsed block renderer
3 // (history). Kept in its own CSS-free module so the worker-path renderer and
4 // plain-tsx tests can import it without pulling the katex stylesheet.
5 //
6 // Fenced code blocks go through CodeViewer for syntax highlighting; inline
7 // code is a styled <code>. Mermaid fences lazy-load the diagram renderer.
8 // Links open in the system browser via RichMarkdownLink. Tables use natural
9 // document flow; large code fences have an explicit disclosure.
10
11 import { lazy, Suspense, useMemo, useState, type ReactNode } from "react";
12 import type { Components } from "react-markdown";
13 import { CodeViewer } from "./CodeViewer";
14 import { RichMarkdownLink } from "./githubLink";
15 import { MarkdownTable } from "./MarkdownTable";
16 import { MarkdownImage } from "./MarkdownImage";
17 import { t } from "../lib/i18n";
18 import { useChatFileLink } from "./ChatFileLinkContext";
19 import { ChatFileReferenceAnchor, ChatFileReferenceCode } from "./ChatFileLink";
20 import { localPathFromHref } from "../lib/localFileUrl";
21 import { looksLikeSvgDocument } from "../lib/svgDocument";
22
23 const MermaidDiagram = lazy(() => import("./MermaidDiagram"));
24 const MarkdownSvgBlock = lazy(() => import("./MarkdownSvgBlock"));
25
26 /** Fences that may hold an SVG document; the body still has to prove it. */
27 const SVG_FENCES = new Set(["svg", "xml", "html"]);
28
29 function MarkdownCode({ value, language }: { value: string; language?: string }) {
30 const [expanded, setExpanded] = useState(false);
31 const lines = useMemo(() => value.split("\n"), [value]);
32 const large = lines.length > 200;
33 return <><CodeViewer value={large && !expanded ? lines.slice(0, 200).join("\n") : value} copyValue={value} language={language} scrollMode="expand" />
34 {large && <div className="chat-code-fold"><button className="btn" aria-expanded={expanded} onClick={() => setExpanded(!expanded)}>{t(expanded ? "chat.collapseCode" : "chat.expandCode")}</button></div>}
35 </>;
36 }
37
38 const STATUS_MARKER_RE = /(?:✅|☑|☒|✔️?|✓|\[[xX ]\])/;
39 const STATUS_MARKER_GLOBAL_RE = /(?:✅|☑|☒|✔️?|✓|\[[xX ]\])/g;
40 const BULLET_RE = /^[-*•]\s+\S/;
41 const DIVIDER_RE = /^[\s\-_=─━—]+$/;
42
43 function splitStatusLine(line: string): string[] {
44 const parts = (line.match(STATUS_MARKER_GLOBAL_RE) ?? []).length > 1
45 ? line.split(/(?=(?:✅|☑|☒|✔️?|✓|\[[xX ]\]))/)
46 : [line];
47 return parts
48 .map((part) => part.replace(/^(?:✅|☑|☒|✔️?|✓|\[[xX ]\]|[-*•])\s*/i, "").trim())
49 .filter(Boolean)
50 .map((part) => part.replace(/\s{2,}/g, " · "));
51 }
52
53 function looksLikeDiagram(text: string): boolean {
54 return /[←→↔]|<{1,2}-{2,}|-{2,}>{1,2}|[-_=─━]{6,}/.test(text);
55 }
56
57 function splitPlainBlock(text: string): { preText: string; statusItems: string[] } {
58 const items: string[] = [];
59 const preLines: string[] = [];
60 const lines = text.split(/\r?\n/);
61 const bulletLines = lines.filter((line) => BULLET_RE.test(line.trim())).length;
62 const collectBulletLines = bulletLines >= 2 && !looksLikeDiagram(text);
63 for (const rawLine of lines) {
64 const line = rawLine.trim();
65 const marked = STATUS_MARKER_RE.test(line) || (collectBulletLines && BULLET_RE.test(line));
66 if (marked) {
67 items.push(...splitStatusLine(line));
68 } else if (DIVIDER_RE.test(line) && items.length > 0 && !looksLikeDiagram(text)) {
69 continue;
70 } else {
71 preLines.push(rawLine);
72 }
73 }
74 while (preLines.length > 0 && preLines[0].trim() === "") preLines.shift();
75 while (preLines.length > 0 && preLines[preLines.length - 1].trim() === "") preLines.pop();
76 return { preText: preLines.join("\n"), statusItems: items };
77 }
78
79 function PlainMarkdownBlock({ text }: { text: string }) {
80 if (text.trim() === "") return null;
81 const { preText, statusItems } = splitPlainBlock(text);
82 const asList = statusItems.length >= 2;
83 return (
84 <div className={`md-plain-block${asList ? " md-plain-block--split" : " md-plain-block--pre"}`}>
85 <CodeViewer value={text} scrollMode="bounded" maxHeight="min(60vh, 28rem)" />
86 {asList && preText && (
87 <div className="md-plain-block__diagram">
88 <CodeViewer value={preText} scrollMode="bounded" maxHeight="min(60vh, 28rem)" />
89 </div>
90 )}
91 {asList && (
92 <div className="md-status-list">
93 {statusItems.map((item, index) => (
94 <div className="md-status-list__item" key={`${index}-${item}`}>
95 <span className="md-status-list__dot" aria-hidden="true" />
96 <span className="md-status-list__text">{item}</span>
97 </div>
98 ))}
99 </div>
100 )}
101 </div>
102 );
103 }
104
105 // The components map is shared by the main-thread react-markdown renderer
106 // (streaming path) and the worker-parsed block renderer (history path), so
107 // both produce byte-identical DOM for the same document.
108 export function createComponents(plainStatusBlocks: boolean): Components {
109 return {
110 pre: ({ children }) => <>{children}</>,
111 table: ({ children }) => <MarkdownTable>{children}</MarkdownTable>,
112 code: ({ className, children }) => {
113 const text = String(children ?? "");
114 const match = /language-([\w-]+)/.exec(className ?? "");
115 const lang = match?.[1];
116 const isBlock = match !== null || text.includes("\n");
117 if (isBlock) {
118 const value = text.replace(/\n$/, "");
119 // An empty fence is a formatting placeholder; a bordered one-line
120 // CodeViewer would otherwise become a phantom row in every surface.
121 if (value.trim() === "") return null;
122 if (lang === "mermaid") {
123 return (
124 <Suspense fallback={<CodeViewer value={value} language="mermaid" scrollMode="bounded" maxHeight="min(60vh, 28rem)" />}>
125 <MermaidDiagram definition={value} />
126 </Suspense>
127 );
128 }
129 if (!match && plainStatusBlocks) return <PlainMarkdownBlock text={text.replace(/\n$/, "")} />;
130 // An `svg` fence is always a candidate; `xml`, `html`, and a
131 // language-less fence are candidates only when the body starts like a
132 // single SVG document. The host's strict parse has the final word, and
133 // anything it refuses renders as the ordinary code block.
134 if (looksLikeSvgDocument(value) && (lang === undefined || SVG_FENCES.has(lang))) {
135 return (
136 <Suspense fallback={<MarkdownCode value={value} language={lang} />}>
137 <MarkdownSvgBlock value={value} />
138 </Suspense>
139 );
140 }
141 return <MarkdownCode value={value} language={lang} />;
142 }
143 return <InlineMarkdownCode text={text}>{children}</InlineMarkdownCode>;
144 },
145 a: (props) => <MarkdownFileLink href={props.href} scanned={(props as Record<string, unknown>)["data-scanned-path"] !== undefined}>{props.children}</MarkdownFileLink>,
146 img: ({ src, alt, title }) => <MarkdownImage src={src} alt={alt} title={title} />,
147 };
148 }
149
150 /**
151 * A local-path link is upgraded to a chat file reference when the host verified
152 * it. A path that was only *scanned* out of prose stays ordinary text until it
153 * is verified — a command, a URL path, or a directory that merely looks like a
154 * file must not become a link. Every other link keeps its existing behavior.
155 */
156 function MarkdownFileLink({ href, scanned, children }: { href?: string; scanned: boolean; children: ReactNode }) {
157 const path = href ? localPathFromHref(href) : null;
158 const link = useChatFileLink(path ?? "");
159 if (path && link) return <ChatFileReferenceAnchor link={link} href={href!}>{children}</ChatFileReferenceAnchor>;
160 if (path && scanned) return <span className="md-rich-link__plain">{children}</span>;
161 return <RichMarkdownLink href={href}>{children}</RichMarkdownLink>;
162 }
163
164 function InlineMarkdownCode({ text, children }: { text: string; children: ReactNode }) {
165 const file = useChatFileLink(text);
166 if (!file) return <code className="md-code">{children}</code>;
167 return <ChatFileReferenceCode link={file}>{children}</ChatFileReferenceCode>;
168 }
169
169 lines Plain Text