markdown.tsx 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321
  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 rehypeMathjaxChtml from 'rehype-mathjax/chtml';
  7. import RemarkGfm from "remark-gfm";
  8. import RehypeHighlight from "rehype-highlight";
  9. import { useRef, useState, RefObject, useEffect, useMemo } from "react";
  10. import { copyToClipboard, useWindowSize } from "../utils";
  11. import mermaid from "mermaid";
  12. import LoadingIcon from "../icons/three-dots.svg";
  13. import React from "react";
  14. import { useDebouncedCallback } from "use-debounce";
  15. import { showImageModal, FullScreen } from "./ui-lib";
  16. import { ArtifactsShareButton, HTMLPreview } from "./artifacts";
  17. import { Plugin } from "../constant";
  18. import { useChatStore } from "../store";
  19. import "katex/dist/katex.min.css";
  20. export function Mermaid(props: { code: string }) {
  21. const ref = useRef<HTMLDivElement>(null);
  22. const [hasError, setHasError] = useState(false);
  23. useEffect(() => {
  24. if (props.code && ref.current) {
  25. mermaid
  26. .run({
  27. nodes: [ref.current],
  28. suppressErrors: true,
  29. })
  30. .catch((e) => {
  31. setHasError(true);
  32. console.error("[Mermaid] ", e.message);
  33. });
  34. }
  35. // eslint-disable-next-line react-hooks/exhaustive-deps
  36. }, [props.code]);
  37. function viewSvgInNewWindow() {
  38. const svg = ref.current?.querySelector("svg");
  39. if (!svg) return;
  40. const text = new XMLSerializer().serializeToString(svg);
  41. const blob = new Blob([text], { type: "image/svg+xml" });
  42. showImageModal(URL.createObjectURL(blob));
  43. }
  44. if (hasError) {
  45. return null;
  46. }
  47. return (
  48. <div
  49. className="no-dark mermaid"
  50. style={{
  51. cursor: "pointer",
  52. overflow: "auto",
  53. }}
  54. ref={ref}
  55. onClick={() => viewSvgInNewWindow()}
  56. >
  57. {props.code}
  58. </div>
  59. );
  60. }
  61. export function PreCode(props: { children: any }) {
  62. const ref = useRef<HTMLPreElement>(null);
  63. const refText = ref.current?.innerText;
  64. const [mermaidCode, setMermaidCode] = useState("");
  65. const [htmlCode, setHtmlCode] = useState("");
  66. const { height } = useWindowSize();
  67. const chatStore = useChatStore();
  68. const session = chatStore.currentSession();
  69. const plugins = session.mask?.plugin;
  70. const renderArtifacts = useDebouncedCallback(() => {
  71. if (!ref.current) return;
  72. const mermaidDom = ref.current.querySelector("code.language-mermaid");
  73. if (mermaidDom) {
  74. setMermaidCode((mermaidDom as HTMLElement).innerText);
  75. }
  76. const htmlDom = ref.current.querySelector("code.language-html");
  77. if (htmlDom) {
  78. setHtmlCode((htmlDom as HTMLElement).innerText);
  79. } else if (refText?.startsWith("<!DOCTYPE")) {
  80. setHtmlCode(refText);
  81. }
  82. }, 600);
  83. useEffect(() => {
  84. setTimeout(renderArtifacts, 1);
  85. // eslint-disable-next-line react-hooks/exhaustive-deps
  86. }, [refText]);
  87. const enableArtifacts = useMemo(
  88. () => plugins?.includes(Plugin.Artifacts),
  89. [plugins],
  90. );
  91. //Wrap the paragraph for plain-text
  92. useEffect(() => {
  93. if (ref.current) {
  94. const codeElements = ref.current.querySelectorAll(
  95. "code",
  96. ) as NodeListOf<HTMLElement>;
  97. const wrapLanguages = [
  98. "",
  99. "think",
  100. "md",
  101. "markdown",
  102. "text",
  103. "txt",
  104. "plaintext",
  105. "tex",
  106. "latex",
  107. ];
  108. codeElements.forEach((codeElement) => {
  109. let languageClass = codeElement.className.match(/language-(\w+)/);
  110. let name = languageClass ? languageClass[1] : "";
  111. if (wrapLanguages.includes(name)) {
  112. codeElement.style.whiteSpace = "pre-wrap";
  113. }
  114. });
  115. }
  116. }, []);
  117. return (
  118. <>
  119. <pre ref={ref}>
  120. <span
  121. className="copy-code-button"
  122. onClick={() => {
  123. if (ref.current) {
  124. const code = ref.current.innerText;
  125. copyToClipboard(code);
  126. }
  127. }}
  128. ></span>
  129. {props.children}
  130. </pre>
  131. {mermaidCode.length > 0 && (
  132. <Mermaid code={mermaidCode} key={mermaidCode} />
  133. )}
  134. {htmlCode.length > 0 && enableArtifacts && (
  135. <FullScreen className="no-dark html" right={70}>
  136. <ArtifactsShareButton
  137. style={{ position: "absolute", right: 20, top: 10 }}
  138. getCode={() => htmlCode}
  139. />
  140. <HTMLPreview
  141. code={htmlCode}
  142. autoHeight={!document.fullscreenElement}
  143. height={!document.fullscreenElement ? 600 : height}
  144. />
  145. </FullScreen>
  146. )}
  147. </>
  148. );
  149. }
  150. function escapeDollarNumber(text: string) {
  151. // 原有的 escapeDollarNumber 逻辑被移除,因为它会破坏以数字开头的公式
  152. // 如果需要防止 $100 被识别为公式,应建议用户正确转义 \$100,或优化 preprocessLaTeX 的识别逻辑
  153. return text;
  154. }
  155. function preprocessLaTeX(content: string) {
  156. const pattern =
  157. /(```[\s\S]*?```|`.*?`)|\\\[([\s\S]*?[^\\])\\\]|\\\((.*?)\\\)|(\$\$[\s\S]*?\$\$)|(\$(?!\s)[\s\S]*?\S\$)/g;
  158. return content.replace(
  159. pattern,
  160. (match, codeBlock, squareBracket, roundBracket, doubleDollar, singleDollar) => {
  161. if (codeBlock) {
  162. return codeBlock;
  163. }
  164. let innerContent = "";
  165. let isBlock = false;
  166. if (squareBracket) {
  167. innerContent = squareBracket;
  168. isBlock = true;
  169. } else if (roundBracket) {
  170. innerContent = roundBracket;
  171. isBlock = false;
  172. } else if (doubleDollar) {
  173. innerContent = doubleDollar.slice(2, -2);
  174. isBlock = true;
  175. } else if (singleDollar) {
  176. innerContent = singleDollar.slice(1, -1);
  177. isBlock = false;
  178. } else {
  179. return match;
  180. }
  181. // 修复1: \200,000 -> 200,000 (移除数字前的反斜杠)
  182. innerContent = innerContent.replace(/\\(\d)/g, "$1");
  183. // 修复2: 400;kg -> 400\;kg (分号间隔)
  184. innerContent = innerContent.replace(/(\d+)\s*;\s*/g, "$1\\;");
  185. return isBlock ? `$$${innerContent}$$` : `$${innerContent}$`;
  186. },
  187. );
  188. }
  189. function _MarkDownContent(props: { content: string }) {
  190. const escapedContent = useMemo(() => {
  191. return preprocessLaTeX(props.content);
  192. }, [props.content]);
  193. return (
  194. <ReactMarkdown
  195. remarkPlugins={[RemarkMath, RemarkGfm, RemarkBreaks]}
  196. rehypePlugins={[
  197. [RehypeKatex,
  198. {
  199. strict: 'ignore',
  200. throwOnError: false, // 关键:即使有错误也不抛出异常,仅渲染错误内容
  201. errorColor: '#ff0000', // 错误内容标红(StackEdit同款)
  202. trust: true, // 允许更多语法,提升兼容性
  203. }],
  204. [
  205. RehypeHighlight,
  206. {
  207. detect: false,
  208. ignoreMissing: true,
  209. },
  210. ],
  211. ]}
  212. // 控制不同标签的显示样式
  213. components={{
  214. pre: PreCode,
  215. head: () => (
  216. <link
  217. rel="stylesheet"
  218. href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css"
  219. integrity="sha384-n8MVd4RsNIU0tAv4ct0nTaAbDJwPJzDEaqSD1odI+WdtXRGWt2kTvGFasHpSy3SV"
  220. crossOrigin="anonymous"
  221. />
  222. ),
  223. code: ({ className, children }) => {
  224. // console.log('className, children-----3',className, children)
  225. if (className && className.includes('language-think')) {
  226. return (
  227. <code style={{ whiteSpace: 'pre-wrap', background: '#f3f4f6', color: '#525252' }}>
  228. {children}
  229. </code>
  230. );
  231. } else {
  232. return children;
  233. }
  234. },
  235. div: (pProps) => {
  236. // console.log('pProps-----2',pProps)
  237. return <div {...pProps} dir="auto" />
  238. },
  239. a: (aProps) => {
  240. // console.log('aProps-----4',aProps)
  241. const href = aProps.href || "";
  242. const isInternal = /^\/#/i.test(href);
  243. const target = isInternal ? "_self" : aProps.target ?? "_blank";
  244. return <a {...aProps} target={target} />;
  245. },
  246. img: ({ src, alt }) => (
  247. // console.log('src, alt-----5',src, alt),
  248. <span style={{ width: '100%', height: 'auto', cursor: 'pointer', display: 'inline-block' }}>
  249. <Image
  250. width='80%'
  251. src={src}
  252. alt={alt}
  253. preview={{
  254. mask: null
  255. }}
  256. />
  257. </span>
  258. ),
  259. }}
  260. >
  261. {escapedContent}
  262. </ReactMarkdown >
  263. );
  264. }
  265. export const MarkdownContent = React.memo(_MarkDownContent);
  266. export function Markdown(
  267. props: {
  268. content: string;
  269. loading?: boolean;
  270. fontSize?: number;
  271. fontFamily?: string;
  272. parentRef?: RefObject<HTMLDivElement>;
  273. defaultShow?: boolean;
  274. } & React.DOMAttributes<HTMLDivElement>,
  275. ) {
  276. const mdRef = useRef<HTMLDivElement>(null);
  277. return (
  278. <div
  279. className="markdown-body"
  280. style={{
  281. fontSize: `${props.fontSize ?? 14}px`,
  282. fontFamily: props.fontFamily || "inherit",
  283. }}
  284. ref={mdRef}
  285. onContextMenu={props.onContextMenu}
  286. onDoubleClickCapture={props.onDoubleClickCapture}
  287. dir="auto"
  288. >
  289. {props.loading ? (
  290. <LoadingIcon />
  291. ) : (
  292. <MarkdownContent content={props.content} />
  293. )}
  294. </div>
  295. );
  296. }