markdown.tsx 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344
  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. import clsx from "clsx";
  26. export function Mermaid(props: { code: string }) {
  27. const ref = useRef<HTMLDivElement>(null);
  28. const [hasError, setHasError] = useState(false);
  29. useEffect(() => {
  30. if (props.code && ref.current) {
  31. mermaid
  32. .run({
  33. nodes: [ref.current],
  34. suppressErrors: true,
  35. })
  36. .catch((e) => {
  37. setHasError(true);
  38. console.error("[Mermaid] ", e.message);
  39. });
  40. }
  41. // eslint-disable-next-line react-hooks/exhaustive-deps
  42. }, [props.code]);
  43. function viewSvgInNewWindow() {
  44. const svg = ref.current?.querySelector("svg");
  45. if (!svg) return;
  46. const text = new XMLSerializer().serializeToString(svg);
  47. const blob = new Blob([text], { type: "image/svg+xml" });
  48. showImageModal(URL.createObjectURL(blob));
  49. }
  50. if (hasError) {
  51. return null;
  52. }
  53. return (
  54. <div
  55. className={clsx("no-dark", "mermaid")}
  56. style={{
  57. cursor: "pointer",
  58. overflow: "auto",
  59. }}
  60. ref={ref}
  61. onClick={() => viewSvgInNewWindow()}
  62. >
  63. {props.code}
  64. </div>
  65. );
  66. }
  67. export function PreCode(props: { children: any }) {
  68. const ref = useRef<HTMLPreElement>(null);
  69. const previewRef = useRef<HTMLPreviewHander>(null);
  70. const [mermaidCode, setMermaidCode] = useState("");
  71. const [htmlCode, setHtmlCode] = useState("");
  72. const { height } = useWindowSize();
  73. const chatStore = useChatStore();
  74. const session = chatStore.currentSession();
  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 config = useAppConfig();
  90. const enableArtifacts =
  91. session.mask?.enableArtifacts !== false && config.enableArtifacts;
  92. //Wrap the paragraph for plain-text
  93. useEffect(() => {
  94. if (ref.current) {
  95. const codeElements = ref.current.querySelectorAll(
  96. "code",
  97. ) as NodeListOf<HTMLElement>;
  98. const wrapLanguages = [
  99. "",
  100. "md",
  101. "markdown",
  102. "text",
  103. "txt",
  104. "plaintext",
  105. "tex",
  106. "latex",
  107. ];
  108. codeElements.forEach((codeElement) => {
  109. let languageClass = codeElement.className.match(/language-(\w+)/);
  110. let name = languageClass ? languageClass[1] : "";
  111. if (wrapLanguages.includes(name)) {
  112. codeElement.style.whiteSpace = "pre-wrap";
  113. }
  114. });
  115. setTimeout(renderArtifacts, 1);
  116. }
  117. }, []);
  118. return (
  119. <>
  120. <pre ref={ref}>
  121. <span
  122. className="copy-code-button"
  123. onClick={() => {
  124. if (ref.current) {
  125. copyToClipboard(
  126. ref.current.querySelector("code")?.innerText ?? "",
  127. );
  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; className?: string }) {
  161. const chatStore = useChatStore();
  162. const session = chatStore.currentSession();
  163. const config = useAppConfig();
  164. const enableCodeFold =
  165. session.mask?.enableCodeFold !== false && config.enableCodeFold;
  166. const ref = useRef<HTMLPreElement>(null);
  167. const [collapsed, setCollapsed] = useState(true);
  168. const [showToggle, setShowToggle] = useState(false);
  169. useEffect(() => {
  170. if (ref.current) {
  171. const codeHeight = ref.current.scrollHeight;
  172. setShowToggle(codeHeight > 400);
  173. ref.current.scrollTop = ref.current.scrollHeight;
  174. }
  175. }, [props.children]);
  176. const toggleCollapsed = () => {
  177. setCollapsed((collapsed) => !collapsed);
  178. };
  179. const renderShowMoreButton = () => {
  180. if (showToggle && enableCodeFold && collapsed) {
  181. return (
  182. <div
  183. className={clsx("show-hide-button", {
  184. collapsed,
  185. expanded: !collapsed,
  186. })}
  187. >
  188. <button onClick={toggleCollapsed}>{Locale.NewChat.More}</button>
  189. </div>
  190. );
  191. }
  192. return null;
  193. };
  194. return (
  195. <>
  196. <code
  197. className={clsx(props?.className)}
  198. ref={ref}
  199. style={{
  200. maxHeight: enableCodeFold && collapsed ? "400px" : "none",
  201. overflowY: "hidden",
  202. }}
  203. >
  204. {props.children}
  205. </code>
  206. {renderShowMoreButton()}
  207. </>
  208. );
  209. }
  210. function escapeBrackets(text: string) {
  211. const pattern =
  212. /(```[\s\S]*?```|`.*?`)|\\\[([\s\S]*?[^\\])\\\]|\\\((.*?)\\\)/g;
  213. return text.replace(
  214. pattern,
  215. (match, codeBlock, squareBracket, roundBracket) => {
  216. if (codeBlock) {
  217. return codeBlock;
  218. } else if (squareBracket) {
  219. return `$$${squareBracket}$$`;
  220. } else if (roundBracket) {
  221. return `$${roundBracket}$`;
  222. }
  223. return match;
  224. },
  225. );
  226. }
  227. function tryWrapHtmlCode(text: string) {
  228. // try add wrap html code (fixed: html codeblock include 2 newline)
  229. return text
  230. .replace(
  231. /([`]*?)(\w*?)([\n\r]*?)(<!DOCTYPE html>)/g,
  232. (match, quoteStart, lang, newLine, doctype) => {
  233. return !quoteStart ? "\n```html\n" + doctype : match;
  234. },
  235. )
  236. .replace(
  237. /(<\/body>)([\r\n\s]*?)(<\/html>)([\n\r]*)([`]*)([\n\r]*?)/g,
  238. (match, bodyEnd, space, htmlEnd, newLine, quoteEnd) => {
  239. return !quoteEnd ? bodyEnd + space + htmlEnd + "\n```\n" : match;
  240. },
  241. );
  242. }
  243. function _MarkDownContent(props: { content: string }) {
  244. const escapedContent = useMemo(() => {
  245. return tryWrapHtmlCode(escapeBrackets(props.content));
  246. }, [props.content]);
  247. return (
  248. <ReactMarkdown
  249. remarkPlugins={[RemarkMath, RemarkGfm, RemarkBreaks]}
  250. rehypePlugins={[
  251. RehypeKatex,
  252. [
  253. RehypeHighlight,
  254. {
  255. detect: false,
  256. ignoreMissing: true,
  257. },
  258. ],
  259. ]}
  260. components={{
  261. pre: PreCode,
  262. code: CustomCode,
  263. p: (pProps) => <p {...pProps} dir="auto" />,
  264. a: (aProps) => {
  265. const href = aProps.href || "";
  266. if (/\.(aac|mp3|opus|wav)$/.test(href)) {
  267. return (
  268. <figure>
  269. <audio controls src={href}></audio>
  270. </figure>
  271. );
  272. }
  273. if (/\.(3gp|3g2|webm|ogv|mpeg|mp4|avi)$/.test(href)) {
  274. return (
  275. <video controls width="99.9%">
  276. <source src={href} />
  277. </video>
  278. );
  279. }
  280. const isInternal = /^\/#/i.test(href);
  281. const target = isInternal ? "_self" : aProps.target ?? "_blank";
  282. return <a {...aProps} target={target} />;
  283. },
  284. }}
  285. >
  286. {escapedContent}
  287. </ReactMarkdown>
  288. );
  289. }
  290. export const MarkdownContent = React.memo(_MarkDownContent);
  291. export function Markdown(
  292. props: {
  293. content: string;
  294. loading?: boolean;
  295. fontSize?: number;
  296. fontFamily?: string;
  297. parentRef?: RefObject<HTMLDivElement>;
  298. defaultShow?: boolean;
  299. } & React.DOMAttributes<HTMLDivElement>,
  300. ) {
  301. const mdRef = useRef<HTMLDivElement>(null);
  302. return (
  303. <div
  304. className="markdown-body"
  305. style={{
  306. fontSize: `${props.fontSize ?? 14}px`,
  307. fontFamily: props.fontFamily || "inherit",
  308. }}
  309. ref={mdRef}
  310. onContextMenu={props.onContextMenu}
  311. onDoubleClickCapture={props.onDoubleClickCapture}
  312. dir="auto"
  313. >
  314. {props.loading ? (
  315. <LoadingIcon />
  316. ) : (
  317. <MarkdownContent content={props.content} />
  318. )}
  319. </div>
  320. );
  321. }