MarkdownRenderer.tsx 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272
  1. import ReactMarkdown from "react-markdown";
  2. import { Image } from "antd";
  3. import RemarkMath from "remark-math";
  4. import RemarkBreaks from "remark-breaks";
  5. import RehypeKatex from "rehype-katex";
  6. import RemarkGfm from "remark-gfm";
  7. import RehypeHighlight from "rehype-highlight";
  8. import { useRef, useState, useEffect, useMemo } from "react";
  9. import { copyToClipboard } from "@/utils/chat";
  10. import mermaid from "mermaid";
  11. import LoadingIcon from "@/assets/public/three-dots.svg";
  12. import React from "react";
  13. import { useDebouncedCallback } from "use-debounce";
  14. import "katex/dist/katex.min.css";
  15. // Types
  16. interface MermaidProps {
  17. code: string;
  18. }
  19. interface PreCodeProps {
  20. children: React.ReactNode;
  21. }
  22. interface MarkdownContentProps {
  23. content: string;
  24. }
  25. interface MarkdownProps {
  26. content: string;
  27. loading?: boolean;
  28. fontSize?: number;
  29. fontFamily?: string;
  30. defaultShow?: boolean;
  31. }
  32. /**
  33. * Mermaid 图表渲染组件
  34. */
  35. export function Mermaid(props: MermaidProps) {
  36. const ref = useRef<HTMLDivElement>(null);
  37. const [hasError, setHasError] = useState(false);
  38. useEffect(() => {
  39. if (props.code && ref.current) {
  40. mermaid
  41. .run({
  42. nodes: [ref.current],
  43. suppressErrors: true,
  44. })
  45. .catch((e) => {
  46. setHasError(true);
  47. console.error("[Mermaid] ", e.message);
  48. });
  49. }
  50. }, [props.code]);
  51. if (hasError) {
  52. return null;
  53. }
  54. return (
  55. <div
  56. className="no-dark mermaid"
  57. style={{
  58. cursor: "pointer",
  59. overflow: "auto",
  60. }}
  61. ref={ref}
  62. >
  63. {props.code}
  64. </div>
  65. );
  66. }
  67. /**
  68. * 代码块渲染组件(带语法高亮和复制功能)
  69. */
  70. export function PreCode(props: PreCodeProps) {
  71. const ref = useRef<HTMLPreElement>(null);
  72. const refText = ref.current?.innerText;
  73. const [mermaidCode, setMermaidCode] = useState("");
  74. useEffect(() => {
  75. if (ref.current) {
  76. const codeElements = ref.current.querySelectorAll(
  77. "code"
  78. ) as NodeListOf<HTMLElement>;
  79. const wrapLanguages = [
  80. "",
  81. "think",
  82. "md",
  83. "markdown",
  84. "text",
  85. "txt",
  86. "plaintext",
  87. "tex",
  88. "latex",
  89. ];
  90. codeElements.forEach((codeElement) => {
  91. let languageClass = codeElement.className.match(/language-(\w+)/);
  92. let name = languageClass ? languageClass[1] : "";
  93. if (wrapLanguages.includes(name)) {
  94. codeElement.style.whiteSpace = "pre-wrap";
  95. }
  96. });
  97. }
  98. }, [refText]);
  99. return (
  100. <>
  101. <pre ref={ref}>
  102. <span
  103. className="copy-code-button"
  104. onClick={() => {
  105. if (ref.current) {
  106. const code = ref.current.innerText;
  107. copyToClipboard(code);
  108. }
  109. }}
  110. ></span>
  111. {props.children}
  112. </pre>
  113. {mermaidCode.length > 0 && (
  114. <Mermaid code={mermaidCode} key={mermaidCode} />
  115. )}
  116. </>
  117. );
  118. }
  119. /**
  120. * LaTeX 公式预处理
  121. */
  122. function preprocessLaTeX(content: string) {
  123. const pattern =
  124. /(```[\s\S]*?```|`.*?`)|\\\[([\s\S]*?[^\\])\\\]|\\\((.*?)\\\)|(\$\$[\s\S]*?\$\$)|(\$(?!\s)[\s\S]*?\S\$)/g;
  125. return content.replace(
  126. pattern,
  127. (match, codeBlock, squareBracket, roundBracket, doubleDollar, singleDollar) => {
  128. if (codeBlock) {
  129. return codeBlock;
  130. }
  131. let innerContent = "";
  132. let isBlock = false;
  133. if (squareBracket) {
  134. innerContent = squareBracket;
  135. isBlock = true;
  136. } else if (roundBracket) {
  137. innerContent = roundBracket;
  138. isBlock = false;
  139. } else if (doubleDollar) {
  140. innerContent = doubleDollar.slice(2, -2);
  141. isBlock = true;
  142. } else if (singleDollar) {
  143. innerContent = singleDollar.slice(1, -1);
  144. isBlock = false;
  145. } else {
  146. return match;
  147. }
  148. // 修复数字前的反斜杠(如 \200,000 -> 200,000)
  149. innerContent = innerContent.replace(/\\(\d)/g, "$1");
  150. // 修复分号间隔(如 400;kg -> 400\;kg)
  151. innerContent = innerContent.replace(/(\d+)\s*;\s*/g, "$1\\;");
  152. return isBlock ? `$$${innerContent}$$` : `$${innerContent}$`;
  153. }
  154. );
  155. }
  156. /**
  157. * Markdown 内容渲染核心组件
  158. */
  159. function _MarkdownContent(props: MarkdownContentProps) {
  160. const escapedContent = useMemo(() => {
  161. return preprocessLaTeX(props.content);
  162. }, [props.content]);
  163. return (
  164. <ReactMarkdown
  165. remarkPlugins={[RemarkMath, RemarkGfm, RemarkBreaks]}
  166. rehypePlugins={[
  167. [
  168. RehypeKatex,
  169. {
  170. strict: 'ignore',
  171. throwOnError: false,
  172. errorColor: '#ff0000',
  173. trust: true,
  174. }
  175. ],
  176. [
  177. RehypeHighlight,
  178. {
  179. detect: false,
  180. ignoreMissing: true,
  181. }
  182. ]
  183. ]}
  184. components={{
  185. pre: PreCode,
  186. head: () => (
  187. <link
  188. rel="stylesheet"
  189. href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css"
  190. integrity="sha384-n8MVd4RsNIU0tAv4ct0nTaAbDJwPJzDEaqSD1odI+WdtXRGWt2kTvGFasHpSy3SV"
  191. crossOrigin="anonymous"
  192. />
  193. ),
  194. code: ({ className, children }) => {
  195. if (className && className.includes('language-think')) {
  196. return (
  197. <code style={{ whiteSpace: 'pre-wrap', background: '#f3f4f6', color: '#525252' }}>
  198. {children}
  199. </code>
  200. );
  201. }
  202. return children;
  203. },
  204. div: (pProps) => <div {...pProps} dir="auto" />,
  205. a: (aProps) => {
  206. const href = aProps.href || "";
  207. const isInternal = /^\/#/i.test(href);
  208. const target = isInternal ? "_self" : aProps.target ?? "_blank";
  209. return <a {...aProps} target={target} />;
  210. },
  211. img: ({ src, alt }) => (
  212. <span style={{ width: '100%', height: 'auto', cursor: 'pointer', display: 'inline-block' }}>
  213. <Image
  214. width='80%'
  215. src={src}
  216. alt={alt}
  217. preview={{ mask: null }}
  218. />
  219. </span>
  220. ),
  221. }}
  222. >
  223. {escapedContent}
  224. </ReactMarkdown>
  225. );
  226. }
  227. export const MarkdownContent = React.memo(_MarkdownContent);
  228. /**
  229. * Markdown 渲染主组件(带加载状态和样式控制)
  230. */
  231. export function Markdown(props: MarkdownProps) {
  232. const mdRef = useRef<HTMLDivElement>(null);
  233. return (
  234. <div
  235. className="markdown-body"
  236. style={{
  237. fontSize: `${props.fontSize ?? 14}px`,
  238. fontFamily: props.fontFamily || "inherit",
  239. }}
  240. ref={mdRef}
  241. dir="auto"
  242. >
  243. {props.loading ? (
  244. <LoadingIcon />
  245. ) : (
  246. <MarkdownContent content={props.content} />
  247. )}
  248. </div>
  249. );
  250. }