| @@ -0,0 +1,21 @@ | |||
| /** | |||
| * @fileoverview AudioBlock component for rendering audio elements in Markdown. | |||
| * Extracted from the main markdown renderer for modularity. | |||
| * Uses the AudioGallery component to display audio players. | |||
| */ | |||
| import React, { memo } from 'react' | |||
| import AudioGallery from '@/app/components/base/audio-gallery' | |||
| const AudioBlock: any = memo(({ node }: any) => { | |||
| const srcs = node.children.filter((child: any) => 'properties' in child).map((child: any) => (child as any).properties.src) | |||
| if (srcs.length === 0) { | |||
| const src = node.properties?.src | |||
| if (src) | |||
| return <AudioGallery key={src} srcs={[src]} /> | |||
| return null | |||
| } | |||
| return <AudioGallery key={srcs.join()} srcs={srcs} /> | |||
| }) | |||
| AudioBlock.displayName = 'AudioBlock' | |||
| export default AudioBlock | |||
| @@ -1,34 +1,19 @@ | |||
| import ReactMarkdown from 'react-markdown' | |||
| import { memo, useEffect, useMemo, useRef, useState } from 'react' | |||
| import ReactEcharts from 'echarts-for-react' | |||
| 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 RehypeRaw from 'rehype-raw' | |||
| import SyntaxHighlighter from 'react-syntax-highlighter' | |||
| import { | |||
| atelierHeathDark, | |||
| atelierHeathLight, | |||
| } from 'react-syntax-highlighter/dist/esm/styles/hljs' | |||
| import { Component, memo, useEffect, useMemo, useRef, useState } from 'react' | |||
| import { flow } from 'lodash-es' | |||
| import ActionButton from '@/app/components/base/action-button' | |||
| import CopyIcon from '@/app/components/base/copy-icon' | |||
| import SVGBtn from '@/app/components/base/svg' | |||
| import Flowchart from '@/app/components/base/mermaid' | |||
| import ImageGallery from '@/app/components/base/image-gallery' | |||
| import { useChatContext } from '@/app/components/base/chat/chat/context' | |||
| import VideoGallery from '@/app/components/base/video-gallery' | |||
| import AudioGallery from '@/app/components/base/audio-gallery' | |||
| import MarkdownButton from '@/app/components/base/markdown-blocks/button' | |||
| import MarkdownForm from '@/app/components/base/markdown-blocks/form' | |||
| import MarkdownMusic from '@/app/components/base/markdown-blocks/music' | |||
| import ThinkBlock from '@/app/components/base/markdown-blocks/think-block' | |||
| import { Theme } from '@/types/app' | |||
| import useTheme from '@/hooks/use-theme' | |||
| import cn from '@/utils/classnames' | |||
| import SVGRenderer from './svg-gallery' | |||
| import SVGRenderer from '../svg-gallery' // Assumes svg-gallery.tsx is in /base directory | |||
| import MarkdownMusic from '@/app/components/base/markdown-blocks/music' | |||
| import ErrorBoundary from '@/app/components/base/markdown/error-boundary' | |||
| // Available language https://github.com/react-syntax-highlighter/react-syntax-highlighter/blob/master/AVAILABLE_LANGUAGES_HLJS.MD | |||
| const capitalizationLanguageNameMap: Record<string, string> = { | |||
| @@ -64,50 +49,6 @@ const getCorrectCapitalizationLanguageName = (language: string) => { | |||
| return language.charAt(0).toUpperCase() + language.substring(1) | |||
| } | |||
| const preprocessLaTeX = (content: string) => { | |||
| if (typeof content !== 'string') | |||
| return content | |||
| const codeBlockRegex = /```[\s\S]*?```/g | |||
| const codeBlocks = content.match(codeBlockRegex) || [] | |||
| let processedContent = content.replace(codeBlockRegex, 'CODE_BLOCK_PLACEHOLDER') | |||
| processedContent = flow([ | |||
| (str: string) => str.replace(/\\\[(.*?)\\\]/g, (_, equation) => `$$${equation}$$`), | |||
| (str: string) => str.replace(/\\\[([\s\S]*?)\\\]/g, (_, equation) => `$$${equation}$$`), | |||
| (str: string) => str.replace(/\\\((.*?)\\\)/g, (_, equation) => `$$${equation}$$`), | |||
| (str: string) => str.replace(/(^|[^\\])\$(.+?)\$/g, (_, prefix, equation) => `${prefix}$${equation}$`), | |||
| ])(processedContent) | |||
| codeBlocks.forEach((block) => { | |||
| processedContent = processedContent.replace('CODE_BLOCK_PLACEHOLDER', block) | |||
| }) | |||
| return processedContent | |||
| } | |||
| const preprocessThinkTag = (content: string) => { | |||
| const thinkOpenTagRegex = /<think>\n/g | |||
| const thinkCloseTagRegex = /\n<\/think>/g | |||
| return flow([ | |||
| (str: string) => str.replace(thinkOpenTagRegex, '<details data-think=true>\n'), | |||
| (str: string) => str.replace(thinkCloseTagRegex, '\n[ENDTHINKFLAG]</details>'), | |||
| ])(content) | |||
| } | |||
| export function PreCode(props: { children: any }) { | |||
| const ref = useRef<HTMLPreElement>(null) | |||
| return ( | |||
| <pre ref={ref}> | |||
| <span | |||
| className="copy-code-button" | |||
| ></span> | |||
| {props.children} | |||
| </pre> | |||
| ) | |||
| } | |||
| // **Add code block | |||
| // Avoid error #185 (Maximum update depth exceeded. | |||
| // This can happen when a component repeatedly calls setState inside componentWillUpdate or componentDidUpdate. | |||
| @@ -444,150 +385,4 @@ const CodeBlock: any = memo(({ inline, className, children = '', ...props }: any | |||
| }) | |||
| CodeBlock.displayName = 'CodeBlock' | |||
| const VideoBlock: any = memo(({ node }: any) => { | |||
| const srcs = node.children.filter((child: any) => 'properties' in child).map((child: any) => (child as any).properties.src) | |||
| if (srcs.length === 0) { | |||
| const src = node.properties?.src | |||
| if (src) | |||
| return <VideoGallery key={src} srcs={[src]} /> | |||
| return null | |||
| } | |||
| return <VideoGallery key={srcs.join()} srcs={srcs} /> | |||
| }) | |||
| VideoBlock.displayName = 'VideoBlock' | |||
| const AudioBlock: any = memo(({ node }: any) => { | |||
| const srcs = node.children.filter((child: any) => 'properties' in child).map((child: any) => (child as any).properties.src) | |||
| if (srcs.length === 0) { | |||
| const src = node.properties?.src | |||
| if (src) | |||
| return <AudioGallery key={src} srcs={[src]} /> | |||
| return null | |||
| } | |||
| return <AudioGallery key={srcs.join()} srcs={srcs} /> | |||
| }) | |||
| AudioBlock.displayName = 'AudioBlock' | |||
| const ScriptBlock = memo(({ node }: any) => { | |||
| const scriptContent = node.children[0]?.value || '' | |||
| return `<script>${scriptContent}</script>` | |||
| }) | |||
| ScriptBlock.displayName = 'ScriptBlock' | |||
| const Paragraph = (paragraph: any) => { | |||
| const { node }: any = paragraph | |||
| const children_node = node.children | |||
| if (children_node && children_node[0] && 'tagName' in children_node[0] && children_node[0].tagName === 'img') { | |||
| return ( | |||
| <div className="markdown-img-wrapper"> | |||
| <ImageGallery srcs={[children_node[0].properties.src]} /> | |||
| { | |||
| Array.isArray(paragraph.children) && paragraph.children.length > 1 && ( | |||
| <div className="mt-2">{paragraph.children.slice(1)}</div> | |||
| ) | |||
| } | |||
| </div> | |||
| ) | |||
| } | |||
| return <p>{paragraph.children}</p> | |||
| } | |||
| const Img = ({ src }: any) => { | |||
| return <div className="markdown-img-wrapper"><ImageGallery srcs={[src]} /></div> | |||
| } | |||
| const Link = ({ node, children, ...props }: any) => { | |||
| if (node.properties?.href && node.properties.href?.toString().startsWith('abbr')) { | |||
| // eslint-disable-next-line react-hooks/rules-of-hooks | |||
| const { onSend } = useChatContext() | |||
| const hidden_text = decodeURIComponent(node.properties.href.toString().split('abbr:')[1]) | |||
| return <abbr className="cursor-pointer underline !decoration-primary-700 decoration-dashed" onClick={() => onSend?.(hidden_text)} title={node.children[0]?.value || ''}>{node.children[0]?.value || ''}</abbr> | |||
| } | |||
| else { | |||
| return <a {...props} target="_blank" className="cursor-pointer underline !decoration-primary-700 decoration-dashed">{children || 'Download'}</a> | |||
| } | |||
| } | |||
| export function Markdown(props: { content: string; className?: string; customDisallowedElements?: string[] }) { | |||
| const latexContent = flow([ | |||
| preprocessThinkTag, | |||
| preprocessLaTeX, | |||
| ])(props.content) | |||
| return ( | |||
| <div className={cn('markdown-body', '!text-text-primary', props.className)}> | |||
| <ReactMarkdown | |||
| remarkPlugins={[ | |||
| RemarkGfm, | |||
| [RemarkMath, { singleDollarTextMath: false }], | |||
| RemarkBreaks, | |||
| ]} | |||
| rehypePlugins={[ | |||
| RehypeKatex, | |||
| RehypeRaw as any, | |||
| // The Rehype plug-in is used to remove the ref attribute of an element | |||
| () => { | |||
| return (tree) => { | |||
| const iterate = (node: any) => { | |||
| if (node.type === 'element' && node.properties?.ref) | |||
| delete node.properties.ref | |||
| if (node.type === 'element' && !/^[a-z][a-z0-9]*$/i.test(node.tagName)) { | |||
| node.type = 'text' | |||
| node.value = `<${node.tagName}` | |||
| } | |||
| if (node.children) | |||
| node.children.forEach(iterate) | |||
| } | |||
| tree.children.forEach(iterate) | |||
| } | |||
| }, | |||
| ]} | |||
| disallowedElements={['iframe', 'head', 'html', 'meta', 'link', 'style', 'body', ...(props.customDisallowedElements || [])]} | |||
| components={{ | |||
| code: CodeBlock, | |||
| img: Img, | |||
| video: VideoBlock, | |||
| audio: AudioBlock, | |||
| a: Link, | |||
| p: Paragraph, | |||
| button: MarkdownButton, | |||
| form: MarkdownForm, | |||
| script: ScriptBlock as any, | |||
| details: ThinkBlock, | |||
| }} | |||
| > | |||
| {/* Markdown detect has problem. */} | |||
| {latexContent} | |||
| </ReactMarkdown> | |||
| </div> | |||
| ) | |||
| } | |||
| // **Add an ECharts runtime error handler | |||
| // Avoid error #7832 (Crash when ECharts accesses undefined objects) | |||
| // This can happen when a component attempts to access an undefined object that references an unregistered map, causing the program to crash. | |||
| export default class ErrorBoundary extends Component { | |||
| constructor(props: any) { | |||
| super(props) | |||
| this.state = { hasError: false } | |||
| } | |||
| componentDidCatch(error: any, errorInfo: any) { | |||
| this.setState({ hasError: true }) | |||
| console.error(error, errorInfo) | |||
| } | |||
| render() { | |||
| // eslint-disable-next-line ts/ban-ts-comment | |||
| // @ts-expect-error | |||
| if (this.state.hasError) | |||
| return <div>Oops! An error occurred. This could be due to an ECharts runtime error or invalid SVG content. <br />(see the browser console for more information)</div> | |||
| // eslint-disable-next-line ts/ban-ts-comment | |||
| // @ts-expect-error | |||
| return this.props.children | |||
| } | |||
| } | |||
| export default CodeBlock | |||
| @@ -0,0 +1,13 @@ | |||
| /** | |||
| * @fileoverview Img component for rendering <img> tags in Markdown. | |||
| * Extracted from the main markdown renderer for modularity. | |||
| * Uses the ImageGallery component to display images. | |||
| */ | |||
| import React from 'react' | |||
| import ImageGallery from '@/app/components/base/image-gallery' | |||
| const Img = ({ src }: any) => { | |||
| return <div className="markdown-img-wrapper"><ImageGallery srcs={[src]} /></div> | |||
| } | |||
| export default Img | |||
| @@ -0,0 +1,18 @@ | |||
| /** | |||
| * @fileoverview Barrel file for all markdown block components. | |||
| * This allows for cleaner imports in other parts of the application. | |||
| */ | |||
| export { default as AudioBlock } from './audio-block' | |||
| export { default as CodeBlock } from './code-block' | |||
| export { default as Img } from './img' | |||
| export { default as Link } from './link' | |||
| export { default as Paragraph } from './paragraph' | |||
| export { default as PreCode } from './pre-code' | |||
| export { default as ScriptBlock } from './script-block' | |||
| export { default as VideoBlock } from './video-block' | |||
| // Assuming these are also standalone components in this directory intended for Markdown rendering | |||
| export { default as MarkdownButton } from './button' | |||
| export { default as MarkdownForm } from './form' | |||
| export { default as ThinkBlock } from './think-block' | |||
| @@ -0,0 +1,21 @@ | |||
| /** | |||
| * @fileoverview Link component for rendering <a> tags in Markdown. | |||
| * Extracted from the main markdown renderer for modularity. | |||
| * Handles special rendering for "abbr:" type links for interactive chat actions. | |||
| */ | |||
| import React from 'react' | |||
| import { useChatContext } from '@/app/components/base/chat/chat/context' | |||
| const Link = ({ node, children, ...props }: any) => { | |||
| const { onSend } = useChatContext() | |||
| if (node.properties?.href && node.properties.href?.toString().startsWith('abbr')) { | |||
| const hidden_text = decodeURIComponent(node.properties.href.toString().split('abbr:')[1]) | |||
| return <abbr className="cursor-pointer underline !decoration-primary-700 decoration-dashed" onClick={() => onSend?.(hidden_text)} title={node.children[0]?.value || ''}>{node.children[0]?.value || ''}</abbr> | |||
| } | |||
| else { | |||
| return <a {...props} target="_blank" className="cursor-pointer underline !decoration-primary-700 decoration-dashed">{children || 'Download'}</a> | |||
| } | |||
| } | |||
| export default Link | |||
| @@ -0,0 +1,27 @@ | |||
| /** | |||
| * @fileoverview Paragraph component for rendering <p> tags in Markdown. | |||
| * Extracted from the main markdown renderer for modularity. | |||
| * Handles special rendering for paragraphs that directly contain an image. | |||
| */ | |||
| import React from 'react' | |||
| import ImageGallery from '@/app/components/base/image-gallery' | |||
| const Paragraph = (paragraph: any) => { | |||
| const { node }: any = paragraph | |||
| const children_node = node.children | |||
| if (children_node && children_node[0] && 'tagName' in children_node[0] && children_node[0].tagName === 'img') { | |||
| return ( | |||
| <div className="markdown-img-wrapper"> | |||
| <ImageGallery srcs={[children_node[0].properties.src]} /> | |||
| { | |||
| Array.isArray(paragraph.children) && paragraph.children.length > 1 && ( | |||
| <div className="mt-2">{paragraph.children.slice(1)}</div> | |||
| ) | |||
| } | |||
| </div> | |||
| ) | |||
| } | |||
| return <p>{paragraph.children}</p> | |||
| } | |||
| export default Paragraph | |||
| @@ -0,0 +1,21 @@ | |||
| /** | |||
| * @fileoverview PreCode component for rendering <pre> tags in Markdown. | |||
| * Extracted from the main markdown renderer for modularity. | |||
| * This is a simple wrapper around the HTML <pre> element. | |||
| */ | |||
| import React, { useRef } from 'react' | |||
| function PreCode(props: { children: any }) { | |||
| const ref = useRef<HTMLPreElement>(null) | |||
| return ( | |||
| <pre ref={ref}> | |||
| <span | |||
| className="copy-code-button" | |||
| ></span> | |||
| {props.children} | |||
| </pre> | |||
| ) | |||
| } | |||
| export default PreCode | |||
| @@ -0,0 +1,15 @@ | |||
| /** | |||
| * @fileoverview ScriptBlock component for handling <script> tags in Markdown. | |||
| * Extracted from the main markdown renderer for modularity. | |||
| * Note: Current implementation returns the script tag as a string, which might not execute as expected in React. | |||
| * This behavior is preserved from the original implementation and may need review for security and functionality. | |||
| */ | |||
| import { memo } from 'react' | |||
| const ScriptBlock = memo(({ node }: any) => { | |||
| const scriptContent = node.children[0]?.value || '' | |||
| return `<script>${scriptContent}</script>` | |||
| }) | |||
| ScriptBlock.displayName = 'ScriptBlock' | |||
| export default ScriptBlock | |||
| @@ -0,0 +1,21 @@ | |||
| /** | |||
| * @fileoverview VideoBlock component for rendering video elements in Markdown. | |||
| * Extracted from the main markdown renderer for modularity. | |||
| * Uses the VideoGallery component to display videos. | |||
| */ | |||
| import React, { memo } from 'react' | |||
| import VideoGallery from '@/app/components/base/video-gallery' | |||
| const VideoBlock: any = memo(({ node }: any) => { | |||
| const srcs = node.children.filter((child: any) => 'properties' in child).map((child: any) => (child as any).properties.src) | |||
| if (srcs.length === 0) { | |||
| const src = node.properties?.src | |||
| if (src) | |||
| return <VideoGallery key={src} srcs={[src]} /> | |||
| return null | |||
| } | |||
| return <VideoGallery key={srcs.join()} srcs={srcs} /> | |||
| }) | |||
| VideoBlock.displayName = 'VideoBlock' | |||
| export default VideoBlock | |||
| @@ -0,0 +1,33 @@ | |||
| /** | |||
| * @fileoverview ErrorBoundary component for React. | |||
| * This component was extracted from the main markdown renderer. | |||
| * It catches JavaScript errors anywhere in its child component tree, | |||
| * logs those errors, and displays a fallback UI instead of the crashed component tree. | |||
| * Primarily used around complex rendering logic like ECharts or SVG within Markdown. | |||
| */ | |||
| import React, { Component } from 'react' | |||
| // **Add an ECharts runtime error handler | |||
| // Avoid error #7832 (Crash when ECharts accesses undefined objects) | |||
| // This can happen when a component attempts to access an undefined object that references an unregistered map, causing the program to crash. | |||
| export default class ErrorBoundary extends Component { | |||
| constructor(props: any) { | |||
| super(props) | |||
| this.state = { hasError: false } | |||
| } | |||
| componentDidCatch(error: any, errorInfo: any) { | |||
| this.setState({ hasError: true }) | |||
| console.error(error, errorInfo) | |||
| } | |||
| render() { | |||
| // eslint-disable-next-line ts/ban-ts-comment | |||
| // @ts-expect-error | |||
| if (this.state.hasError) | |||
| return <div>Oops! An error occurred. This could be due to an ECharts runtime error or invalid SVG content. <br />(see the browser console for more information)</div> | |||
| // eslint-disable-next-line ts/ban-ts-comment | |||
| // @ts-expect-error | |||
| return this.props.children | |||
| } | |||
| } | |||
| @@ -0,0 +1,87 @@ | |||
| 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 RehypeRaw from 'rehype-raw' | |||
| import { flow } from 'lodash-es' | |||
| import cn from '@/utils/classnames' | |||
| import { preprocessLaTeX, preprocessThinkTag } from './markdown-utils' | |||
| import { | |||
| AudioBlock, | |||
| CodeBlock, | |||
| Img, | |||
| Link, | |||
| MarkdownButton, | |||
| MarkdownForm, | |||
| Paragraph, | |||
| ScriptBlock, | |||
| ThinkBlock, | |||
| VideoBlock, | |||
| } from '@/app/components/base/markdown-blocks' | |||
| /** | |||
| * @fileoverview Main Markdown rendering component. | |||
| * This file was refactored to extract individual block renderers and utility functions | |||
| * into separate modules for better organization and maintainability as of [Date of refactor]. | |||
| * Further refactoring candidates (custom block components not fitting general categories) | |||
| * are noted in their respective files if applicable. | |||
| */ | |||
| export function Markdown(props: { content: string; className?: string; customDisallowedElements?: string[] }) { | |||
| const latexContent = flow([ | |||
| preprocessThinkTag, | |||
| preprocessLaTeX, | |||
| ])(props.content) | |||
| return ( | |||
| <div className={cn('markdown-body', '!text-text-primary', props.className)}> | |||
| <ReactMarkdown | |||
| remarkPlugins={[ | |||
| RemarkGfm, | |||
| [RemarkMath, { singleDollarTextMath: false }], | |||
| RemarkBreaks, | |||
| ]} | |||
| rehypePlugins={[ | |||
| RehypeKatex, | |||
| RehypeRaw as any, | |||
| // The Rehype plug-in is used to remove the ref attribute of an element | |||
| () => { | |||
| return (tree: any) => { | |||
| const iterate = (node: any) => { | |||
| if (node.type === 'element' && node.properties?.ref) | |||
| delete node.properties.ref | |||
| if (node.type === 'element' && !/^[a-z][a-z0-9]*$/i.test(node.tagName)) { | |||
| node.type = 'text' | |||
| node.value = `<${node.tagName}` | |||
| } | |||
| if (node.children) | |||
| node.children.forEach(iterate) | |||
| } | |||
| tree.children.forEach(iterate) | |||
| } | |||
| }, | |||
| ]} | |||
| disallowedElements={['iframe', 'head', 'html', 'meta', 'link', 'style', 'body', ...(props.customDisallowedElements || [])]} | |||
| components={{ | |||
| code: CodeBlock, | |||
| img: Img, | |||
| video: VideoBlock, | |||
| audio: AudioBlock, | |||
| a: Link, | |||
| p: Paragraph, | |||
| button: MarkdownButton, | |||
| form: MarkdownForm, | |||
| script: ScriptBlock as any, | |||
| details: ThinkBlock, | |||
| }} | |||
| > | |||
| {/* Markdown detect has problem. */} | |||
| {latexContent} | |||
| </ReactMarkdown> | |||
| </div> | |||
| ) | |||
| } | |||
| @@ -0,0 +1,37 @@ | |||
| /** | |||
| * @fileoverview Utility functions for preprocessing Markdown content. | |||
| * These functions were extracted from the main markdown renderer for better separation of concerns. | |||
| * Includes preprocessing for LaTeX and custom "think" tags. | |||
| */ | |||
| import { flow } from 'lodash-es' | |||
| export const preprocessLaTeX = (content: string) => { | |||
| if (typeof content !== 'string') | |||
| return content | |||
| const codeBlockRegex = /```[\s\S]*?```/g | |||
| const codeBlocks = content.match(codeBlockRegex) || [] | |||
| let processedContent = content.replace(codeBlockRegex, 'CODE_BLOCK_PLACEHOLDER') | |||
| processedContent = flow([ | |||
| (str: string) => str.replace(/\\\[(.*?)\\\]/g, (_, equation) => `$$${equation}$$`), | |||
| (str: string) => str.replace(/\\\[([\s\S]*?)\\\]/g, (_, equation) => `$$${equation}$$`), | |||
| (str: string) => str.replace(/\\\((.*?)\\\)/g, (_, equation) => `$$${equation}$$`), | |||
| (str: string) => str.replace(/(^|[^\\])\$(.+?)\$/g, (_, prefix, equation) => `${prefix}$${equation}$`), | |||
| ])(processedContent) | |||
| codeBlocks.forEach((block) => { | |||
| processedContent = processedContent.replace('CODE_BLOCK_PLACEHOLDER', block) | |||
| }) | |||
| return processedContent | |||
| } | |||
| export const preprocessThinkTag = (content: string) => { | |||
| const thinkOpenTagRegex = /<think>\n/g | |||
| const thinkCloseTagRegex = /\n<\/think>/g | |||
| return flow([ | |||
| (str: string) => str.replace(thinkOpenTagRegex, '<details data-think=true>\n'), | |||
| (str: string) => str.replace(thinkCloseTagRegex, '\n[ENDTHINKFLAG]</details>'), | |||
| ])(content) | |||
| } | |||