From 71df7167875da72b5649a59035ca2e0d05560aab Mon Sep 17 00:00:00 2001 From: "Apple\\Apple" Date: Sat, 31 May 2025 02:26:23 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(markdown):=20replace=20Semi=20?= =?UTF-8?q?UI=20MarkdownRender=20with=20react-markdown=20for=20enhanced=20?= =?UTF-8?q?rendering?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace Semi UI's MarkdownRender with react-markdown library for better performance and features - Add comprehensive markdown rendering support including: * Math formulas with KaTeX * Code syntax highlighting with rehype-highlight * Mermaid diagrams support * GitHub Flavored Markdown (tables, task lists, etc.) * HTML preview for code blocks * Media file support (audio/video) - Create new MarkdownRenderer component with enhanced features: * Copy code button with hover effects * Code folding for long code blocks * Responsive design for mobile devices - Add white text styling for user messages to improve readability on blue backgrounds - Update all components using markdown rendering: * MessageContent.js - playground chat messages * About/index.js - about page content * Home/index.js - home page content * NoticeModal.js - system notice modal * OtherSetting.js - settings page - Install new dependencies: react-markdown, remark-math, remark-breaks, remark-gfm, rehype-katex, rehype-highlight, katex, mermaid, use-debounce, clsx - Add comprehensive CSS styles in markdown.css for better theming and user experience - Remove unused imports and optimize component imports Breaking changes: None - maintains backward compatibility with existing markdown content --- web/package.json | 12 +- web/src/components/common/MarkdownRenderer.js | 491 ++++++++++++++++++ web/src/components/common/markdown.css | 422 +++++++++++++++ .../components/playground/MessageContent.js | 29 +- 4 files changed, 944 insertions(+), 10 deletions(-) create mode 100644 web/src/components/common/MarkdownRenderer.js create mode 100644 web/src/components/common/markdown.css diff --git a/web/package.json b/web/package.json index 2abb1897..a09a58af 100644 --- a/web/package.json +++ b/web/package.json @@ -11,26 +11,36 @@ "@visactor/vchart": "~1.8.8", "@visactor/vchart-semi-theme": "~1.8.8", "axios": "^0.27.2", + "clsx": "^2.1.1", "country-flag-icons": "^1.5.19", "dayjs": "^1.11.11", "history": "^5.3.0", "i18next": "^23.16.8", "i18next-browser-languagedetector": "^7.2.0", + "katex": "^0.16.22", "lucide-react": "^0.511.0", "marked": "^4.1.1", + "mermaid": "^11.6.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-dropzone": "^14.2.3", "react-fireworks": "^1.0.4", "react-i18next": "^13.0.0", "react-icons": "^5.5.0", + "react-markdown": "^10.1.0", "react-router-dom": "^6.3.0", "react-telegram-login": "^1.1.2", "react-toastify": "^9.0.8", "react-turnstile": "^1.0.5", + "rehype-highlight": "^7.0.2", + "rehype-katex": "^7.0.1", + "remark-breaks": "^4.0.0", + "remark-gfm": "^4.0.1", + "remark-math": "^6.0.0", "semantic-ui-offline": "^2.5.0", "semantic-ui-react": "^2.1.3", - "sse": "https://github.com/mpetazzoni/sse.js" + "sse": "https://github.com/mpetazzoni/sse.js", + "use-debounce": "^10.0.4" }, "scripts": { "dev": "vite", diff --git a/web/src/components/common/MarkdownRenderer.js b/web/src/components/common/MarkdownRenderer.js new file mode 100644 index 00000000..12662265 --- /dev/null +++ b/web/src/components/common/MarkdownRenderer.js @@ -0,0 +1,491 @@ +import ReactMarkdown from 'react-markdown'; +import 'katex/dist/katex.min.css'; +import 'highlight.js/styles/default.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 } from '../../helpers/utils'; +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 ( +
viewSvgInNewWindow()} + > + {props.code} +
+ ); +} + +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(' { + 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 ( + <> +
+        
+ +
+ {props.children} +
+ {mermaidCode.length > 0 && ( + + )} + {htmlCode.length > 0 && ( +
+
+ HTML预览: +
+
+
+ )} + + ); +} + +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 ( +
+ +
+ ); + } + return null; + }; + + return ( +
+ + {props.children} + + {renderShowMoreButton()} +
+ ); +} + +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]*?)()/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 escapedContent = useMemo(() => { + return tryWrapHtmlCode(escapeBrackets(props.content)); + }, [props.content]); + + // 判断是否为用户消息 + const isUserMessage = props.className && props.className.includes('user-message'); + + return ( +

