| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272 |
- import ReactMarkdown from "react-markdown";
- import { Image } from "antd";
- 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, useEffect, useMemo } from "react";
- import { copyToClipboard } from "@/utils/chat";
- import mermaid from "mermaid";
- import LoadingIcon from "@/assets/public/three-dots.svg";
- import React from "react";
- import { useDebouncedCallback } from "use-debounce";
- import "katex/dist/katex.min.css";
- // Types
- interface MermaidProps {
- code: string;
- }
- interface PreCodeProps {
- children: React.ReactNode;
- }
- interface MarkdownContentProps {
- content: string;
- }
- interface MarkdownProps {
- content: string;
- loading?: boolean;
- fontSize?: number;
- fontFamily?: string;
- defaultShow?: boolean;
- }
- /**
- * Mermaid 图表渲染组件
- */
- export function Mermaid(props: MermaidProps) {
- 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);
- });
- }
- }, [props.code]);
- if (hasError) {
- return null;
- }
- return (
- <div
- className="no-dark mermaid"
- style={{
- cursor: "pointer",
- overflow: "auto",
- }}
- ref={ref}
- >
- {props.code}
- </div>
- );
- }
- /**
- * 代码块渲染组件(带语法高亮和复制功能)
- */
- export function PreCode(props: PreCodeProps) {
- const ref = useRef<HTMLPreElement>(null);
- const refText = ref.current?.innerText;
- const [mermaidCode, setMermaidCode] = useState("");
- useEffect(() => {
- if (ref.current) {
- const codeElements = ref.current.querySelectorAll(
- "code"
- ) as NodeListOf<HTMLElement>;
- const wrapLanguages = [
- "",
- "think",
- "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";
- }
- });
- }
- }, [refText]);
- return (
- <>
- <pre ref={ref}>
- <span
- className="copy-code-button"
- onClick={() => {
- if (ref.current) {
- const code = ref.current.innerText;
- copyToClipboard(code);
- }
- }}
- ></span>
- {props.children}
- </pre>
- {mermaidCode.length > 0 && (
- <Mermaid code={mermaidCode} key={mermaidCode} />
- )}
- </>
- );
- }
- /**
- * LaTeX 公式预处理
- */
- function preprocessLaTeX(content: string) {
- const pattern =
- /(```[\s\S]*?```|`.*?`)|\\\[([\s\S]*?[^\\])\\\]|\\\((.*?)\\\)|(\$\$[\s\S]*?\$\$)|(\$(?!\s)[\s\S]*?\S\$)/g;
-
- return content.replace(
- pattern,
- (match, codeBlock, squareBracket, roundBracket, doubleDollar, singleDollar) => {
- if (codeBlock) {
- return codeBlock;
- }
- let innerContent = "";
- let isBlock = false;
- if (squareBracket) {
- innerContent = squareBracket;
- isBlock = true;
- } else if (roundBracket) {
- innerContent = roundBracket;
- isBlock = false;
- } else if (doubleDollar) {
- innerContent = doubleDollar.slice(2, -2);
- isBlock = true;
- } else if (singleDollar) {
- innerContent = singleDollar.slice(1, -1);
- isBlock = false;
- } else {
- return match;
- }
- // 修复数字前的反斜杠(如 \200,000 -> 200,000)
- innerContent = innerContent.replace(/\\(\d)/g, "$1");
- // 修复分号间隔(如 400;kg -> 400\;kg)
- innerContent = innerContent.replace(/(\d+)\s*;\s*/g, "$1\\;");
- return isBlock ? `$$${innerContent}$$` : `$${innerContent}$`;
- }
- );
- }
- /**
- * Markdown 内容渲染核心组件
- */
- function _MarkdownContent(props: MarkdownContentProps) {
- const escapedContent = useMemo(() => {
- return preprocessLaTeX(props.content);
- }, [props.content]);
- return (
- <ReactMarkdown
- remarkPlugins={[RemarkMath, RemarkGfm, RemarkBreaks]}
- rehypePlugins={[
- [
- RehypeKatex,
- {
- strict: 'ignore',
- throwOnError: false,
- errorColor: '#ff0000',
- trust: true,
- }
- ],
- [
- RehypeHighlight,
- {
- detect: false,
- ignoreMissing: true,
- }
- ]
- ]}
- components={{
- pre: PreCode,
- head: () => (
- <link
- rel="stylesheet"
- href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css"
- integrity="sha384-n8MVd4RsNIU0tAv4ct0nTaAbDJwPJzDEaqSD1odI+WdtXRGWt2kTvGFasHpSy3SV"
- crossOrigin="anonymous"
- />
- ),
- code: ({ className, children }) => {
- if (className && className.includes('language-think')) {
- return (
- <code style={{ whiteSpace: 'pre-wrap', background: '#f3f4f6', color: '#525252' }}>
- {children}
- </code>
- );
- }
- return children;
- },
- div: (pProps) => <div {...pProps} dir="auto" />,
- a: (aProps) => {
- const href = aProps.href || "";
- const isInternal = /^\/#/i.test(href);
- const target = isInternal ? "_self" : aProps.target ?? "_blank";
- return <a {...aProps} target={target} />;
- },
- img: ({ src, alt }) => (
- <span style={{ width: '100%', height: 'auto', cursor: 'pointer', display: 'inline-block' }}>
- <Image
- width='80%'
- src={src}
- alt={alt}
- preview={{ mask: null }}
- />
- </span>
- ),
- }}
- >
- {escapedContent}
- </ReactMarkdown>
- );
- }
- export const MarkdownContent = React.memo(_MarkdownContent);
- /**
- * Markdown 渲染主组件(带加载状态和样式控制)
- */
- export function Markdown(props: MarkdownProps) {
- const mdRef = useRef<HTMLDivElement>(null);
- return (
- <div
- className="markdown-body"
- style={{
- fontSize: `${props.fontSize ?? 14}px`,
- fontFamily: props.fontFamily || "inherit",
- }}
- ref={mdRef}
- dir="auto"
- >
- {props.loading ? (
- <LoadingIcon />
- ) : (
- <MarkdownContent content={props.content} />
- )}
- </div>
- );
- }
|