| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352 |
- 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,
- HTMLPreviewHandler,
- } 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<HTMLPreviewHandler>(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>
- );
- }
|