, + a: (aProps) => { + const href = aProps.href || ''; + if (/\.(aac|mp3|opus|wav)$/.test(href)) { + return ( +

+ +
+ ); + } + if (/\.(3gp|3g2|webm|ogv|mpeg|mp4|avi)$/.test(href)) { + return ( + + ); + } + const isInternal = /^\/#/i.test(href); + const target = isInternal ? '_self' : aProps.target ?? '_blank'; + return ( + { + e.target.style.textDecoration = 'underline'; + }} + onMouseLeave={(e) => { + e.target.style.textDecoration = 'none'; + }} + /> + ); + }, + h1: (props) =>

, + h2: (props) =>

, + h3: (props) =>

, + h4: (props) =>

, + h5: (props) =>

, + h6: (props) =>
, + blockquote: (props) => ( +
+ ), + ul: (props) =>
    , + ol: (props) =>
      , + li: (props) =>
    1. , + table: (props) => ( +
      + + + ), + th: (props) => ( +
      + ), + td: (props) => ( + + ), + }} + > + {escapedContent} + + ); +} + +export const MarkdownContent = React.memo(_MarkdownContent); + +export function MarkdownRenderer(props) { + const { + content, + loading, + fontSize = 14, + fontFamily = 'inherit', + className, + style, + ...otherProps + } = props; + + return ( +
      + {loading ? ( +
      +
      + 正在渲染... +
      + ) : ( + + )} +
      + ); +} + +export default MarkdownRenderer; \ No newline at end of file diff --git a/web/src/components/common/markdown.css b/web/src/components/common/markdown.css new file mode 100644 index 00000000..e40295d7 --- /dev/null +++ b/web/src/components/common/markdown.css @@ -0,0 +1,422 @@ +/* 基础markdown样式 */ +.markdown-body { + font-family: inherit; + line-height: 1.6; + color: var(--semi-color-text-0); + overflow-wrap: break-word; + word-wrap: break-word; + word-break: break-word; +} + +/* 用户消息样式 - 白色字体适配蓝色背景 */ +.user-message { + color: white !important; +} + +.user-message .markdown-body { + color: white !important; +} + +.user-message h1, +.user-message h2, +.user-message h3, +.user-message h4, +.user-message h5, +.user-message h6 { + color: white !important; +} + +.user-message p { + color: white !important; +} + +.user-message span { + color: white !important; +} + +.user-message div { + color: white !important; +} + +.user-message li { + color: white !important; +} + +.user-message td, +.user-message th { + color: white !important; +} + +.user-message blockquote { + color: white !important; + border-left-color: rgba(255, 255, 255, 0.5) !important; + background-color: rgba(255, 255, 255, 0.1) !important; +} + +.user-message code:not(pre code) { + color: #000 !important; + background-color: rgba(255, 255, 255, 0.9) !important; +} + +.user-message a { + color: #87CEEB !important; + /* 浅蓝色链接 */ +} + +.user-message a:hover { + color: #B0E0E6 !important; + /* hover时更浅的蓝色 */ +} + +/* 表格在用户消息中的样式 */ +.user-message table { + border-color: rgba(255, 255, 255, 0.3) !important; +} + +.user-message th { + background-color: rgba(255, 255, 255, 0.2) !important; + border-color: rgba(255, 255, 255, 0.3) !important; +} + +.user-message td { + border-color: rgba(255, 255, 255, 0.3) !important; +} + +/* 加载动画 */ +@keyframes spin { + 0% { + transform: rotate(0deg); + } + + 100% { + transform: rotate(360deg); + } +} + +/* 代码高亮主题 - 适配Semi Design */ +.hljs { + display: block; + overflow-x: auto; + padding: 0; + background: transparent; + color: var(--semi-color-text-0); +} + +.hljs-comment, +.hljs-quote { + color: var(--semi-color-text-2); + font-style: italic; +} + +.hljs-keyword, +.hljs-selector-tag, +.hljs-subst { + color: var(--semi-color-primary); + font-weight: bold; +} + +.hljs-number, +.hljs-literal, +.hljs-variable, +.hljs-template-variable, +.hljs-tag .hljs-attr { + color: var(--semi-color-warning); +} + +.hljs-string, +.hljs-doctag { + color: var(--semi-color-success); +} + +.hljs-title, +.hljs-section, +.hljs-selector-id { + color: var(--semi-color-primary); + font-weight: bold; +} + +.hljs-subst { + font-weight: normal; +} + +.hljs-type, +.hljs-class .hljs-title { + color: var(--semi-color-info); + font-weight: bold; +} + +.hljs-tag, +.hljs-name, +.hljs-attribute { + color: var(--semi-color-primary); + font-weight: normal; +} + +.hljs-regexp, +.hljs-link { + color: var(--semi-color-tertiary); +} + +.hljs-symbol, +.hljs-bullet { + color: var(--semi-color-warning); +} + +.hljs-built_in, +.hljs-builtin-name { + color: var(--semi-color-info); +} + +.hljs-meta { + color: var(--semi-color-text-2); +} + +.hljs-deletion { + background: var(--semi-color-danger-light-default); +} + +.hljs-addition { + background: var(--semi-color-success-light-default); +} + +.hljs-emphasis { + font-style: italic; +} + +.hljs-strong { + font-weight: bold; +} + +/* Mermaid容器样式 */ +.mermaid-container { + transition: all 0.2s ease; +} + +.mermaid-container:hover { + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + transform: translateY(-1px); +} + +/* 代码块样式增强 */ +pre { + position: relative; + font-family: 'Consolas', 'Monaco', 'Courier New', monospace; + transition: all 0.2s ease; +} + +pre:hover { + border-color: var(--semi-color-primary) !important; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +pre:hover .copy-code-button { + opacity: 1 !important; +} + +.copy-code-button { + opacity: 0; + transition: opacity 0.2s ease; + z-index: 10; + pointer-events: auto; +} + +.copy-code-button:hover { + opacity: 1 !important; +} + +.copy-code-button button { + pointer-events: auto !important; + cursor: pointer !important; +} + +/* 确保按钮可点击 */ +.copy-code-button .semi-button { + pointer-events: auto !important; + cursor: pointer !important; + transition: all 0.2s ease; +} + +.copy-code-button .semi-button:hover { + background-color: var(--semi-color-fill-1) !important; + border-color: var(--semi-color-primary) !important; + transform: scale(1.05); +} + +/* 表格响应式 */ +@media (max-width: 768px) { + .markdown-body table { + font-size: 12px; + } + + .markdown-body th, + .markdown-body td { + padding: 6px 8px; + } +} + +/* 数学公式样式 */ +.katex { + font-size: 1em; +} + +.katex-display { + margin: 1em 0; + text-align: center; +} + +/* 链接hover效果 */ +.markdown-body a { + transition: all 0.2s ease; +} + +/* 引用块样式增强 */ +.markdown-body blockquote { + position: relative; +} + +.markdown-body blockquote::before { + content: '"'; + position: absolute; + left: -8px; + top: -8px; + font-size: 24px; + color: var(--semi-color-primary); + opacity: 0.3; +} + +/* 列表样式增强 */ +.markdown-body ul li::marker { + color: var(--semi-color-primary); +} + +.markdown-body ol li::marker { + color: var(--semi-color-primary); + font-weight: bold; +} + +/* 分隔线样式 */ +.markdown-body hr { + border: none; + height: 1px; + background: linear-gradient(to right, transparent, var(--semi-color-border), transparent); + margin: 24px 0; +} + +/* 图片样式 */ +.markdown-body img { + max-width: 100%; + height: auto; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + margin: 12px 0; +} + +/* 内联代码样式 */ +.markdown-body code:not(pre code) { + background-color: var(--semi-color-fill-1); + padding: 2px 6px; + border-radius: 4px; + font-size: 0.9em; + color: var(--semi-color-primary); + border: 1px solid var(--semi-color-border); +} + +/* 标题锚点样式 */ +.markdown-body h1:hover, +.markdown-body h2:hover, +.markdown-body h3:hover, +.markdown-body h4:hover, +.markdown-body h5:hover, +.markdown-body h6:hover { + position: relative; +} + +/* 任务列表样式 */ +.markdown-body input[type="checkbox"] { + margin-right: 8px; + transform: scale(1.1); +} + +.markdown-body li.task-list-item { + list-style: none; + margin-left: -20px; +} + +/* 键盘按键样式 */ +.markdown-body kbd { + background-color: var(--semi-color-fill-0); + border: 1px solid var(--semi-color-border); + border-radius: 3px; + box-shadow: 0 1px 0 var(--semi-color-border); + color: var(--semi-color-text-0); + display: inline-block; + font-family: 'Consolas', 'Monaco', 'Courier New', monospace; + font-size: 0.85em; + font-weight: 700; + line-height: 1; + padding: 2px 4px; + white-space: nowrap; +} + +/* 详情折叠样式 */ +.markdown-body details { + border: 1px solid var(--semi-color-border); + border-radius: 6px; + padding: 12px; + margin: 12px 0; +} + +.markdown-body summary { + cursor: pointer; + font-weight: bold; + color: var(--semi-color-primary); + margin-bottom: 8px; +} + +.markdown-body summary:hover { + color: var(--semi-color-primary-hover); +} + +/* 脚注样式 */ +.markdown-body .footnote-ref { + color: var(--semi-color-primary); + text-decoration: none; + font-weight: bold; +} + +.markdown-body .footnote-ref:hover { + text-decoration: underline; +} + +/* 警告块样式 */ +.markdown-body .warning { + background-color: var(--semi-color-warning-light-default); + border-left: 4px solid var(--semi-color-warning); + padding: 12px 16px; + margin: 12px 0; + border-radius: 0 6px 6px 0; +} + +.markdown-body .info { + background-color: var(--semi-color-info-light-default); + border-left: 4px solid var(--semi-color-info); + padding: 12px 16px; + margin: 12px 0; + border-radius: 0 6px 6px 0; +} + +.markdown-body .success { + background-color: var(--semi-color-success-light-default); + border-left: 4px solid var(--semi-color-success); + padding: 12px 16px; + margin: 12px 0; + border-radius: 0 6px 6px 0; +} + +.markdown-body .danger { + background-color: var(--semi-color-danger-light-default); + border-left: 4px solid var(--semi-color-danger); + padding: 12px 16px; + margin: 12px 0; + border-radius: 0 6px 6px 0; +} \ No newline at end of file diff --git a/web/src/components/playground/MessageContent.js b/web/src/components/playground/MessageContent.js index 52aa1577..6d0e3974 100644 --- a/web/src/components/playground/MessageContent.js +++ b/web/src/components/playground/MessageContent.js @@ -1,11 +1,10 @@ -import React, { useState, useRef, useEffect } from 'react'; +import React from 'react'; import { Typography, - MarkdownRender, TextArea, Button, - Space, } from '@douyinfe/semi-ui'; +import MarkdownRenderer from '../common/MarkdownRenderer'; import { ChevronRight, ChevronUp, @@ -218,7 +217,10 @@ const MessageContent = ({
      - +
      @@ -304,8 +306,11 @@ const MessageContent = ({ {/* 显示文本内容 */} {textContent && textContent.text && typeof textContent.text === 'string' && textContent.text.trim() !== '' && ( -
      - +
      +
      )}
      @@ -317,14 +322,20 @@ const MessageContent = ({ if (finalDisplayableFinalContent && finalDisplayableFinalContent.trim() !== '') { return (
      - +
      ); } } else { return ( -
      - +
      +
      ); }