dify
This commit is contained in:
33
dify/web/app/components/base/markdown/error-boundary.tsx
Normal file
33
dify/web/app/components/base/markdown/error-boundary.tsx
Normal 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
|
||||
}
|
||||
}
|
||||
88
dify/web/app/components/base/markdown/index.stories.tsx
Normal file
88
dify/web/app/components/base/markdown/index.stories.tsx
Normal 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,
|
||||
},
|
||||
}
|
||||
35
dify/web/app/components/base/markdown/index.tsx
Normal file
35
dify/web/app/components/base/markdown/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
94
dify/web/app/components/base/markdown/markdown-utils.ts
Normal file
94
dify/web/app/components/base/markdown/markdown-utils.ts
Normal 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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user