mirror of
				https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web.git
				synced 2025-11-04 08:13:43 +08:00 
			
		
		
		
	
		
			
				
	
	
		
			339 lines
		
	
	
		
			9.3 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			339 lines
		
	
	
		
			9.3 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
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";
 | 
						|
 | 
						|
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="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")) {
 | 
						|
      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={`show-hide-button ${collapsed ? "collapsed" : "expanded"}`}>
 | 
						|
          <button onClick={toggleCollapsed}>{Locale.NewChat.More}</button>
 | 
						|
        </div>
 | 
						|
      );
 | 
						|
    }
 | 
						|
    return null;
 | 
						|
  };
 | 
						|
  return (
 | 
						|
    <>
 | 
						|
      <code
 | 
						|
        className={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)
 | 
						|
  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>
 | 
						|
  );
 | 
						|
}
 |