Spaces:
Paused
Paused
| import ReactMarkdown from "react-markdown"; | |
| import "katex/dist/katex.min.css"; | |
| import RemarkMath from "remark-math"; | |
| import RemarkBreaks from "remark-breaks"; | |
| import RehypeKatex from "rehype-katex"; | |
| import RemarkGfm from "remark-gfm"; | |
| import RehypeHighlight from "rehype-highlight"; | |
| import { useRef, useState, RefObject, useEffect, useMemo } from "react"; | |
| import { copyToClipboard, useWindowSize } from "../utils"; | |
| import mermaid from "mermaid"; | |
| import Locale from "../locales"; | |
| import LoadingIcon from "../icons/three-dots.svg"; | |
| import ReloadButtonIcon from "../icons/reload.svg"; | |
| import React from "react"; | |
| import { useDebouncedCallback } from "use-debounce"; | |
| import { showImageModal, FullScreen } from "./ui-lib"; | |
| import { | |
| ArtifactsShareButton, | |
| HTMLPreview, | |
| HTMLPreviewHander, | |
| } from "./artifacts"; | |
| import { useChatStore } from "../store"; | |
| import { IconButton } from "./button"; | |
| import { useAppConfig } from "../store/config"; | |
| import clsx from "clsx"; | |
| export function Mermaid(props: { code: string }) { | |
| const ref = useRef<HTMLDivElement>(null); | |
| const [hasError, setHasError] = useState(false); | |
| useEffect(() => { | |
| if (props.code && ref.current) { | |
| mermaid | |
| .run({ | |
| nodes: [ref.current], | |
| suppressErrors: true, | |
| }) | |
| .catch((e) => { | |
| setHasError(true); | |
| console.error("[Mermaid] ", e.message); | |
| }); | |
| } | |
| // eslint-disable-next-line react-hooks/exhaustive-deps | |
| }, [props.code]); | |
| function viewSvgInNewWindow() { | |
| const svg = ref.current?.querySelector("svg"); | |
| if (!svg) return; | |
| const text = new XMLSerializer().serializeToString(svg); | |
| const blob = new Blob([text], { type: "image/svg+xml" }); | |
| showImageModal(URL.createObjectURL(blob)); | |
| } | |
| if (hasError) { | |
| return null; | |
| } | |
| return ( | |
| <div | |
| className={clsx("no-dark", "mermaid")} | |
| style={{ | |
| cursor: "pointer", | |
| overflow: "auto", | |
| }} | |
| ref={ref} | |
| onClick={() => viewSvgInNewWindow()} | |
| > | |
| {props.code} | |
| </div> | |
| ); | |
| } | |
| export function PreCode(props: { children: any }) { | |
| const ref = useRef<HTMLPreElement>(null); | |
| const previewRef = useRef<HTMLPreviewHander>(null); | |
| const [mermaidCode, setMermaidCode] = useState(""); | |
| const [htmlCode, setHtmlCode] = useState(""); | |
| const { height } = useWindowSize(); | |
| const chatStore = useChatStore(); | |
| const session = chatStore.currentSession(); | |
| const renderArtifacts = useDebouncedCallback(() => { | |
| if (!ref.current) return; | |
| const mermaidDom = ref.current.querySelector("code.language-mermaid"); | |
| if (mermaidDom) { | |
| setMermaidCode((mermaidDom as HTMLElement).innerText); | |
| } | |
| const htmlDom = ref.current.querySelector("code.language-html"); | |
| const refText = ref.current.querySelector("code")?.innerText; | |
| if (htmlDom) { | |
| setHtmlCode((htmlDom as HTMLElement).innerText); | |
| } else if ( | |
| refText?.startsWith("<!DOCTYPE") || | |
| refText?.startsWith("<svg") || | |
| refText?.startsWith("<?xml") | |
| ) { | |
| setHtmlCode(refText); | |
| } | |
| }, 600); | |
| const config = useAppConfig(); | |
| const enableArtifacts = | |
| session.mask?.enableArtifacts !== false && config.enableArtifacts; | |
| //Wrap the paragraph for plain-text | |
| useEffect(() => { | |
| if (ref.current) { | |
| const codeElements = ref.current.querySelectorAll( | |
| "code", | |
| ) as NodeListOf<HTMLElement>; | |
| const wrapLanguages = [ | |
| "", | |
| "md", | |
| "markdown", | |
| "text", | |
| "txt", | |
| "plaintext", | |
| "tex", | |
| "latex", | |
| ]; | |
| codeElements.forEach((codeElement) => { | |
| let languageClass = codeElement.className.match(/language-(\w+)/); | |
| let name = languageClass ? languageClass[1] : ""; | |
| if (wrapLanguages.includes(name)) { | |
| codeElement.style.whiteSpace = "pre-wrap"; | |
| } | |
| }); | |
| setTimeout(renderArtifacts, 1); | |
| } | |
| }, []); | |
| return ( | |
| <> | |
| <pre ref={ref}> | |
| <span | |
| className="copy-code-button" | |
| onClick={() => { | |
| if (ref.current) { | |
| copyToClipboard( | |
| ref.current.querySelector("code")?.innerText ?? "", | |
| ); | |
| } | |
| }} | |
| ></span> | |
| {props.children} | |
| </pre> | |
| {mermaidCode.length > 0 && ( | |
| <Mermaid code={mermaidCode} key={mermaidCode} /> | |
| )} | |
| {htmlCode.length > 0 && enableArtifacts && ( | |
| <FullScreen className="no-dark html" right={70}> | |
| <ArtifactsShareButton | |
| style={{ position: "absolute", right: 20, top: 10 }} | |
| getCode={() => htmlCode} | |
| /> | |
| <IconButton | |
| style={{ position: "absolute", right: 120, top: 10 }} | |
| bordered | |
| icon={<ReloadButtonIcon />} | |
| shadow | |
| onClick={() => previewRef.current?.reload()} | |
| /> | |
| <HTMLPreview | |
| ref={previewRef} | |
| code={htmlCode} | |
| autoHeight={!document.fullscreenElement} | |
| height={!document.fullscreenElement ? 600 : height} | |
| /> | |
| </FullScreen> | |
| )} | |
| </> | |
| ); | |
| } | |
| function CustomCode(props: { children: any; className?: string }) { | |
| const chatStore = useChatStore(); | |
| const session = chatStore.currentSession(); | |
| const config = useAppConfig(); | |
| const enableCodeFold = | |
| session.mask?.enableCodeFold !== false && config.enableCodeFold; | |
| const ref = useRef<HTMLPreElement>(null); | |
| const [collapsed, setCollapsed] = useState(true); | |
| const [showToggle, setShowToggle] = useState(false); | |
| useEffect(() => { | |
| if (ref.current) { | |
| const codeHeight = ref.current.scrollHeight; | |
| setShowToggle(codeHeight > 400); | |
| ref.current.scrollTop = ref.current.scrollHeight; | |
| } | |
| }, [props.children]); | |
| const toggleCollapsed = () => { | |
| setCollapsed((collapsed) => !collapsed); | |
| }; | |
| const renderShowMoreButton = () => { | |
| if (showToggle && enableCodeFold && collapsed) { | |
| return ( | |
| <div | |
| className={clsx("show-hide-button", { | |
| collapsed, | |
| expanded: !collapsed, | |
| })} | |
| > | |
| <button onClick={toggleCollapsed}>{Locale.NewChat.More}</button> | |
| </div> | |
| ); | |
| } | |
| return null; | |
| }; | |
| return ( | |
| <> | |
| <code | |
| className={clsx(props?.className)} | |
| ref={ref} | |
| style={{ | |
| maxHeight: enableCodeFold && collapsed ? "400px" : "none", | |
| overflowY: "hidden", | |
| }} | |
| > | |
| {props.children} | |
| </code> | |
| {renderShowMoreButton()} | |
| </> | |
| ); | |
| } | |
| function escapeBrackets(text: string) { | |
| const pattern = | |
| /(```[\s\S]*?```|`.*?`)|\\\[([\s\S]*?[^\\])\\\]|\\\((.*?)\\\)/g; | |
| return text.replace( | |
| pattern, | |
| (match, codeBlock, squareBracket, roundBracket) => { | |
| if (codeBlock) { | |
| return codeBlock; | |
| } else if (squareBracket) { | |
| return `$$${squareBracket}$$`; | |
| } else if (roundBracket) { | |
| return `$${roundBracket}$`; | |
| } | |
| return match; | |
| }, | |
| ); | |
| } | |
| function tryWrapHtmlCode(text: string) { | |
| // try add wrap html code (fixed: html codeblock include 2 newline) | |
| // ignore embed codeblock | |
| if (text.includes("```")) { | |
| return text; | |
| } | |
| return text | |
| .replace( | |
| /([`]*?)(\w*?)([\n\r]*?)(<!DOCTYPE html>)/g, | |
| (match, quoteStart, lang, newLine, doctype) => { | |
| return !quoteStart ? "\n```html\n" + doctype : match; | |
| }, | |
| ) | |
| .replace( | |
| /(<\/body>)([\r\n\s]*?)(<\/html>)([\n\r]*)([`]*)([\n\r]*?)/g, | |
| (match, bodyEnd, space, htmlEnd, newLine, quoteEnd) => { | |
| return !quoteEnd ? bodyEnd + space + htmlEnd + "\n```\n" : match; | |
| }, | |
| ); | |
| } | |
| function _MarkDownContent(props: { content: string }) { | |
| const escapedContent = useMemo(() => { | |
| return tryWrapHtmlCode(escapeBrackets(props.content)); | |
| }, [props.content]); | |
| return ( | |
| <ReactMarkdown | |
| remarkPlugins={[RemarkMath, RemarkGfm, RemarkBreaks]} | |
| rehypePlugins={[ | |
| RehypeKatex, | |
| [ | |
| RehypeHighlight, | |
| { | |
| detect: false, | |
| ignoreMissing: true, | |
| }, | |
| ], | |
| ]} | |
| components={{ | |
| pre: PreCode, | |
| code: CustomCode, | |
| p: (pProps) => <p {...pProps} dir="auto" />, | |
| a: (aProps) => { | |
| const href = aProps.href || ""; | |
| if (/\.(aac|mp3|opus|wav)$/.test(href)) { | |
| return ( | |
| <figure> | |
| <audio controls src={href}></audio> | |
| </figure> | |
| ); | |
| } | |
| if (/\.(3gp|3g2|webm|ogv|mpeg|mp4|avi)$/.test(href)) { | |
| return ( | |
| <video controls width="99.9%"> | |
| <source src={href} /> | |
| </video> | |
| ); | |
| } | |
| const isInternal = /^\/#/i.test(href); | |
| const target = isInternal ? "_self" : aProps.target ?? "_blank"; | |
| return <a {...aProps} target={target} />; | |
| }, | |
| }} | |
| > | |
| {escapedContent} | |
| </ReactMarkdown> | |
| ); | |
| } | |
| export const MarkdownContent = React.memo(_MarkDownContent); | |
| export function Markdown( | |
| props: { | |
| content: string; | |
| loading?: boolean; | |
| fontSize?: number; | |
| fontFamily?: string; | |
| parentRef?: RefObject<HTMLDivElement>; | |
| defaultShow?: boolean; | |
| } & React.DOMAttributes<HTMLDivElement>, | |
| ) { | |
| const mdRef = useRef<HTMLDivElement>(null); | |
| return ( | |
| <div | |
| className="markdown-body" | |
| style={{ | |
| fontSize: `${props.fontSize ?? 14}px`, | |
| fontFamily: props.fontFamily || "inherit", | |
| }} | |
| ref={mdRef} | |
| onContextMenu={props.onContextMenu} | |
| onDoubleClickCapture={props.onDoubleClickCapture} | |
| dir="auto" | |
| > | |
| {props.loading ? ( | |
| <LoadingIcon /> | |
| ) : ( | |
| <MarkdownContent content={props.content} /> | |
| )} | |
| </div> | |
| ); | |
| } | |