markdown.tsx 7.9 KB

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