markdown.tsx 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311
  1. import ReactMarkdown from "react-markdown";
  2. import "katex/dist/katex.min.css";
  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, RefObject, useEffect, useMemo } from "react";
  9. import { copyToClipboard, useWindowSize } from "../utils";
  10. import mermaid from "mermaid";
  11. import LoadingIcon from "../icons/three-dots.svg";
  12. import React from "react";
  13. import { useDebouncedCallback } from "use-debounce";
  14. import { showImageModal, FullScreen } from "./ui-lib";
  15. import { ArtifactsShareButton, HTMLPreview } from "./artifacts";
  16. import { Plugin } from "../constant";
  17. import { useChatStore } from "../store";
  18. export function Mermaid(props: { code: string }) {
  19. const ref = useRef<HTMLDivElement>(null);
  20. const [hasError, setHasError] = useState(false);
  21. useEffect(() => {
  22. if (props.code && ref.current) {
  23. mermaid
  24. .run({
  25. nodes: [ref.current],
  26. suppressErrors: true,
  27. })
  28. .catch((e) => {
  29. setHasError(true);
  30. console.error("[Mermaid] ", e.message);
  31. });
  32. }
  33. // eslint-disable-next-line react-hooks/exhaustive-deps
  34. }, [props.code]);
  35. function viewSvgInNewWindow() {
  36. const svg = ref.current?.querySelector("svg");
  37. if (!svg) return;
  38. const text = new XMLSerializer().serializeToString(svg);
  39. const blob = new Blob([text], { type: "image/svg+xml" });
  40. showImageModal(URL.createObjectURL(blob));
  41. }
  42. if (hasError) {
  43. return null;
  44. }
  45. return (
  46. <div
  47. className="no-dark mermaid"
  48. style={{
  49. cursor: "pointer",
  50. overflow: "auto",
  51. }}
  52. ref={ref}
  53. onClick={() => viewSvgInNewWindow()}
  54. >
  55. {props.code}
  56. </div>
  57. );
  58. }
  59. export function PreCode(props: { children: any }) {
  60. const ref = useRef<HTMLPreElement>(null);
  61. const refText = ref.current?.innerText;
  62. const [mermaidCode, setMermaidCode] = useState("");
  63. const [htmlCode, setHtmlCode] = useState("");
  64. const { height } = useWindowSize();
  65. const chatStore = useChatStore();
  66. const session = chatStore.currentSession();
  67. const plugins = session.mask?.plugin;
  68. const renderArtifacts = useDebouncedCallback(() => {
  69. if (!ref.current) return;
  70. const mermaidDom = ref.current.querySelector("code.language-mermaid");
  71. if (mermaidDom) {
  72. setMermaidCode((mermaidDom as HTMLElement).innerText);
  73. }
  74. const htmlDom = ref.current.querySelector("code.language-html");
  75. if (htmlDom) {
  76. setHtmlCode((htmlDom as HTMLElement).innerText);
  77. } else if (refText?.startsWith("<!DOCTYPE")) {
  78. setHtmlCode(refText);
  79. }
  80. }, 600);
  81. useEffect(() => {
  82. setTimeout(renderArtifacts, 1);
  83. // eslint-disable-next-line react-hooks/exhaustive-deps
  84. }, [refText]);
  85. const enableArtifacts = useMemo(
  86. () => plugins?.includes(Plugin.Artifacts),
  87. [plugins],
  88. );
  89. //Wrap the paragraph for plain-text
  90. useEffect(() => {
  91. if (ref.current) {
  92. const codeElements = ref.current.querySelectorAll(
  93. "code",
  94. ) as NodeListOf<HTMLElement>;
  95. const wrapLanguages = [
  96. "",
  97. "md",
  98. "markdown",
  99. "text",
  100. "txt",
  101. "plaintext",
  102. "tex",
  103. "latex",
  104. ];
  105. codeElements.forEach((codeElement) => {
  106. let languageClass = codeElement.className.match(/language-(\w+)/);
  107. let name = languageClass ? languageClass[1] : "";
  108. if (wrapLanguages.includes(name)) {
  109. codeElement.style.whiteSpace = "pre-wrap";
  110. }
  111. });
  112. }
  113. }, []);
  114. return (
  115. <>
  116. <div style={{ position: "relative" }}>
  117. <pre
  118. ref={ref}
  119. style={{
  120. overflowY: "hidden",
  121. }}
  122. >
  123. <span
  124. className="copy-code-button"
  125. onClick={() => {
  126. if (ref.current) {
  127. const code = ref.current.innerText;
  128. copyToClipboard(code);
  129. }
  130. }}
  131. ></span>
  132. {props.children}
  133. </pre>
  134. </div>
  135. {mermaidCode.length > 0 && (
  136. <Mermaid code={mermaidCode} key={mermaidCode} />
  137. )}
  138. {htmlCode.length > 0 && enableArtifacts && (
  139. <FullScreen className="no-dark html" right={70}>
  140. <ArtifactsShareButton
  141. style={{ position: "absolute", right: 20, top: 10 }}
  142. getCode={() => htmlCode}
  143. />
  144. <HTMLPreview
  145. code={htmlCode}
  146. autoHeight={!document.fullscreenElement}
  147. height={!document.fullscreenElement ? 600 : height}
  148. />
  149. </FullScreen>
  150. )}
  151. </>
  152. );
  153. }
  154. function CustomCode(props: { children: any }) {
  155. const ref = useRef<HTMLPreElement>(null);
  156. const [collapsed, setCollapsed] = useState(true);
  157. const [showToggle, setShowToggle] = useState(false);
  158. useEffect(() => {
  159. if (ref.current) {
  160. const codeHeight = ref.current.scrollHeight;
  161. setShowToggle(codeHeight > 400);
  162. ref.current.scrollTop = ref.current.scrollHeight;
  163. }
  164. }, [props.children]);
  165. const toggleCollapsed = () => {
  166. setCollapsed((collapsed) => !collapsed);
  167. };
  168. return (
  169. <>
  170. <code
  171. ref={ref}
  172. style={{
  173. maxHeight: collapsed ? "400px" : "none",
  174. overflowY: "hidden",
  175. }}
  176. >
  177. {props.children}
  178. {showToggle && collapsed && (
  179. <div
  180. className={`show-hide-button ${
  181. collapsed ? "collapsed" : "expanded"
  182. }`}
  183. >
  184. <button onClick={toggleCollapsed}>查看全部</button>
  185. </div>
  186. )}
  187. </code>
  188. </>
  189. );
  190. }
  191. function escapeDollarNumber(text: string) {
  192. let escapedText = "";
  193. for (let i = 0; i < text.length; i += 1) {
  194. let char = text[i];
  195. const nextChar = text[i + 1] || " ";
  196. if (char === "$" && nextChar >= "0" && nextChar <= "9") {
  197. char = "\\$";
  198. }
  199. escapedText += char;
  200. }
  201. return escapedText;
  202. }
  203. function escapeBrackets(text: string) {
  204. const pattern =
  205. /(```[\s\S]*?```|`.*?`)|\\\[([\s\S]*?[^\\])\\\]|\\\((.*?)\\\)/g;
  206. return text.replace(
  207. pattern,
  208. (match, codeBlock, squareBracket, roundBracket) => {
  209. if (codeBlock) {
  210. return codeBlock;
  211. } else if (squareBracket) {
  212. return `$$${squareBracket}$$`;
  213. } else if (roundBracket) {
  214. return `$${roundBracket}$`;
  215. }
  216. return match;
  217. },
  218. );
  219. }
  220. function _MarkDownContent(props: { content: string }) {
  221. const escapedContent = useMemo(() => {
  222. return escapeBrackets(escapeDollarNumber(props.content));
  223. }, [props.content]);
  224. console.log(escapedContent, 11233);
  225. return (
  226. <ReactMarkdown
  227. remarkPlugins={[RemarkMath, RemarkGfm, RemarkBreaks]}
  228. rehypePlugins={[
  229. RehypeKatex,
  230. [
  231. RehypeHighlight,
  232. {
  233. detect: false,
  234. ignoreMissing: true,
  235. },
  236. ],
  237. ]}
  238. components={{
  239. pre: PreCode,
  240. code: CustomCode,
  241. p: (pProps) => <p {...pProps} dir="auto" />,
  242. a: (aProps) => {
  243. const href = aProps.href || "";
  244. const isInternal = /^\/#/i.test(href);
  245. const target = isInternal ? "_self" : aProps.target ?? "_blank";
  246. return <a {...aProps} target={target} />;
  247. },
  248. }}
  249. >
  250. {escapedContent}
  251. </ReactMarkdown>
  252. );
  253. }
  254. export const MarkdownContent = React.memo(_MarkDownContent);
  255. export function Markdown(
  256. props: {
  257. content: string;
  258. loading?: boolean;
  259. fontSize?: number;
  260. fontFamily?: string;
  261. parentRef?: RefObject<HTMLDivElement>;
  262. defaultShow?: boolean;
  263. } & React.DOMAttributes<HTMLDivElement>,
  264. ) {
  265. const mdRef = useRef<HTMLDivElement>(null);
  266. return (
  267. <div
  268. className="markdown-body"
  269. style={{
  270. fontSize: `${props.fontSize ?? 14}px`,
  271. fontFamily: props.fontFamily || "inherit",
  272. }}
  273. ref={mdRef}
  274. onContextMenu={props.onContextMenu}
  275. onDoubleClickCapture={props.onDoubleClickCapture}
  276. dir="auto"
  277. >
  278. {props.loading ? (
  279. <LoadingIcon />
  280. ) : (
  281. <MarkdownContent content={props.content} />
  282. )}
  283. </div>
  284. );
  285. }