This commit is contained in:
2025-12-01 17:21:38 +08:00
parent 32fee2b8ab
commit fab8c13cb3
7511 changed files with 996300 additions and 0 deletions

View File

@@ -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
}
}

View File

@@ -0,0 +1,88 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { useState } from 'react'
import { Markdown } from '.'
const SAMPLE_MD = `
# Product Update
Our agent now supports **tool-runs** with structured outputs.
## Highlights
- Faster reasoning with \\(O(n \\log n)\\) planning.
- Inline chain-of-thought:
<details data-think>
<summary>Thinking aloud</summary>
Check cached metrics first.
If missing, fetch raw warehouse data.
[ENDTHINKFLAG]
</details>
## Mermaid Diagram
\`\`\`mermaid
graph TD
Start[User Message] --> Parse{Detect Intent?}
Parse -->|Tool| ToolCall[Call search tool]
Parse -->|Answer| Respond[Stream response]
ToolCall --> Respond
\`\`\`
## Code Example
\`\`\`typescript
const reply = await client.chat({
message: 'Summarise weekly metrics.',
tags: ['analytics'],
})
\`\`\`
`
const MarkdownDemo = ({
compact = false,
}: {
compact?: boolean
}) => {
const [content] = useState(SAMPLE_MD.trim())
return (
<div className="flex w-full max-w-3xl flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
<div className="text-xs uppercase tracking-[0.18em] text-text-tertiary">Markdown renderer</div>
<Markdown
content={content}
className={compact ? '!text-sm leading-relaxed' : ''}
/>
</div>
)
}
const meta = {
title: 'Base/Data Display/Markdown',
component: MarkdownDemo,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Markdown wrapper with GitHub-flavored markdown, Mermaid diagrams, math, and custom blocks (details, audio, etc.).',
},
},
},
argTypes: {
compact: { control: 'boolean' },
},
args: {
compact: false,
},
tags: ['autodocs'],
} satisfies Meta<typeof MarkdownDemo>
export default meta
type Story = StoryObj<typeof meta>
export const Playground: Story = {}
export const Compact: Story = {
args: {
compact: true,
},
}

View File

@@ -0,0 +1,35 @@
import dynamic from 'next/dynamic'
import 'katex/dist/katex.min.css'
import { flow } from 'lodash-es'
import cn from '@/utils/classnames'
import { preprocessLaTeX, preprocessThinkTag } from './markdown-utils'
import type { ReactMarkdownWrapperProps, SimplePluginInfo } from './react-markdown-wrapper'
const ReactMarkdown = dynamic(() => import('./react-markdown-wrapper').then(mod => mod.ReactMarkdownWrapper), { ssr: false })
/**
* @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 type MarkdownProps = {
content: string
className?: string
pluginInfo?: SimplePluginInfo
} & Pick<ReactMarkdownWrapperProps, 'customComponents' | 'customDisallowedElements'>
export const Markdown = (props: MarkdownProps) => {
const { customComponents = {}, pluginInfo } = props
const latexContent = flow([
preprocessThinkTag,
preprocessLaTeX,
])(props.content)
return (
<div className={cn('markdown-body', '!text-text-primary', props.className)}>
<ReactMarkdown pluginInfo={pluginInfo} latexContent={latexContent} customComponents={customComponents} customDisallowedElements={props.customDisallowedElements} />
</div>
)
}

View File

@@ -0,0 +1,94 @@
/**
* @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'
import { ALLOW_UNSAFE_DATA_SCHEME } from '@/config'
export const preprocessLaTeX = (content: string) => {
if (typeof content !== 'string')
return content
const codeBlockRegex = /```[\s\S]*?```/g
const codeBlocks = content.match(codeBlockRegex) || []
const escapeReplacement = (str: string) => str.replace(/\$/g, '_TMP_REPLACE_DOLLAR_')
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', escapeReplacement(block))
})
processedContent = processedContent.replace(/_TMP_REPLACE_DOLLAR_/g, '$')
return processedContent
}
export const preprocessThinkTag = (content: string) => {
const thinkOpenTagRegex = /(<think>\s*)+/g
const thinkCloseTagRegex = /(\s*<\/think>)+/g
return flow([
(str: string) => str.replace(thinkOpenTagRegex, '<details data-think=true>\n'),
(str: string) => str.replace(thinkCloseTagRegex, '\n[ENDTHINKFLAG]</details>'),
(str: string) => str.replace(/(<\/details>)(?![^\S\r\n]*[\r\n])(?![^\S\r\n]*$)/g, '$1\n'),
])(content)
}
/**
* Transforms a URI for use in react-markdown, ensuring security and compatibility.
* This function is designed to work with react-markdown v9+ which has stricter
* default URL handling.
*
* Behavior:
* 1. Always allows the custom 'abbr:' protocol.
* 2. Always allows page-local fragments (e.g., "#some-id").
* 3. Always allows protocol-relative URLs (e.g., "//example.com/path").
* 4. Always allows purely relative paths (e.g., "path/to/file", "/abs/path").
* 5. Allows absolute URLs if their scheme is in a permitted list (case-insensitive):
* 'http:', 'https:', 'mailto:', 'xmpp:', 'irc:', 'ircs:'.
* 6. Intelligently distinguishes colons used for schemes from colons within
* paths, query parameters, or fragments of relative-like URLs.
* 7. Returns the original URI if allowed, otherwise returns `undefined` to
* signal that the URI should be removed/disallowed by react-markdown.
*/
export const customUrlTransform = (uri: string): string | undefined => {
const PERMITTED_SCHEME_REGEX = /^(https?|ircs?|mailto|xmpp|abbr):$/i
if (uri.startsWith('#'))
return uri
if (uri.startsWith('//'))
return uri
const colonIndex = uri.indexOf(':')
if (colonIndex === -1)
return uri
const slashIndex = uri.indexOf('/')
const questionMarkIndex = uri.indexOf('?')
const hashIndex = uri.indexOf('#')
if (
(slashIndex !== -1 && colonIndex > slashIndex)
|| (questionMarkIndex !== -1 && colonIndex > questionMarkIndex)
|| (hashIndex !== -1 && colonIndex > hashIndex)
)
return uri
const scheme = uri.substring(0, colonIndex + 1).toLowerCase()
if (PERMITTED_SCHEME_REGEX.test(scheme))
return uri
if (ALLOW_UNSAFE_DATA_SCHEME && scheme === 'data:')
return uri
return undefined
}

