markdown.tsx 8.7 KB

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