| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513 |
- import ReactMarkdown from 'react-markdown';
- import 'katex/dist/katex.min.css';
- import 'highlight.js/styles/github.css';
- import './markdown.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, useEffect, useMemo } from 'react';
- import mermaid from 'mermaid';
- import React from 'react';
- import { useDebouncedCallback } from 'use-debounce';
- import clsx from 'clsx';
- import { Button, Tooltip, Toast } from '@douyinfe/semi-ui';
- import { copy, rehypeSplitWordsIntoSpans } from '../../../helpers';
- import { IconCopy } from '@douyinfe/semi-icons';
- import { useTranslation } from 'react-i18next';
- mermaid.initialize({
- startOnLoad: false,
- theme: 'default',
- securityLevel: 'loose',
- });
- export function Mermaid(props) {
- const ref = useRef(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]);
- 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' });
- const url = URL.createObjectURL(blob);
- window.open(url, '_blank');
- }
- if (hasError) {
- return null;
- }
- return (
- <div
- className={clsx('mermaid-container')}
- style={{
- cursor: 'pointer',
- overflow: 'auto',
- padding: '12px',
- border: '1px solid var(--semi-color-border)',
- borderRadius: '8px',
- backgroundColor: 'var(--semi-color-bg-1)',
- margin: '12px 0',
- }}
- ref={ref}
- onClick={() => viewSvgInNewWindow()}
- >
- {props.code}
- </div>
- );
- }
- export function PreCode(props) {
- const ref = useRef(null);
- const [mermaidCode, setMermaidCode] = useState('');
- const [htmlCode, setHtmlCode] = useState('');
- const { t } = useTranslation();
- const renderArtifacts = useDebouncedCallback(() => {
- if (!ref.current) return;
- const mermaidDom = ref.current.querySelector('code.language-mermaid');
- if (mermaidDom) {
- setMermaidCode(mermaidDom.innerText);
- }
- const htmlDom = ref.current.querySelector('code.language-html');
- const refText = ref.current.querySelector('code')?.innerText;
- if (htmlDom) {
- setHtmlCode(htmlDom.innerText);
- } else if (
- refText?.startsWith('<!DOCTYPE') ||
- refText?.startsWith('<svg') ||
- refText?.startsWith('<?xml')
- ) {
- setHtmlCode(refText);
- }
- }, 600);
- // 处理代码块的换行
- useEffect(() => {
- if (ref.current) {
- const codeElements = ref.current.querySelectorAll('code');
- 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}
- style={{
- position: 'relative',
- backgroundColor: 'var(--semi-color-fill-0)',
- border: '1px solid var(--semi-color-border)',
- borderRadius: '6px',
- padding: '12px',
- margin: '12px 0',
- overflow: 'auto',
- fontSize: '14px',
- lineHeight: '1.4',
- }}
- >
- <div
- className="copy-code-button"
- style={{
- position: 'absolute',
- top: '8px',
- right: '8px',
- display: 'flex',
- gap: '4px',
- zIndex: 10,
- opacity: 0,
- transition: 'opacity 0.2s ease',
- }}
- >
- <Tooltip content={t('复制代码')}>
- <Button
- size="small"
- theme="borderless"
- icon={<IconCopy />}
- onClick={(e) => {
- e.preventDefault();
- e.stopPropagation();
- if (ref.current) {
- const code = ref.current.querySelector('code')?.innerText ?? '';
- copy(code).then((success) => {
- if (success) {
- Toast.success(t('代码已复制到剪贴板'));
- } else {
- Toast.error(t('复制失败,请手动复制'));
- }
- });
- }
- }}
- style={{
- padding: '4px',
- backgroundColor: 'var(--semi-color-bg-2)',
- borderRadius: '4px',
- cursor: 'pointer',
- border: '1px solid var(--semi-color-border)',
- boxShadow: '0 1px 2px rgba(0, 0, 0, 0.1)',
- }}
- />
- </Tooltip>
- </div>
- {props.children}
- </pre>
- {mermaidCode.length > 0 && (
- <Mermaid code={mermaidCode} key={mermaidCode} />
- )}
- {htmlCode.length > 0 && (
- <div
- style={{
- border: '1px solid var(--semi-color-border)',
- borderRadius: '8px',
- padding: '16px',
- margin: '12px 0',
- backgroundColor: 'var(--semi-color-bg-1)',
- }}
- >
- <div style={{ marginBottom: '8px', fontSize: '12px', color: 'var(--semi-color-text-2)' }}>
- HTML预览:
- </div>
- <div dangerouslySetInnerHTML={{ __html: htmlCode }} />
- </div>
- )}
- </>
- );
- }
- function CustomCode(props) {
- const ref = useRef(null);
- const [collapsed, setCollapsed] = useState(true);
- const [showToggle, setShowToggle] = useState(false);
- const { t } = useTranslation();
- 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 && collapsed) {
- return (
- <div
- style={{
- position: 'absolute',
- bottom: '8px',
- right: '8px',
- left: '8px',
- display: 'flex',
- justifyContent: 'center',
- }}
- >
- <Button size="small" onClick={toggleCollapsed} theme="solid">
- {t('显示更多')}
- </Button>
- </div>
- );
- }
- return null;
- };
- return (
- <div style={{ position: 'relative' }}>
- <code
- className={clsx(props?.className)}
- ref={ref}
- style={{
- maxHeight: collapsed ? '400px' : 'none',
- overflowY: 'hidden',
- display: 'block',
- padding: '8px 12px',
- backgroundColor: 'var(--semi-color-fill-0)',
- borderRadius: '4px',
- fontSize: '13px',
- lineHeight: '1.4',
- }}
- >
- {props.children}
- </code>
- {renderShowMoreButton()}
- </div>
- );
- }
- function escapeBrackets(text) {
- 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) {
- // 尝试包装HTML代码
- 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) {
- const {
- content,
- className,
- animated = false,
- previousContentLength = 0,
- } = props;
- const escapedContent = useMemo(() => {
- return tryWrapHtmlCode(escapeBrackets(content));
- }, [content]);
- // 判断是否为用户消息
- const isUserMessage = className && className.includes('user-message');
- const rehypePluginsBase = useMemo(() => {
- const base = [
- RehypeKatex,
- [
- RehypeHighlight,
- {
- detect: false,
- ignoreMissing: true,
- },
- ],
- ];
- if (animated) {
- base.push([rehypeSplitWordsIntoSpans, { previousContentLength }]);
- }
- return base;
- }, [animated, previousContentLength]);
- return (
- <ReactMarkdown
- remarkPlugins={[RemarkMath, RemarkGfm, RemarkBreaks]}
- rehypePlugins={rehypePluginsBase}
- components={{
- pre: PreCode,
- code: CustomCode,
- p: (pProps) => <p {...pProps} dir="auto" style={{ lineHeight: '1.6', color: isUserMessage ? 'white' : 'inherit' }} />,
- a: (aProps) => {
- const href = aProps.href || '';
- if (/\.(aac|mp3|opus|wav)$/.test(href)) {
- return (
- <figure style={{ margin: '12px 0' }}>
- <audio controls src={href} style={{ width: '100%' }}></audio>
- </figure>
- );
- }
- if (/\.(3gp|3g2|webm|ogv|mpeg|mp4|avi)$/.test(href)) {
- return (
- <video controls style={{ width: '100%', maxWidth: '100%', margin: '12px 0' }}>
- <source src={href} />
- </video>
- );
- }
- const isInternal = /^\/#/i.test(href);
- const target = isInternal ? '_self' : aProps.target ?? '_blank';
- return (
- <a
- {...aProps}
- target={target}
- style={{
- color: isUserMessage ? '#87CEEB' : 'var(--semi-color-primary)',
- textDecoration: 'none',
- }}
- onMouseEnter={(e) => {
- e.target.style.textDecoration = 'underline';
- }}
- onMouseLeave={(e) => {
- e.target.style.textDecoration = 'none';
- }}
- />
- );
- },
- h1: (props) => <h1 {...props} style={{ fontSize: '24px', fontWeight: 'bold', margin: '20px 0 12px 0', color: isUserMessage ? 'white' : 'var(--semi-color-text-0)' }} />,
- h2: (props) => <h2 {...props} style={{ fontSize: '20px', fontWeight: 'bold', margin: '18px 0 10px 0', color: isUserMessage ? 'white' : 'var(--semi-color-text-0)' }} />,
- h3: (props) => <h3 {...props} style={{ fontSize: '18px', fontWeight: 'bold', margin: '16px 0 8px 0', color: isUserMessage ? 'white' : 'var(--semi-color-text-0)' }} />,
- h4: (props) => <h4 {...props} style={{ fontSize: '16px', fontWeight: 'bold', margin: '14px 0 6px 0', color: isUserMessage ? 'white' : 'var(--semi-color-text-0)' }} />,
- h5: (props) => <h5 {...props} style={{ fontSize: '14px', fontWeight: 'bold', margin: '12px 0 4px 0', color: isUserMessage ? 'white' : 'var(--semi-color-text-0)' }} />,
- h6: (props) => <h6 {...props} style={{ fontSize: '13px', fontWeight: 'bold', margin: '10px 0 4px 0', color: isUserMessage ? 'white' : 'var(--semi-color-text-0)' }} />,
- blockquote: (props) => (
- <blockquote
- {...props}
- style={{
- borderLeft: isUserMessage ? '4px solid rgba(255, 255, 255, 0.5)' : '4px solid var(--semi-color-primary)',
- paddingLeft: '16px',
- margin: '12px 0',
- backgroundColor: isUserMessage ? 'rgba(255, 255, 255, 0.1)' : 'var(--semi-color-fill-0)',
- padding: '8px 16px',
- borderRadius: '0 4px 4px 0',
- fontStyle: 'italic',
- color: isUserMessage ? 'white' : 'inherit',
- }}
- />
- ),
- ul: (props) => <ul {...props} style={{ margin: '8px 0', paddingLeft: '20px', color: isUserMessage ? 'white' : 'inherit' }} />,
- ol: (props) => <ol {...props} style={{ margin: '8px 0', paddingLeft: '20px', color: isUserMessage ? 'white' : 'inherit' }} />,
- li: (props) => <li {...props} style={{ margin: '4px 0', lineHeight: '1.6', color: isUserMessage ? 'white' : 'inherit' }} />,
- table: (props) => (
- <div style={{ overflow: 'auto', margin: '12px 0' }}>
- <table
- {...props}
- style={{
- width: '100%',
- borderCollapse: 'collapse',
- border: isUserMessage ? '1px solid rgba(255, 255, 255, 0.3)' : '1px solid var(--semi-color-border)',
- borderRadius: '6px',
- overflow: 'hidden',
- }}
- />
- </div>
- ),
- th: (props) => (
- <th
- {...props}
- style={{
- padding: '8px 12px',
- backgroundColor: isUserMessage ? 'rgba(255, 255, 255, 0.2)' : 'var(--semi-color-fill-1)',
- border: isUserMessage ? '1px solid rgba(255, 255, 255, 0.3)' : '1px solid var(--semi-color-border)',
- fontWeight: 'bold',
- textAlign: 'left',
- color: isUserMessage ? 'white' : 'inherit',
- }}
- />
- ),
- td: (props) => (
- <td
- {...props}
- style={{
- padding: '8px 12px',
- border: isUserMessage ? '1px solid rgba(255, 255, 255, 0.3)' : '1px solid var(--semi-color-border)',
- color: isUserMessage ? 'white' : 'inherit',
- }}
- />
- ),
- }}
- >
- {escapedContent}
- </ReactMarkdown>
- );
- }
- export const MarkdownContent = React.memo(_MarkdownContent);
- export function MarkdownRenderer(props) {
- const {
- content,
- loading,
- fontSize = 14,
- fontFamily = 'inherit',
- className,
- style,
- animated = false,
- previousContentLength = 0,
- ...otherProps
- } = props;
- return (
- <div
- className={clsx('markdown-body', className)}
- style={{
- fontSize: `${fontSize}px`,
- fontFamily: fontFamily,
- lineHeight: '1.6',
- color: 'var(--semi-color-text-0)',
- ...style,
- }}
- dir="auto"
- {...otherProps}
- >
- {loading ? (
- <div style={{
- display: 'flex',
- alignItems: 'center',
- gap: '8px',
- padding: '16px',
- color: 'var(--semi-color-text-2)',
- }}>
- <div style={{
- width: '16px',
- height: '16px',
- border: '2px solid var(--semi-color-border)',
- borderTop: '2px solid var(--semi-color-primary)',
- borderRadius: '50%',
- animation: 'spin 1s linear infinite',
- }} />
- 正在渲染...
- </div>
- ) : (
- <MarkdownContent
- content={content}
- className={className}
- animated={animated}
- previousContentLength={previousContentLength}
- />
- )}
- </div>
- );
- }
- export default MarkdownRenderer;
|