View File

@@ -0,0 +1,79 @@
import { AudioBlock, Img, Link, MarkdownButton, MarkdownForm, Paragraph, PluginImg, PluginParagraph, ScriptBlock, ThinkBlock, VideoBlock } from '@/app/components/base/markdown-blocks'
import { ENABLE_SINGLE_DOLLAR_LATEX } from '@/config'
import dynamic from 'next/dynamic'
import type { FC } from 'react'
import ReactMarkdown from 'react-markdown'
import RehypeKatex from 'rehype-katex'
import RehypeRaw from 'rehype-raw'
import RemarkBreaks from 'remark-breaks'
import RemarkGfm from 'remark-gfm'
import RemarkMath from 'remark-math'
import { customUrlTransform } from './markdown-utils'
const CodeBlock = dynamic(() => import('@/app/components/base/markdown-blocks/code-block'), { ssr: false })
export type SimplePluginInfo = {
pluginUniqueIdentifier: string
pluginId: string
}
export type ReactMarkdownWrapperProps = {
latexContent: any
customDisallowedElements?: string[]
customComponents?: Record<string, React.ComponentType<any>>
pluginInfo?: SimplePluginInfo
}
export const ReactMarkdownWrapper: FC<ReactMarkdownWrapperProps> = (props) => {
const { customComponents, latexContent, pluginInfo } = props
return (
<ReactMarkdown
remarkPlugins={[
RemarkGfm,
[RemarkMath, { singleDollarTextMath: ENABLE_SINGLE_DOLLAR_LATEX }],
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)
}
},
]}
urlTransform={customUrlTransform}
disallowedElements={['iframe', 'head', 'html', 'meta', 'link', 'style', 'body', ...(props.customDisallowedElements || [])]}
components={{
code: CodeBlock,
img: (props: any) => pluginInfo ? <PluginImg {...props} pluginInfo={pluginInfo} /> : <Img {...props} />,
video: VideoBlock,
audio: AudioBlock,
a: Link,
p: (props: any) => pluginInfo ? <PluginParagraph {...props} pluginInfo={pluginInfo} /> : <Paragraph {...props} />,
button: MarkdownButton,
form: MarkdownForm,
script: ScriptBlock as any,
details: ThinkBlock,
...customComponents,
}}
>
{/* Markdown detect has problem. */}
{latexContent}
</ReactMarkdown>
)
}