markdown.tsx 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333
  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. import { useAppConfig } from "../store/config";
  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 renderArtifacts = useDebouncedCallback(() => {
  75. if (!ref.current) return;
  76. const mermaidDom = ref.current.querySelector("code.language-mermaid");
  77. if (mermaidDom) {
  78. setMermaidCode((mermaidDom as HTMLElement).innerText);
  79. }
  80. const htmlDom = ref.current.querySelector("code.language-html");
  81. const refText = ref.current.querySelector("code")?.innerText;
  82. if (htmlDom) {
  83. setHtmlCode((htmlDom as HTMLElement).innerText);
  84. } else if (refText?.startsWith("<!DOCTYPE")) {
  85. setHtmlCode(refText);
  86. }
  87. }, 600);
  88. const config = useAppConfig();
  89. const enableArtifacts =
  90. session.mask?.enableArtifacts !== false && config.enableArtifacts;
  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. "md",
  100. "markdown",
  101. "text",
  102. "txt",
  103. "plaintext",
  104. "tex",
  105. "latex",
  106. ];
  107. codeElements.forEach((codeElement) => {
  108. let languageClass = codeElement.className.match(/language-(\w+)/);
  109. let name = languageClass ? languageClass[1] : "";
  110. if (wrapLanguages.includes(name)) {
  111. codeElement.style.whiteSpace = "pre-wrap";
  112. }
  113. });
  114. setTimeout(renderArtifacts, 1);
  115. }
  116. }, []);
  117. return (
  118. <>
  119. <pre ref={ref}>
  120. <span
  121. className="copy-code-button"
  122. onClick={() => {
  123. if (ref.current) {
  124. copyToClipboard(
  125. ref.current.querySelector("code")?.innerText ?? "",
  126. );
  127. }
  128. }}
  129. ></span>
  130. {props.children}
  131. </pre>
  132. {mermaidCode.length > 0 && (
  133. <Mermaid code={mermaidCode} key={mermaidCode} />
  134. )}
  135. {htmlCode.length > 0 && enableArtifacts && (
  136. <FullScreen className="no-dark html" right={70}>
  137. <ArtifactsShareButton
  138. style={{ position: "absolute", right: 20, top: 10 }}
  139. getCode={() => htmlCode}
  140. />
  141. <IconButton
  142. style={{ position: "absolute", right: 120, top: 10 }}
  143. bordered
  144. icon={<ReloadButtonIcon />}
  145. shadow
  146. onClick={() => previewRef.current?.reload()}
  147. />
  148. <HTMLPreview
  149. ref={previewRef}
  150. code={htmlCode}
  151. autoHeight={!document.fullscreenElement}
  152. height={!document.fullscreenElement ? 600 : height}
  153. />
  154. </FullScreen>
  155. )}
  156. </>
  157. );
  158. }
  159. function CustomCode(props: { children: any; className?: string }) {
  160. const chatStore = useChatStore();
  161. const session = chatStore.currentSession();
  162. const config = useAppConfig();
  163. const enableCodeFold =
  164. session.mask?.enableCodeFold != false && config.enableCodeFold;
  165. const ref = useRef<HTMLPreElement>(null);
  166. const [collapsed, setCollapsed] = useState(true);
  167. const [showToggle, setShowToggle] = useState(false);
  168. useEffect(() => {
  169. if (ref.current) {
  170. const codeHeight = ref.current.scrollHeight;
  171. setShowToggle(codeHeight > 400);
  172. ref.current.scrollTop = ref.current.scrollHeight;
  173. }
  174. }, [props.children]);
  175. const toggleCollapsed = () => {
  176. setCollapsed((collapsed) => !collapsed);
  177. };
  178. return (
  179. <>
  180. <code
  181. className={props?.className}
  182. ref={ref}
  183. style={{
  184. maxHeight: enableCodeFold && collapsed ? "400px" : "none",
  185. overflowY: "hidden",
  186. }}
  187. >
  188. {props.children}
  189. </code>
  190. {showToggle && enableCodeFold && collapsed && (
  191. <div
  192. className={`show-hide-button ${collapsed ? "collapsed" : "expanded"}`}
  193. >
  194. <button onClick={toggleCollapsed}>{Locale.NewChat.More}</button>
  195. </div>
  196. )}
  197. </>
  198. );
  199. }
  200. function escapeBrackets(text: string) {
  201. const pattern =
  202. /(```[\s\S]*?```|`.*?`)|\\\[([\s\S]*?[^\\])\\\]|\\\((.*?)\\\)/g;
  203. return text.replace(
  204. pattern,
  205. (match, codeBlock, squareBracket, roundBracket) => {
  206. if (codeBlock) {
  207. return codeBlock;
  208. } else if (squareBracket) {
  209. return `$$${squareBracket}$$`;
  210. } else if (roundBracket) {
  211. return `$${roundBracket}$`;
  212. }
  213. return match;
  214. },
  215. );
  216. }
  217. function tryWrapHtmlCode(text: string) {
  218. // try add wrap html code (fixed: html codeblock include 2 newline)
  219. return text
  220. .replace(
  221. /([`]*?)(\w*?)([\n\r]*?)(<!DOCTYPE html>)/g,
  222. (match, quoteStart, lang, newLine, doctype) => {
  223. return !quoteStart ? "\n```html\n" + doctype : match;
  224. },
  225. )
  226. .replace(
  227. /(<\/body>)([\r\n\s]*?)(<\/html>)([\n\r]*)([`]*)([\n\r]*?)/g,
  228. (match, bodyEnd, space, htmlEnd, newLine, quoteEnd) => {
  229. return !quoteEnd ? bodyEnd + space + htmlEnd + "\n```\n" : match;
  230. },
  231. );
  232. }
  233. function _MarkDownContent(props: { content: string }) {
  234. const escapedContent = useMemo(() => {
  235. return tryWrapHtmlCode(escapeBrackets(props.content));
  236. }, [props.content]);
  237. return (
  238. <ReactMarkdown
  239. remarkPlugins={[RemarkMath, RemarkGfm, RemarkBreaks]}
  240. rehypePlugins={[
  241. RehypeKatex,
  242. [
  243. RehypeHighlight,
  244. {
  245. detect: false,
  246. ignoreMissing: true,
  247. },
  248. ],
  249. ]}
  250. components={{
  251. pre: PreCode,
  252. code: CustomCode,
  253. p: (pProps) => <p {...pProps} dir="auto" />,
  254. a: (aProps) => {
  255. const href = aProps.href || "";
  256. if (/\.(aac|mp3|opus|wav)$/.test(href)) {
  257. return (
  258. <figure>
  259. <audio controls src={href}></audio>
  260. </figure>
  261. );
  262. }
  263. if (/\.(3gp|3g2|webm|ogv|mpeg|mp4|avi)$/.test(href)) {
  264. return (
  265. <video controls width="99.9%">
  266. <source src={href} />
  267. </video>
  268. );
  269. }
  270. const isInternal = /^\/#/i.test(href);
  271. const target = isInternal ? "_self" : aProps.target ?? "_blank";
  272. return <a {...aProps} target={target} />;
  273. },
  274. }}
  275. >
  276. {escapedContent}
  277. </ReactMarkdown>
  278. );
  279. }
  280. export const MarkdownContent = React.memo(_MarkDownContent);
  281. export function Markdown(
  282. props: {
  283. content: string;
  284. loading?: boolean;
  285. fontSize?: number;
  286. fontFamily?: string;
  287. parentRef?: RefObject<HTMLDivElement>;
  288. defaultShow?: boolean;
  289. } & React.DOMAttributes<HTMLDivElement>,
  290. ) {
  291. const mdRef = useRef<HTMLDivElement>(null);
  292. return (
  293. <div
  294. className="markdown-body"
  295. style={{
  296. fontSize: `${props.fontSize ?? 14}px`,
  297. fontFamily: props.fontFamily || "inherit",
  298. }}
  299. ref={mdRef}
  300. onContextMenu={props.onContextMenu}
  301. onDoubleClickCapture={props.onDoubleClickCapture}
  302. dir="auto"
  303. >
  304. {props.loading ? (
  305. <LoadingIcon />
  306. ) : (
  307. <MarkdownContent content={props.content} />
  308. )}
  309. </div>
  310. );
  311. }