markdown.tsx 9.5 KB

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