MarkdownRenderer.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513
  1. import ReactMarkdown from 'react-markdown';
  2. import 'katex/dist/katex.min.css';
  3. import 'highlight.js/styles/github.css';
  4. import './markdown.css';
  5. import RemarkMath from 'remark-math';
  6. import RemarkBreaks from 'remark-breaks';
  7. import RehypeKatex from 'rehype-katex';
  8. import RemarkGfm from 'remark-gfm';
  9. import RehypeHighlight from 'rehype-highlight';
  10. import { useRef, useState, useEffect, useMemo } from 'react';
  11. import mermaid from 'mermaid';
  12. import React from 'react';
  13. import { useDebouncedCallback } from 'use-debounce';
  14. import clsx from 'clsx';
  15. import { Button, Tooltip, Toast } from '@douyinfe/semi-ui';
  16. import { copy, rehypeSplitWordsIntoSpans } from '../../../helpers';
  17. import { IconCopy } from '@douyinfe/semi-icons';
  18. import { useTranslation } from 'react-i18next';
  19. mermaid.initialize({
  20. startOnLoad: false,
  21. theme: 'default',
  22. securityLevel: 'loose',
  23. });
  24. export function Mermaid(props) {
  25. const ref = useRef(null);
  26. const [hasError, setHasError] = useState(false);
  27. useEffect(() => {
  28. if (props.code && ref.current) {
  29. mermaid
  30. .run({
  31. nodes: [ref.current],
  32. suppressErrors: true,
  33. })
  34. .catch((e) => {
  35. setHasError(true);
  36. console.error('[Mermaid] ', e.message);
  37. });
  38. }
  39. }, [props.code]);
  40. function viewSvgInNewWindow() {
  41. const svg = ref.current?.querySelector('svg');
  42. if (!svg) return;
  43. const text = new XMLSerializer().serializeToString(svg);
  44. const blob = new Blob([text], { type: 'image/svg+xml' });
  45. const url = URL.createObjectURL(blob);
  46. window.open(url, '_blank');
  47. }
  48. if (hasError) {
  49. return null;
  50. }
  51. return (
  52. <div
  53. className={clsx('mermaid-container')}
  54. style={{
  55. cursor: 'pointer',
  56. overflow: 'auto',
  57. padding: '12px',
  58. border: '1px solid var(--semi-color-border)',
  59. borderRadius: '8px',
  60. backgroundColor: 'var(--semi-color-bg-1)',
  61. margin: '12px 0',
  62. }}
  63. ref={ref}
  64. onClick={() => viewSvgInNewWindow()}
  65. >
  66. {props.code}
  67. </div>
  68. );
  69. }
  70. export function PreCode(props) {
  71. const ref = useRef(null);
  72. const [mermaidCode, setMermaidCode] = useState('');
  73. const [htmlCode, setHtmlCode] = useState('');
  74. const { t } = useTranslation();
  75. const renderArtifacts = useDebouncedCallback(() => {
  76. if (!ref.current) return;
  77. const mermaidDom = ref.current.querySelector('code.language-mermaid');
  78. if (mermaidDom) {
  79. setMermaidCode(mermaidDom.innerText);
  80. }
  81. const htmlDom = ref.current.querySelector('code.language-html');
  82. const refText = ref.current.querySelector('code')?.innerText;
  83. if (htmlDom) {
  84. setHtmlCode(htmlDom.innerText);
  85. } else if (
  86. refText?.startsWith('<!DOCTYPE') ||
  87. refText?.startsWith('<svg') ||
  88. refText?.startsWith('<?xml')
  89. ) {
  90. setHtmlCode(refText);
  91. }
  92. }, 600);
  93. // 处理代码块的换行
  94. useEffect(() => {
  95. if (ref.current) {
  96. const codeElements = ref.current.querySelectorAll('code');
  97. const wrapLanguages = [
  98. '',
  99. 'md',
  100. 'markdown',
  101. 'text',
  102. 'txt',
  103. 'plaintext',
  104. 'tex',
  105. 'latex',
  106. ];
  107. codeElements.forEach((codeElement) => {
  108. let languageClass = codeElement.className.match(/language-(\w+)/);
  109. let name = languageClass ? languageClass[1] : '';
  110. if (wrapLanguages.includes(name)) {
  111. codeElement.style.whiteSpace = 'pre-wrap';
  112. }
  113. });
  114. setTimeout(renderArtifacts, 1);
  115. }
  116. }, []);
  117. return (
  118. <>
  119. <pre
  120. ref={ref}
  121. style={{
  122. position: 'relative',
  123. backgroundColor: 'var(--semi-color-fill-0)',
  124. border: '1px solid var(--semi-color-border)',
  125. borderRadius: '6px',
  126. padding: '12px',
  127. margin: '12px 0',
  128. overflow: 'auto',
  129. fontSize: '14px',
  130. lineHeight: '1.4',
  131. }}
  132. >
  133. <div
  134. className="copy-code-button"
  135. style={{
  136. position: 'absolute',
  137. top: '8px',
  138. right: '8px',
  139. display: 'flex',
  140. gap: '4px',
  141. zIndex: 10,
  142. opacity: 0,
  143. transition: 'opacity 0.2s ease',
  144. }}
  145. >
  146. <Tooltip content={t('复制代码')}>
  147. <Button
  148. size="small"
  149. theme="borderless"
  150. icon={<IconCopy />}
  151. onClick={(e) => {
  152. e.preventDefault();
  153. e.stopPropagation();
  154. if (ref.current) {
  155. const code = ref.current.querySelector('code')?.innerText ?? '';
  156. copy(code).then((success) => {
  157. if (success) {
  158. Toast.success(t('代码已复制到剪贴板'));
  159. } else {
  160. Toast.error(t('复制失败,请手动复制'));
  161. }
  162. });
  163. }
  164. }}
  165. style={{
  166. padding: '4px',
  167. backgroundColor: 'var(--semi-color-bg-2)',
  168. borderRadius: '4px',
  169. cursor: 'pointer',
  170. border: '1px solid var(--semi-color-border)',
  171. boxShadow: '0 1px 2px rgba(0, 0, 0, 0.1)',
  172. }}
  173. />
  174. </Tooltip>
  175. </div>
  176. {props.children}
  177. </pre>
  178. {mermaidCode.length > 0 && (
  179. <Mermaid code={mermaidCode} key={mermaidCode} />
  180. )}
  181. {htmlCode.length > 0 && (
  182. <div
  183. style={{
  184. border: '1px solid var(--semi-color-border)',
  185. borderRadius: '8px',
  186. padding: '16px',
  187. margin: '12px 0',
  188. backgroundColor: 'var(--semi-color-bg-1)',
  189. }}
  190. >
  191. <div style={{ marginBottom: '8px', fontSize: '12px', color: 'var(--semi-color-text-2)' }}>
  192. HTML预览:
  193. </div>
  194. <div dangerouslySetInnerHTML={{ __html: htmlCode }} />
  195. </div>
  196. )}
  197. </>
  198. );
  199. }
  200. function CustomCode(props) {
  201. const ref = useRef(null);
  202. const [collapsed, setCollapsed] = useState(true);
  203. const [showToggle, setShowToggle] = useState(false);
  204. const { t } = useTranslation();
  205. useEffect(() => {
  206. if (ref.current) {
  207. const codeHeight = ref.current.scrollHeight;
  208. setShowToggle(codeHeight > 400);
  209. ref.current.scrollTop = ref.current.scrollHeight;
  210. }
  211. }, [props.children]);
  212. const toggleCollapsed = () => {
  213. setCollapsed((collapsed) => !collapsed);
  214. };
  215. const renderShowMoreButton = () => {
  216. if (showToggle && collapsed) {
  217. return (
  218. <div
  219. style={{
  220. position: 'absolute',
  221. bottom: '8px',
  222. right: '8px',
  223. left: '8px',
  224. display: 'flex',
  225. justifyContent: 'center',
  226. }}
  227. >
  228. <Button size="small" onClick={toggleCollapsed} theme="solid">
  229. {t('显示更多')}
  230. </Button>
  231. </div>
  232. );
  233. }
  234. return null;
  235. };
  236. return (
  237. <div style={{ position: 'relative' }}>
  238. <code
  239. className={clsx(props?.className)}
  240. ref={ref}
  241. style={{
  242. maxHeight: collapsed ? '400px' : 'none',
  243. overflowY: 'hidden',
  244. display: 'block',
  245. padding: '8px 12px',
  246. backgroundColor: 'var(--semi-color-fill-0)',
  247. borderRadius: '4px',
  248. fontSize: '13px',
  249. lineHeight: '1.4',
  250. }}
  251. >
  252. {props.children}
  253. </code>
  254. {renderShowMoreButton()}
  255. </div>
  256. );
  257. }
  258. function escapeBrackets(text) {
  259. const pattern =
  260. /(```[\s\S]*?```|`.*?`)|\\\[([\s\S]*?[^\\])\\\]|\\\((.*?)\\\)/g;
  261. return text.replace(
  262. pattern,
  263. (match, codeBlock, squareBracket, roundBracket) => {
  264. if (codeBlock) {
  265. return codeBlock;
  266. } else if (squareBracket) {
  267. return `$$${squareBracket}$$`;
  268. } else if (roundBracket) {
  269. return `$${roundBracket}$`;
  270. }
  271. return match;
  272. },
  273. );
  274. }
  275. function tryWrapHtmlCode(text) {
  276. // 尝试包装HTML代码
  277. if (text.includes('```')) {
  278. return text;
  279. }
  280. return text
  281. .replace(
  282. /([`]*?)(\w*?)([\n\r]*?)(<!DOCTYPE html>)/g,
  283. (match, quoteStart, lang, newLine, doctype) => {
  284. return !quoteStart ? '\n```html\n' + doctype : match;
  285. },
  286. )
  287. .replace(
  288. /(<\/body>)([\r\n\s]*?)(<\/html>)([\n\r]*)([`]*)([\n\r]*?)/g,
  289. (match, bodyEnd, space, htmlEnd, newLine, quoteEnd) => {
  290. return !quoteEnd ? bodyEnd + space + htmlEnd + '\n```\n' : match;
  291. },
  292. );
  293. }
  294. function _MarkdownContent(props) {
  295. const {
  296. content,
  297. className,
  298. animated = false,
  299. previousContentLength = 0,
  300. } = props;
  301. const escapedContent = useMemo(() => {
  302. return tryWrapHtmlCode(escapeBrackets(content));
  303. }, [content]);
  304. // 判断是否为用户消息
  305. const isUserMessage = className && className.includes('user-message');
  306. const rehypePluginsBase = useMemo(() => {
  307. const base = [
  308. RehypeKatex,
  309. [
  310. RehypeHighlight,
  311. {
  312. detect: false,
  313. ignoreMissing: true,
  314. },
  315. ],
  316. ];
  317. if (animated) {
  318. base.push([rehypeSplitWordsIntoSpans, { previousContentLength }]);
  319. }
  320. return base;
  321. }, [animated, previousContentLength]);
  322. return (
  323. <ReactMarkdown
  324. remarkPlugins={[RemarkMath, RemarkGfm, RemarkBreaks]}
  325. rehypePlugins={rehypePluginsBase}
  326. components={{
  327. pre: PreCode,
  328. code: CustomCode,
  329. p: (pProps) => <p {...pProps} dir="auto" style={{ lineHeight: '1.6', color: isUserMessage ? 'white' : 'inherit' }} />,
  330. a: (aProps) => {
  331. const href = aProps.href || '';
  332. if (/\.(aac|mp3|opus|wav)$/.test(href)) {
  333. return (
  334. <figure style={{ margin: '12px 0' }}>
  335. <audio controls src={href} style={{ width: '100%' }}></audio>
  336. </figure>
  337. );
  338. }
  339. if (/\.(3gp|3g2|webm|ogv|mpeg|mp4|avi)$/.test(href)) {
  340. return (
  341. <video controls style={{ width: '100%', maxWidth: '100%', margin: '12px 0' }}>
  342. <source src={href} />
  343. </video>
  344. );
  345. }
  346. const isInternal = /^\/#/i.test(href);
  347. const target = isInternal ? '_self' : aProps.target ?? '_blank';
  348. return (
  349. <a
  350. {...aProps}
  351. target={target}
  352. style={{
  353. color: isUserMessage ? '#87CEEB' : 'var(--semi-color-primary)',
  354. textDecoration: 'none',
  355. }}
  356. onMouseEnter={(e) => {
  357. e.target.style.textDecoration = 'underline';
  358. }}
  359. onMouseLeave={(e) => {
  360. e.target.style.textDecoration = 'none';
  361. }}
  362. />
  363. );
  364. },
  365. h1: (props) => <h1 {...props} style={{ fontSize: '24px', fontWeight: 'bold', margin: '20px 0 12px 0', color: isUserMessage ? 'white' : 'var(--semi-color-text-0)' }} />,
  366. h2: (props) => <h2 {...props} style={{ fontSize: '20px', fontWeight: 'bold', margin: '18px 0 10px 0', color: isUserMessage ? 'white' : 'var(--semi-color-text-0)' }} />,
  367. h3: (props) => <h3 {...props} style={{ fontSize: '18px', fontWeight: 'bold', margin: '16px 0 8px 0', color: isUserMessage ? 'white' : 'var(--semi-color-text-0)' }} />,
  368. h4: (props) => <h4 {...props} style={{ fontSize: '16px', fontWeight: 'bold', margin: '14px 0 6px 0', color: isUserMessage ? 'white' : 'var(--semi-color-text-0)' }} />,
  369. h5: (props) => <h5 {...props} style={{ fontSize: '14px', fontWeight: 'bold', margin: '12px 0 4px 0', color: isUserMessage ? 'white' : 'var(--semi-color-text-0)' }} />,
  370. h6: (props) => <h6 {...props} style={{ fontSize: '13px', fontWeight: 'bold', margin: '10px 0 4px 0', color: isUserMessage ? 'white' : 'var(--semi-color-text-0)' }} />,
  371. blockquote: (props) => (
  372. <blockquote
  373. {...props}
  374. style={{
  375. borderLeft: isUserMessage ? '4px solid rgba(255, 255, 255, 0.5)' : '4px solid var(--semi-color-primary)',
  376. paddingLeft: '16px',
  377. margin: '12px 0',
  378. backgroundColor: isUserMessage ? 'rgba(255, 255, 255, 0.1)' : 'var(--semi-color-fill-0)',
  379. padding: '8px 16px',
  380. borderRadius: '0 4px 4px 0',
  381. fontStyle: 'italic',
  382. color: isUserMessage ? 'white' : 'inherit',
  383. }}
  384. />
  385. ),
  386. ul: (props) => <ul {...props} style={{ margin: '8px 0', paddingLeft: '20px', color: isUserMessage ? 'white' : 'inherit' }} />,
  387. ol: (props) => <ol {...props} style={{ margin: '8px 0', paddingLeft: '20px', color: isUserMessage ? 'white' : 'inherit' }} />,
  388. li: (props) => <li {...props} style={{ margin: '4px 0', lineHeight: '1.6', color: isUserMessage ? 'white' : 'inherit' }} />,
  389. table: (props) => (
  390. <div style={{ overflow: 'auto', margin: '12px 0' }}>
  391. <table
  392. {...props}
  393. style={{
  394. width: '100%',
  395. borderCollapse: 'collapse',
  396. border: isUserMessage ? '1px solid rgba(255, 255, 255, 0.3)' : '1px solid var(--semi-color-border)',
  397. borderRadius: '6px',
  398. overflow: 'hidden',
  399. }}
  400. />
  401. </div>
  402. ),
  403. th: (props) => (
  404. <th
  405. {...props}
  406. style={{
  407. padding: '8px 12px',
  408. backgroundColor: isUserMessage ? 'rgba(255, 255, 255, 0.2)' : 'var(--semi-color-fill-1)',
  409. border: isUserMessage ? '1px solid rgba(255, 255, 255, 0.3)' : '1px solid var(--semi-color-border)',
  410. fontWeight: 'bold',
  411. textAlign: 'left',
  412. color: isUserMessage ? 'white' : 'inherit',
  413. }}
  414. />
  415. ),
  416. td: (props) => (
  417. <td
  418. {...props}
  419. style={{
  420. padding: '8px 12px',
  421. border: isUserMessage ? '1px solid rgba(255, 255, 255, 0.3)' : '1px solid var(--semi-color-border)',
  422. color: isUserMessage ? 'white' : 'inherit',
  423. }}
  424. />
  425. ),
  426. }}
  427. >
  428. {escapedContent}
  429. </ReactMarkdown>
  430. );
  431. }
  432. export const MarkdownContent = React.memo(_MarkdownContent);
  433. export function MarkdownRenderer(props) {
  434. const {
  435. content,
  436. loading,
  437. fontSize = 14,
  438. fontFamily = 'inherit',
  439. className,
  440. style,
  441. animated = false,
  442. previousContentLength = 0,
  443. ...otherProps
  444. } = props;
  445. return (
  446. <div
  447. className={clsx('markdown-body', className)}
  448. style={{
  449. fontSize: `${fontSize}px`,
  450. fontFamily: fontFamily,
  451. lineHeight: '1.6',
  452. color: 'var(--semi-color-text-0)',
  453. ...style,
  454. }}
  455. dir="auto"
  456. {...otherProps}
  457. >
  458. {loading ? (
  459. <div style={{
  460. display: 'flex',
  461. alignItems: 'center',
  462. gap: '8px',
  463. padding: '16px',
  464. color: 'var(--semi-color-text-2)',
  465. }}>
  466. <div style={{
  467. width: '16px',
  468. height: '16px',
  469. border: '2px solid var(--semi-color-border)',
  470. borderTop: '2px solid var(--semi-color-primary)',
  471. borderRadius: '50%',
  472. animation: 'spin 1s linear infinite',
  473. }} />
  474. 正在渲染...
  475. </div>
  476. ) : (
  477. <MarkdownContent
  478. content={content}
  479. className={className}
  480. animated={animated}
  481. previousContentLength={previousContentLength}
  482. />
  483. )}
  484. </div>
  485. );
  486. }
  487. export default MarkdownRenderer;