markdown.tsx 8.2 KB

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