| | |
| | | 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 { atelierHeathLight } from 'react-syntax-highlighter/dist/esm/styles/hljs' |
| | | import { Component, memo, 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 type { CodeComponent } from 'react-markdown/lib/ast-to-react' |
| | | import cn from '@/utils/classnames' |
| | | import CopyBtn from '@/app/components/base/copy-btn' |
| | | 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 SVGRenderer from '@/app/components/base/svg-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' |
| | | |
| | | // Available language https://github.com/react-syntax-highlighter/react-syntax-highlighter/blob/master/AVAILABLE_LANGUAGES_HLJS.MD |
| | | const capitalizationLanguageNameMap: Record<string, string> = { |
| | |
| | | json: 'JSON', |
| | | latex: 'Latex', |
| | | svg: 'SVG', |
| | | abc: 'ABC', |
| | | } |
| | | const getCorrectCapitalizationLanguageName = (language: string) => { |
| | | if (!language) |
| | |
| | | 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(/\\\[(.*?)\\\]/gs, (_, 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) |
| | | return content.replace(/\\\[(.*?)\\\]/g, (_, equation) => `$$${equation}$$`) |
| | | .replace(/\\\((.*?)\\\)/g, (_, equation) => `$$${equation}$$`) |
| | | .replace(/(^|[^\\])\$(.+?)\$/g, (_, prefix, equation) => `${prefix}$${equation}$`) |
| | | } |
| | | |
| | | export function PreCode(props: { children: any }) { |
| | |
| | | // visit https://reactjs.org/docs/error-decoder.html?invariant=185 for the full message |
| | | // or use the non-minified dev environment for full errors and additional helpful warnings. |
| | | |
| | | const CodeBlock: any = memo(({ inline, className, children = '', ...props }: any) => { |
| | | const { theme } = useTheme() |
| | | const CodeBlock: CodeComponent = memo(({ inline, className, children, ...props }) => { |
| | | const [isSVG, setIsSVG] = useState(true) |
| | | const match = /language-(\w+)/.exec(className || '') |
| | | const language = match?.[1] |
| | | const languageShowName = getCorrectCapitalizationLanguageName(language || '') |
| | | const chartData = useMemo(() => { |
| | | const str = String(children).replace(/\n$/, '') |
| | | if (language === 'echarts') { |
| | | try { |
| | | return JSON.parse(str) |
| | | return JSON.parse(String(children).replace(/\n$/, '')) |
| | | } |
| | | catch { } |
| | | try { |
| | | // eslint-disable-next-line no-new-func, sonarjs/code-eval |
| | | return new Function(`return ${str}`)() |
| | | } |
| | | catch { } |
| | | catch (error) { } |
| | | } |
| | | return JSON.parse('{"title":{"text":"ECharts error - Wrong option."}}') |
| | | return JSON.parse('{"title":{"text":"ECharts error - Wrong JSON format."}}') |
| | | }, [language, children]) |
| | | |
| | | const renderCodeContent = useMemo(() => { |
| | | const content = String(children).replace(/\n$/, '') |
| | | switch (language) { |
| | | case 'mermaid': |
| | | if (isSVG) |
| | | return <Flowchart PrimitiveCode={content} /> |
| | | break |
| | | case 'echarts': |
| | | return ( |
| | | <div style={{ minHeight: '350px', minWidth: '100%', overflowX: 'scroll' }}> |
| | | <ErrorBoundary> |
| | | <ReactEcharts option={chartData} style={{ minWidth: '700px' }} /> |
| | | </ErrorBoundary> |
| | | </div> |
| | | ) |
| | | case 'svg': |
| | | if (isSVG) { |
| | | return ( |
| | | <ErrorBoundary> |
| | | <SVGRenderer content={content} /> |
| | | </ErrorBoundary> |
| | | ) |
| | | } |
| | | break |
| | | case 'abc': |
| | | return ( |
| | | <ErrorBoundary> |
| | | <MarkdownMusic children={content} /> |
| | | </ErrorBoundary> |
| | | ) |
| | | default: |
| | | return ( |
| | | <SyntaxHighlighter |
| | | {...props} |
| | | style={theme === Theme.light ? atelierHeathLight : atelierHeathDark} |
| | | customStyle={{ |
| | | paddingLeft: 12, |
| | | borderBottomLeftRadius: '10px', |
| | | borderBottomRightRadius: '10px', |
| | | backgroundColor: 'var(--color-components-input-bg-normal)', |
| | | }} |
| | | language={match?.[1]} |
| | | showLineNumbers |
| | | PreTag="div" |
| | | > |
| | | {content} |
| | | </SyntaxHighlighter> |
| | | ) |
| | | if (language === 'mermaid' && isSVG) { |
| | | return <Flowchart PrimitiveCode={content} /> |
| | | } |
| | | }, [children, language, isSVG, chartData, props, theme, match]) |
| | | else if (language === 'echarts') { |
| | | return ( |
| | | <div style={{ minHeight: '350px', minWidth: '100%', overflowX: 'scroll' }}> |
| | | <ErrorBoundary> |
| | | <ReactEcharts option={chartData} style={{ minWidth: '700px' }} /> |
| | | </ErrorBoundary> |
| | | </div> |
| | | ) |
| | | } |
| | | else if (language === 'svg' && isSVG) { |
| | | return ( |
| | | <ErrorBoundary> |
| | | <SVGRenderer content={content} /> |
| | | </ErrorBoundary> |
| | | ) |
| | | } |
| | | else { |
| | | return ( |
| | | <SyntaxHighlighter |
| | | {...props} |
| | | style={atelierHeathLight} |
| | | customStyle={{ |
| | | paddingLeft: 12, |
| | | backgroundColor: '#fff', |
| | | }} |
| | | language={match?.[1]} |
| | | showLineNumbers |
| | | PreTag="div" |
| | | > |
| | | {content} |
| | | </SyntaxHighlighter> |
| | | ) |
| | | } |
| | | }, [language, match, props, children, chartData, isSVG]) |
| | | |
| | | if (inline || !match) |
| | | return <code {...props} className={className}>{children}</code> |
| | | |
| | | return ( |
| | | <div className='relative'> |
| | | <div className='flex h-8 items-center justify-between rounded-t-[10px] border-b border-divider-subtle bg-components-input-bg-normal p-1 pl-3'> |
| | | <div className='system-xs-semibold-uppercase text-text-secondary'>{languageShowName}</div> |
| | | <div className='flex items-center gap-1'> |
| | | <div> |
| | | <div |
| | | className='flex justify-between h-8 items-center p-1 pl-3 border-b' |
| | | style={{ |
| | | borderColor: 'rgba(0, 0, 0, 0.05)', |
| | | }} |
| | | > |
| | | <div className='text-[13px] text-gray-500 font-normal'>{languageShowName}</div> |
| | | <div style={{ display: 'flex' }}> |
| | | {(['mermaid', 'svg']).includes(language!) && <SVGBtn isSVG={isSVG} setIsSVG={setIsSVG} />} |
| | | <ActionButton> |
| | | <CopyIcon content={String(children).replace(/\n$/, '')} /> |
| | | </ActionButton> |
| | | <CopyBtn |
| | | className='mr-1' |
| | | value={String(children).replace(/\n$/, '')} |
| | | isPlain |
| | | /> |
| | | </div> |
| | | </div> |
| | | {renderCodeContent} |
| | |
| | | }) |
| | | 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) |
| | | const VideoBlock: CodeComponent = memo(({ node }) => { |
| | | const srcs = node.children.filter(child => 'properties' in child).map(child => (child as any).properties.src) |
| | | if (srcs.length === 0) |
| | | 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) |
| | | const AudioBlock: CodeComponent = memo(({ node }) => { |
| | | const srcs = node.children.filter(child => 'properties' in child).map(child => (child as any).properties.src) |
| | | if (srcs.length === 0) |
| | | return null |
| | | return <AudioGallery key={srcs.join()} srcs={srcs} /> |
| | |
| | | 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> |
| | | <p>{paragraph.children.slice(1)}</p> |
| | | </> |
| | | ) |
| | | } |
| | | return <p>{paragraph.children}</p> |
| | | } |
| | | |
| | | const Img = ({ src }: any) => { |
| | | return <div className="markdown-img-wrapper"><ImageGallery srcs={[src]} /></div> |
| | | return (<ImageGallery srcs={[src]} />) |
| | | } |
| | | |
| | | const Link = ({ node, children, ...props }: any) => { |
| | | const Link = ({ node, ...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> |
| | | return <abbr className="underline decoration-dashed !decoration-primary-700 cursor-pointer" 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> |
| | | return <a {...props} target="_blank" className="underline decoration-dashed !decoration-primary-700 cursor-pointer">{node.children[0] ? node.children[0]?.value : 'Download'}</a> |
| | | } |
| | | } |
| | | |
| | | export function Markdown(props: { content: string; className?: string; customDisallowedElements?: string[] }) { |
| | | const latexContent = flow([ |
| | | preprocessThinkTag, |
| | | preprocessLaTeX, |
| | | ])(props.content) |
| | | |
| | | export function Markdown(props: { content: string; className?: string }) { |
| | | const latexContent = preprocessLaTeX(props.content) |
| | | return ( |
| | | <div className={cn('markdown-body', '!text-text-primary', props.className)}> |
| | | <div className={cn(props.className, 'markdown-body')}> |
| | | <ReactMarkdown |
| | | remarkPlugins={[ |
| | | RemarkGfm, |
| | |
| | | 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) |
| | | } |
| | |
| | | } |
| | | }, |
| | | ]} |
| | | disallowedElements={['iframe', 'head', 'html', 'meta', 'link', 'style', 'body', ...(props.customDisallowedElements || [])]} |
| | | disallowedElements={['iframe', 'head', 'html', 'meta', 'link', 'style', 'body']} |
| | | components={{ |
| | | code: CodeBlock, |
| | | img: Img, |
| | |
| | | p: Paragraph, |
| | | button: MarkdownButton, |
| | | form: MarkdownForm, |
| | | script: ScriptBlock as any, |
| | | details: ThinkBlock, |
| | | script: ScriptBlock, |
| | | }} |
| | | linkTarget='_blank' |
| | | > |
| | | {/* Markdown detect has problem. */} |
| | | {latexContent} |
| | |
| | | } |
| | | |
| | | render() { |
| | | // eslint-disable-next-line ts/ban-ts-comment |
| | | // eslint-disable-next-line @typescript-eslint/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 |
| | | // eslint-disable-next-line @typescript-eslint/ban-ts-comment |
| | | // @ts-expect-error |
| | | return this.props.children |
| | | } |