Compare commits

...

4 Commits

23 changed files with 324 additions and 417 deletions

View File

@@ -295,7 +295,13 @@ const AudioPlayer: React.FC<AudioPlayerProps> = ({ src, srcs }) => {
<source key={index} src={srcUrl} />
))}
</audio>
<button type="button" data-testid="play-pause-btn" className="inline-flex shrink-0 cursor-pointer items-center justify-center border-none text-text-accent transition-all hover:text-text-accent-secondary disabled:text-components-button-primary-bg-disabled" onClick={togglePlay} disabled={!isAudioAvailable}>
<button
type="button"
data-testid="play-pause-btn"
className="inline-flex shrink-0 cursor-pointer items-center justify-center border-none text-text-accent transition-all hover:text-text-accent-secondary disabled:text-components-button-primary-bg-disabled"
onClick={togglePlay}
disabled={!isAudioAvailable}
>
{isPlaying
? (
<div className="i-ri-pause-circle-fill h-5 w-5" />

View File

@@ -158,7 +158,7 @@ const Answer: FC<AnswerProps> = ({
<div className={cn('group relative pr-10', chatAnswerContainerInner)}>
<div
ref={humanInputFormContainerRef}
className={cn('body-lg-regular relative inline-block w-full max-w-full rounded-2xl bg-chat-bubble-bg px-4 py-3 text-text-primary')}
className={cn('relative inline-block w-full max-w-full rounded-2xl bg-chat-bubble-bg px-4 py-3 text-text-primary body-lg-regular')}
>
{
!responding && contentIsEmpty && !hasAgentThoughts && (
@@ -227,7 +227,7 @@ const Answer: FC<AnswerProps> = ({
<div className="absolute -top-2 left-6 h-3 w-0.5 bg-chat-answer-human-input-form-divider-bg" />
<div
ref={contentRef}
className="body-lg-regular relative inline-block w-full max-w-full rounded-2xl bg-chat-bubble-bg px-4 py-3 text-text-primary"
className="relative inline-block w-full max-w-full rounded-2xl bg-chat-bubble-bg px-4 py-3 text-text-primary body-lg-regular"
>
{
!responding && (
@@ -322,7 +322,7 @@ const Answer: FC<AnswerProps> = ({
<div className={cn('group relative pr-10', chatAnswerContainerInner)}>
<div
ref={contentRef}
className={cn('body-lg-regular relative inline-block max-w-full rounded-2xl bg-chat-bubble-bg px-4 py-3 text-text-primary', workflowProcess && 'w-full')}
className={cn('relative inline-block max-w-full rounded-2xl bg-chat-bubble-bg px-4 py-3 text-text-primary body-lg-regular', workflowProcess && 'w-full')}
>
{
!responding && (

View File

@@ -332,8 +332,7 @@ const Chat: FC<ChatProps> = ({
!noStopResponding && isResponding && (
<div data-testid="stop-responding-container" className="mb-2 flex justify-center">
<Button className="border-components-panel-border bg-components-panel-bg text-components-button-secondary-text" onClick={onStopResponding}>
{/* eslint-disable-next-line tailwindcss/no-unknown-classes */}
<div className="i-custom-vender-solid-mediaanddevices-stop-circle mr-[5px] h-3.5 w-3.5" />
<span className="i-custom-vender-solid-mediaAndDevices-stop-circle mr-[5px] h-3.5 w-3.5" />
<span className="text-xs font-normal">{t('operation.stopResponding', { ns: 'appDebug' })}</span>
</Button>
</div>

View File

@@ -1,4 +1,5 @@
/* eslint-disable next/no-img-element */
import type { ExtraProps } from 'streamdown'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -25,13 +26,14 @@ vi.mock('@/app/components/base/image-uploader/image-preview', () => ({
}))
/**
* Interfaces to avoid 'any' and satisfy strict linting
* Helper to build a minimal hast-compatible Element node for testing.
* The runtime code only reads `node.children[*].tagName` and `.properties.src`,
* so we keep the mock minimal and cast to satisfy the full hast Element type.
*/
type MockNode = {
children?: Array<{
tagName?: string
properties?: { src?: string }
}>
type MockChild = { tagName?: string, properties?: { src?: string } }
function mockNode(children: MockChild[]): ExtraProps['node'] {
return { type: 'element', tagName: 'p', properties: {}, children } as unknown as ExtraProps['node']
}
type HookReturn = {
@@ -64,7 +66,7 @@ describe('PluginParagraph', () => {
})
it('should render a standard paragraph when not an image', () => {
const node: MockNode = { children: [{ tagName: 'span' }] }
const node = mockNode([{ tagName: 'span' }])
render(
<PluginParagraph node={node}>
Hello World
@@ -75,9 +77,7 @@ describe('PluginParagraph', () => {
})
it('should render an ImageGallery when the first child is an image', () => {
const node: MockNode = {
children: [{ tagName: 'img', properties: { src: 'test-img.png' } }],
}
const node = mockNode([{ tagName: 'img', properties: { src: 'test-img.png' } }])
vi.mocked(getMarkdownImageURL).mockReturnValue('https://cdn.com/test-img.png')
const { container } = render(
@@ -93,9 +93,7 @@ describe('PluginParagraph', () => {
})
it('should use a blob URL when asset data is successfully fetched', () => {
const node: MockNode = {
children: [{ tagName: 'img', properties: { src: 'test-img.png' } }],
}
const node = mockNode([{ tagName: 'img', properties: { src: 'test-img.png' } }])
const mockBlob = new Blob([''], { type: 'image/png' })
vi.mocked(usePluginReadmeAsset).mockReturnValue({
data: mockBlob,
@@ -114,12 +112,10 @@ describe('PluginParagraph', () => {
})
it('should render remaining children below the image gallery', () => {
const node: MockNode = {
children: [
{ tagName: 'img', properties: { src: 'test-img.png' } },
{ tagName: 'text' },
],
}
const node = mockNode([
{ tagName: 'img', properties: { src: 'test-img.png' } },
{ tagName: 'text' },
])
render(
<PluginParagraph pluginInfo={mockPluginInfo} node={node}>
@@ -132,9 +128,7 @@ describe('PluginParagraph', () => {
})
it('should revoke the blob URL on unmount to prevent memory leaks', () => {
const node: MockNode = {
children: [{ tagName: 'img', properties: { src: 'test-img.png' } }],
}
const node = mockNode([{ tagName: 'img', properties: { src: 'test-img.png' } }])
const mockBlob = new Blob([''], { type: 'image/png' })
vi.mocked(usePluginReadmeAsset).mockReturnValue({
data: mockBlob,
@@ -155,9 +149,7 @@ describe('PluginParagraph', () => {
it('should open the image preview modal when an image in the gallery is clicked', async () => {
const user = userEvent.setup()
const node: MockNode = {
children: [{ tagName: 'img', properties: { src: 'test-img.png' } }],
}
const node = mockNode([{ tagName: 'img', properties: { src: 'test-img.png' } }])
vi.mocked(getMarkdownImageURL).mockReturnValue('https://cdn.com/gallery.png')
const { container } = render(

View File

@@ -1,61 +0,0 @@
import { render, screen } from '@testing-library/react'
import * as React from 'react'
import { describe, expect, it } from 'vitest'
import PreCode from '../pre-code'
describe('PreCode Component', () => {
it('renders children correctly inside the pre tag', () => {
const { container } = render(
<PreCode>
<code data-testid="test-code">console.log("hello world")</code>
</PreCode>,
)
const preElement = container.querySelector('pre')
const codeElement = screen.getByTestId('test-code')
expect(preElement).toBeInTheDocument()
expect(codeElement).toBeInTheDocument()
// Verify code is a descendant of pre
expect(preElement).toContainElement(codeElement)
expect(codeElement.textContent).toBe('console.log("hello world")')
})
it('contains the copy button span for CSS targeting', () => {
const { container } = render(
<PreCode>
<code>test content</code>
</PreCode>,
)
const copySpan = container.querySelector('.copy-code-button')
expect(copySpan).toBeInTheDocument()
expect(copySpan?.tagName).toBe('SPAN')
})
it('renders as a <pre> element', () => {
const { container } = render(<PreCode>Content</PreCode>)
expect(container.querySelector('pre')).toBeInTheDocument()
})
it('handles multiple children correctly', () => {
render(
<PreCode>
<span>Line 1</span>
<span>Line 2</span>
</PreCode>,
)
expect(screen.getByText('Line 1')).toBeInTheDocument()
expect(screen.getByText('Line 2')).toBeInTheDocument()
})
it('correctly instantiates the pre element node', () => {
const { container } = render(<PreCode>Ref check</PreCode>)
const pre = container.querySelector('pre')
// Verifies the node is an actual HTMLPreElement,
// confirming the ref-linked element rendered correctly.
expect(pre).toBeInstanceOf(HTMLPreElement)
})
})

View File

@@ -1,69 +0,0 @@
import { cleanup, render } from '@testing-library/react'
import * as React from 'react'
import { afterEach, describe, expect, it } from 'vitest'
import ScriptBlock from '../script-block'
afterEach(() => {
cleanup()
})
type ScriptNode = {
children: Array<{ value?: string }>
}
describe('ScriptBlock', () => {
it('renders script tag string when child has value', () => {
const node: ScriptNode = {
children: [{ value: 'alert("hi")' }],
}
const { container } = render(
<ScriptBlock node={node} />,
)
expect(container.textContent).toBe('<script>alert("hi")</script>')
})
it('renders empty script tag when child value is undefined', () => {
const node: ScriptNode = {
children: [{}],
}
const { container } = render(
<ScriptBlock node={node} />,
)
expect(container.textContent).toBe('<script></script>')
})
it('renders empty script tag when children array is empty', () => {
const node: ScriptNode = {
children: [],
}
const { container } = render(
<ScriptBlock node={node} />,
)
expect(container.textContent).toBe('<script></script>')
})
it('preserves multiline script content', () => {
const multi = `console.log("line1");
console.log("line2");`
const node: ScriptNode = {
children: [{ value: multi }],
}
const { container } = render(
<ScriptBlock node={node} />,
)
expect(container.textContent).toBe(`<script>${multi}</script>`)
})
it('has displayName set correctly', () => {
expect(ScriptBlock.displayName).toBe('ScriptBlock')
})
})

View File

@@ -399,7 +399,6 @@ const CodeBlock: any = memo(({ inline, className, children = '', ...props }: any
}}
language={match?.[1]}
showLineNumbers
PreTag="div"
>
{content}
</SyntaxHighlighter>
@@ -413,7 +412,7 @@ const CodeBlock: any = memo(({ inline, className, children = '', ...props }: any
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="text-text-secondary system-xs-semibold-uppercase">{languageShowName}</div>
<div className="flex items-center gap-1">
{language === 'svg' && <SVGBtn isSVG={isSVG} setIsSVG={setIsSVG} />}
<ActionButton>

View File

@@ -3,11 +3,12 @@
* Extracted from the main markdown renderer for modularity.
* Uses the ImageGallery component to display images.
*/
import * as React from 'react'
import { memo, useMemo } from 'react'
import ImageGallery from '@/app/components/base/image-gallery'
const Img = ({ src }: any) => {
return <div className="markdown-img-wrapper"><ImageGallery srcs={[src]} /></div>
}
const Img = memo(({ src }: { src: string }) => {
const srcs = useMemo(() => [src], [src])
return <div className="markdown-img-wrapper"><ImageGallery srcs={srcs} /></div>
})
export default Img

View File

@@ -13,8 +13,6 @@ export { default as Link } from './link'
export { default as Paragraph } from './paragraph'
export * from './plugin-img'
export * from './plugin-paragraph'
export { default as PreCode } from './pre-code'
export { default as ScriptBlock } from './script-block'
export { default as ThinkBlock } from './think-block'
export { default as VideoBlock } from './video-block'

View File

@@ -4,8 +4,7 @@ import type { SimplePluginInfo } from '../markdown/react-markdown-wrapper'
* Extracted from the main markdown renderer for modularity.
* Uses the ImageGallery component to display images.
*/
import * as React from 'react'
import { useEffect, useMemo, useState } from 'react'
import { memo, useEffect, useMemo, useState } from 'react'
import ImageGallery from '@/app/components/base/image-gallery'
import { usePluginReadmeAsset } from '@/service/use-plugins'
import { getMarkdownImageURL } from './utils'
@@ -15,7 +14,7 @@ type ImgProps = {
pluginInfo?: SimplePluginInfo
}
export const PluginImg: React.FC<ImgProps> = ({ src, pluginInfo }) => {
export const PluginImg = memo<ImgProps>(({ src, pluginInfo }) => {
const { pluginUniqueIdentifier, pluginId } = pluginInfo || {}
const { data: assetData } = usePluginReadmeAsset({ plugin_unique_identifier: pluginUniqueIdentifier, file_name: src })
const [blobUrl, setBlobUrl] = useState<string>()
@@ -41,9 +40,11 @@ export const PluginImg: React.FC<ImgProps> = ({ src, pluginInfo }) => {
return getMarkdownImageURL(src, pluginId)
}, [blobUrl, pluginId, src])
const srcs = useMemo(() => [imageUrl], [imageUrl])
return (
<div className="markdown-img-wrapper">
<ImageGallery srcs={[imageUrl]} />
<ImageGallery srcs={srcs} />
</div>
)
}
})

View File

@@ -1,24 +1,30 @@
import type { SimplePluginInfo } from '../markdown/react-markdown-wrapper'
import * as React from 'react'
import { useEffect, useMemo, useState } from 'react'
/**
* @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 type { ExtraProps } from 'streamdown'
import type { SimplePluginInfo } from '../markdown/react-markdown-wrapper'
import * as React from 'react'
import { useEffect, useMemo, useState } from 'react'
import ImageGallery from '@/app/components/base/image-gallery'
import { usePluginReadmeAsset } from '@/service/use-plugins'
import { getMarkdownImageURL } from './utils'
type HastChildNode = {
tagName?: string
properties?: { src?: string, [key: string]: unknown }
}
type PluginParagraphProps = {
pluginInfo?: SimplePluginInfo
node?: any
node?: ExtraProps['node']
children?: React.ReactNode
}
export const PluginParagraph: React.FC<PluginParagraphProps> = ({ pluginInfo, node, children }) => {
const { pluginUniqueIdentifier, pluginId } = pluginInfo || {}
const childrenNode = node?.children as Array<any> | undefined
const childrenNode = node?.children as HastChildNode[] | undefined
const firstChild = childrenNode?.[0]
const isImageParagraph = firstChild?.tagName === 'img'
const imageSrc = isImageParagraph ? firstChild?.properties?.src : undefined

View File

@@ -1,23 +0,0 @@
/**
* @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 * as React from 'react'
import { 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

View File

@@ -1,15 +0,0 @@
/**
* @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

View File

@@ -99,7 +99,7 @@ describe('Markdown', () => {
it('should pass customComponents through', () => {
const customComponents = {
h1: ({ children }: { children: React.ReactNode }) => <h1>{children}</h1>,
h1: ({ children }: { children?: React.ReactNode }) => <h1>{children}</h1>,
}
render(<Markdown content="# title" customComponents={customComponents} />)
const props = getLastWrapperProps()

View File

@@ -1,6 +1,6 @@
import type { PropsWithChildren, ReactNode } from 'react'
import { render, screen } from '@testing-library/react'
import { ReactMarkdownWrapper } from '../react-markdown-wrapper'
import ReactMarkdownWrapper from '../react-markdown-wrapper'
vi.mock('@/app/components/base/markdown-blocks', () => ({
AudioBlock: ({ children }: PropsWithChildren) => <div data-testid="audio-block">{children}</div>,

View File

@@ -1,11 +1,16 @@
import type { ReactMarkdownWrapperProps, SimplePluginInfo } from './react-markdown-wrapper'
import { flow } from 'es-toolkit/compat'
import dynamic from 'next/dynamic'
import { memo, useMemo } from 'react'
import { cn } from '@/utils/classnames'
import { preprocessLaTeX, preprocessThinkTag } from './markdown-utils'
import 'katex/dist/katex.min.css'
const ReactMarkdown = dynamic(() => import('./react-markdown-wrapper').then(mod => mod.ReactMarkdownWrapper), { ssr: false })
const ReactMarkdown = dynamic(() => import('./react-markdown-wrapper'), { ssr: false })
const preprocess = flow([preprocessThinkTag, preprocessLaTeX])
const EMPTY_COMPONENTS = {} as const
/**
* @fileoverview Main Markdown rendering component.
@@ -18,24 +23,32 @@ export type MarkdownProps = {
content: string
className?: string
pluginInfo?: SimplePluginInfo
} & Pick<ReactMarkdownWrapperProps, 'customComponents' | 'customDisallowedElements' | 'rehypePlugins'>
} & Pick<ReactMarkdownWrapperProps, 'customComponents' | 'customDisallowedElements' | 'rehypePlugins' | 'isAnimating'>
export const Markdown = (props: MarkdownProps) => {
const { customComponents = {}, pluginInfo } = props
const latexContent = flow([
preprocessThinkTag,
preprocessLaTeX,
])(props.content)
export const Markdown = memo((props: MarkdownProps) => {
const {
content,
customComponents = EMPTY_COMPONENTS,
pluginInfo,
isAnimating,
customDisallowedElements,
rehypePlugins,
className,
} = props
const latexContent = useMemo(() => preprocess(content), [content])
return (
<div className={cn('markdown-body', '!text-text-primary', props.className)}>
<div className={cn('markdown-body', '!text-text-primary', className)}>
<ReactMarkdown
pluginInfo={pluginInfo}
latexContent={latexContent}
customComponents={customComponents}
customDisallowedElements={props.customDisallowedElements}
rehypePlugins={props.rehypePlugins}
customDisallowedElements={customDisallowedElements}
rehypePlugins={rehypePlugins}
isAnimating={isAnimating}
/>
</div>
)
}
})
Markdown.displayName = 'Markdown'

View File

@@ -1,81 +1,164 @@
import type { FC } from 'react'
import type { Components, StreamdownProps } from 'streamdown'
import { createMathPlugin } from '@streamdown/math'
import dynamic from 'next/dynamic'
import ReactMarkdown from 'react-markdown'
import RehypeKatex from 'rehype-katex'
import RehypeRaw from 'rehype-raw'
import { memo, useMemo } from 'react'
import RemarkBreaks from 'remark-breaks'
import RemarkGfm from 'remark-gfm'
import RemarkMath from 'remark-math'
import { AudioBlock, Img, Link, MarkdownButton, MarkdownForm, Paragraph, PluginImg, PluginParagraph, ScriptBlock, ThinkBlock, VideoBlock } from '@/app/components/base/markdown-blocks'
import { defaultRehypePlugins, defaultRemarkPlugins, Streamdown } from 'streamdown'
import {
AudioBlock,
Img,
Link,
MarkdownButton,
MarkdownForm,
Paragraph,
PluginImg,
PluginParagraph,
ThinkBlock,
VideoBlock,
} from '@/app/components/base/markdown-blocks'
import { ENABLE_SINGLE_DOLLAR_LATEX } from '@/config'
import { customUrlTransform } from './markdown-utils'
import 'katex/dist/katex.min.css'
type PluggableList = NonNullable<StreamdownProps['rehypePlugins']>
type Pluggable = PluggableList[number]
const CodeBlock = dynamic(() => import('@/app/components/base/markdown-blocks/code-block'), { ssr: false })
const mathPlugin = createMathPlugin({
singleDollarTextMath: ENABLE_SINGLE_DOLLAR_LATEX,
})
/**
* Allowed HTML tags and their permitted data attributes for rehype-sanitize.
* Keys = tag names to allow; values = attribute names in **hast** property format
* (camelCase, e.g. `dataThink` for `data-think`, or the wildcard `data*`).
*/
const ALLOWED_TAGS: Record<string, string[]> = {
button: ['data*'],
form: ['data*'],
details: ['dataThink'],
video: ['src', 'controls', 'width', 'height', 'data*'],
audio: ['src', 'controls', 'data*'],
source: ['src'],
mark: [],
sub: [],
sup: [],
kbd: [],
}
/**
* Build a rehype plugin list that includes the default raw → sanitize → harden
* pipeline with `ALLOWED_TAGS` baked into the sanitize schema, plus any extra
* plugins the caller provides.
*
* This sidesteps the streamdown `allowedTags` prop, which only takes effect
* when `rehypePlugins` is the exact default reference (identity check).
*/
function buildRehypePlugins(extraPlugins?: PluggableList): PluggableList {
// defaultRehypePlugins.sanitize is [rehypeSanitize, schema]
const [sanitizePlugin, defaultSanitizeSchema] = defaultRehypePlugins.sanitize as [Pluggable, Record<string, unknown>]
const tagNamesSet = new Set([
...((defaultSanitizeSchema.tagNames as string[]) ?? []),
...Object.keys(ALLOWED_TAGS),
])
const customSchema = {
...defaultSanitizeSchema,
tagNames: Array.from(tagNamesSet),
attributes: {
...(defaultSanitizeSchema.attributes as Record<string, string[]>),
...ALLOWED_TAGS,
},
}
return [
defaultRehypePlugins.raw,
...(extraPlugins ?? []),
[sanitizePlugin, customSchema] as Pluggable,
defaultRehypePlugins.harden,
]
}
export type SimplePluginInfo = {
pluginUniqueIdentifier: string
pluginId: string
}
export type ReactMarkdownWrapperProps = {
latexContent: any
latexContent: string
customDisallowedElements?: string[]
customComponents?: Record<string, React.ComponentType<any>>
customComponents?: Components
pluginInfo?: SimplePluginInfo
rehypePlugins?: any// js: PluggableList[]
rehypePlugins?: StreamdownProps['rehypePlugins']
isAnimating?: boolean
className?: string
}
export const ReactMarkdownWrapper: FC<ReactMarkdownWrapperProps> = (props) => {
const { customComponents, latexContent, pluginInfo } = props
const ReactMarkdownWrapper = (props: ReactMarkdownWrapperProps) => {
const { customComponents, latexContent, pluginInfo, isAnimating, className } = props
const remarkPlugins = useMemo(
() => [
[Array.isArray(defaultRemarkPlugins.gfm) ? defaultRemarkPlugins.gfm[0] : defaultRemarkPlugins.gfm, { singleTilde: false }] as Pluggable,
RemarkBreaks,
],
[],
)
const rehypePlugins = useMemo(
() => buildRehypePlugins(props.rehypePlugins ?? undefined),
[props.rehypePlugins],
)
const plugins = useMemo(
() => ({
math: mathPlugin,
}),
[],
)
const disallowedElements = useMemo(
() => ['iframe', 'head', 'html', 'meta', 'link', 'style', 'body', ...(props.customDisallowedElements || [])],
[props.customDisallowedElements],
)
const components: Components = useMemo(
() => ({
code: CodeBlock,
img: imgProps => pluginInfo ? <PluginImg src={String(imgProps.src ?? '')} pluginInfo={pluginInfo} /> : <Img src={String(imgProps.src ?? '')} />,
video: VideoBlock,
audio: AudioBlock,
a: Link,
p: pProps => pluginInfo ? <PluginParagraph {...pProps} pluginInfo={pluginInfo} /> : <Paragraph {...pProps} />,
button: MarkdownButton,
form: MarkdownForm,
details: ThinkBlock as React.ComponentType,
...customComponents,
}),
[pluginInfo, customComponents],
)
const controls = useMemo(() => ({
table: false,
}), [])
return (
<ReactMarkdown
remarkPlugins={[
[RemarkGfm, { singleTilde: false }],
[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)
}
},
...(props.rehypePlugins || []),
]}
<Streamdown
className={className}
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
plugins={plugins}
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,
}}
disallowedElements={disallowedElements}
components={components}
controls={controls}
isAnimating={isAnimating}
>
{/* Markdown detect has problem. */}
{latexContent}
</ReactMarkdown>
</Streamdown>
)
}
export default memo(ReactMarkdownWrapper)

View File

@@ -2,7 +2,6 @@
import type { FC } from 'react'
import type { FormInputItem, UserAction } from '../types'
import type { ButtonProps } from '@/app/components/base/button'
import { RiCloseLine } from '@remixicon/react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
@@ -47,15 +46,15 @@ const FormContentPreview: FC<FormContentPreviewProps> = ({
>
<div className="flex h-[26px] items-center justify-between px-4">
<Badge uppercase className="border-text-accent-secondary text-text-accent-secondary">{t(`${i18nPrefix}.formContent.preview`, { ns: 'workflow' })}</Badge>
<ActionButton onClick={onClose}><RiCloseLine className="w-5 text-text-tertiary" /></ActionButton>
<ActionButton onClick={onClose}><span className="i-ri-close-line w-5 text-text-tertiary" /></ActionButton>
</div>
<div className="max-h-[calc(100vh-167px)] overflow-y-auto px-4">
<Markdown
content={content}
rehypePlugins={[rehypeVariable, rehypeNotes]}
customComponents={{
variable: ({ node }: { node: { properties?: { [key: string]: string } } }) => {
const path = node.properties?.['data-path'] as string
variable: ({ node }) => {
const path = String(node?.properties?.['data-path'] ?? '')
let newPath = path
if (path) {
newPath = path.replace(/#([^#.]+)([.#])/g, (match, nodeId, sep) => {
@@ -64,8 +63,8 @@ const FormContentPreview: FC<FormContentPreviewProps> = ({
}
return <Variable path={newPath} />
},
section: ({ node }: { node: { properties?: { [key: string]: string } } }) => (() => {
const name = node.properties?.['data-name'] as string
section: ({ node }) => (() => {
const name = String(node?.properties?.['data-name'] ?? '')
const input = formInputs.find(i => i.output_variable_name === name)
if (!input) {
return (
@@ -92,7 +91,7 @@ const FormContentPreview: FC<FormContentPreviewProps> = ({
</Button>
))}
</div>
<div className="system-xs-regular mt-1 text-text-tertiary">{t('nodes.humanInput.editor.previewTip', { ns: 'workflow' })}</div>
<div className="mt-1 text-text-tertiary system-xs-regular">{t('nodes.humanInput.editor.previewTip', { ns: 'workflow' })}</div>
</div>
</div>
)

View File

@@ -141,10 +141,6 @@
font-size: 1em;
}
.markdown-body hr {
margin: 24px 0;
}
.markdown-body hr::before {
display: table;
content: "";
@@ -275,18 +271,6 @@
border-radius: 6px;
}
.markdown-body h1,
.markdown-body h2,
.markdown-body h3,
.markdown-body h4,
.markdown-body h5,
.markdown-body h6 {
padding-top: 12px;
margin-bottom: 12px;
font-weight: var(--base-text-weight-semibold, 600);
line-height: 1.25;
}
.markdown-body h1 {
font-size: 18px;
}
@@ -379,14 +363,6 @@
content: "";
}
.markdown-body>*:first-child {
margin-top: 0 !important;
}
.markdown-body>*:last-child {
margin-bottom: 0 !important;
}
.markdown-body a:not([href]) {
color: inherit;
text-decoration: none;
@@ -407,18 +383,6 @@
outline: none;
}
.markdown-body p,
.markdown-body blockquote,
.markdown-body ul,
.markdown-body ol,
.markdown-body dl,
.markdown-body table,
.markdown-body pre,
.markdown-body details {
margin-top: 0;
margin-bottom: 12px;
}
.markdown-body ul,
.markdown-body ol {
padding-left: 2em;
@@ -542,14 +506,6 @@
margin-bottom: 0;
}
.markdown-body li>p {
margin-top: 16px;
}
.markdown-body li+li {
margin-top: 0.25em;
}
.markdown-body dl {
padding: 0;
}

View File

@@ -1662,9 +1662,6 @@
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 3
},
"tailwindcss/enforce-consistent-class-order": {
"count": 3
},
"ts/no-explicit-any": {
"count": 1
}
@@ -2352,9 +2349,6 @@
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 7
},
"tailwindcss/enforce-consistent-class-order": {
"count": 1
},
"ts/no-explicit-any": {
"count": 9
}
@@ -2370,11 +2364,6 @@
"count": 11
}
},
"app/components/base/markdown-blocks/img.tsx": {
"ts/no-explicit-any": {
"count": 1
}
},
"app/components/base/markdown-blocks/link.tsx": {
"ts/no-explicit-any": {
"count": 1
@@ -2393,19 +2382,6 @@
"app/components/base/markdown-blocks/plugin-paragraph.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 2
},
"ts/no-explicit-any": {
"count": 2
}
},
"app/components/base/markdown-blocks/pre-code.tsx": {
"ts/no-explicit-any": {
"count": 1
}
},
"app/components/base/markdown-blocks/script-block.tsx": {
"ts/no-explicit-any": {
"count": 1
}
},
"app/components/base/markdown-blocks/think-block.tsx": {
@@ -2431,11 +2407,6 @@
"count": 1
}
},
"app/components/base/markdown/react-markdown-wrapper.tsx": {
"ts/no-explicit-any": {
"count": 9
}
},
"app/components/base/mermaid/index.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 7
@@ -7681,11 +7652,6 @@
"count": 2
}
},
"app/components/workflow/nodes/human-input/components/form-content-preview.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 1
}
},
"app/components/workflow/nodes/human-input/components/form-content.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1

View File

@@ -85,6 +85,7 @@
"@orpc/tanstack-query": "1.13.6",
"@remixicon/react": "4.7.0",
"@sentry/react": "8.55.0",
"@streamdown/math": "1.0.2",
"@svgdotjs/svg.js": "3.2.5",
"@t3-oss/env-nextjs": "0.13.10",
"@tailwindcss/typography": "0.5.19",
@@ -139,7 +140,6 @@
"react-easy-crop": "5.5.3",
"react-hotkeys-hook": "4.6.2",
"react-i18next": "16.5.0",
"react-markdown": "9.1.0",
"react-multi-email": "1.0.25",
"react-papaparse": "4.4.0",
"react-pdf-highlighter": "8.0.0-rc.0",
@@ -149,15 +149,12 @@
"react-textarea-autosize": "8.5.9",
"react-window": "1.8.11",
"reactflow": "11.11.4",
"rehype-katex": "7.0.1",
"rehype-raw": "7.0.0",
"remark-breaks": "4.0.0",
"remark-gfm": "4.0.1",
"remark-math": "6.0.0",
"scheduler": "0.27.0",
"semver": "7.7.3",
"sharp": "0.33.5",
"sortablejs": "1.15.6",
"streamdown": "2.3.0",
"string-ts": "2.3.1",
"tailwind-merge": "2.6.1",
"tldts": "7.0.17",

136
web/pnpm-lock.yaml generated
View File

@@ -126,6 +126,9 @@ importers:
'@sentry/react':
specifier: 8.55.0
version: 8.55.0(react@19.2.4)
'@streamdown/math':
specifier: 1.0.2
version: 1.0.2(react@19.2.4)
'@svgdotjs/svg.js':
specifier: 3.2.5
version: 3.2.5
@@ -288,9 +291,6 @@ importers:
react-i18next:
specifier: 16.5.0
version: 16.5.0(i18next@25.7.3(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)
react-markdown:
specifier: 9.1.0
version: 9.1.0(@types/react@19.2.9)(react@19.2.4)
react-multi-email:
specifier: 1.0.25
version: 1.0.25(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -318,21 +318,9 @@ importers:
reactflow:
specifier: 11.11.4
version: 11.11.4(@types/react@19.2.9)(immer@11.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
rehype-katex:
specifier: 7.0.1
version: 7.0.1
rehype-raw:
specifier: 7.0.0
version: 7.0.0
remark-breaks:
specifier: 4.0.0
version: 4.0.0
remark-gfm:
specifier: 4.0.1
version: 4.0.1
remark-math:
specifier: 6.0.0
version: 6.0.0
scheduler:
specifier: 0.27.0
version: 0.27.0
@@ -345,6 +333,9 @@ importers:
sortablejs:
specifier: 1.15.6
version: 1.15.6
streamdown:
specifier: 2.3.0
version: 2.3.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
string-ts:
specifier: 2.3.1
version: 2.3.1
@@ -2875,6 +2866,11 @@ packages:
typescript:
optional: true
'@streamdown/math@1.0.2':
resolution: {integrity: sha512-r8Ur9/lBuFnzZAFdEWrLUF2s/gRwRRRwruqltdZibyjbCBnuW7SJbFm26nXqvpJPW/gzpBUMrBVBzd88z05D5g==}
peerDependencies:
react: ^18.0.0 || ^19.0.0
'@stylistic/eslint-plugin@https://pkg.pr.new/@stylistic/eslint-plugin@258f9d8':
resolution: {tarball: https://pkg.pr.new/@stylistic/eslint-plugin@258f9d8}
version: 5.9.0
@@ -5204,6 +5200,9 @@ packages:
hast-util-raw@9.1.0:
resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==}
hast-util-sanitize@5.0.2:
resolution: {integrity: sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==}
hast-util-to-estree@3.1.3:
resolution: {integrity: sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w==}
@@ -5602,6 +5601,10 @@ packages:
resolution: {integrity: sha512-woHRUZ/iF23GBP1dkDQMh1QBad9dmr8/PAwNA54VrSOVYgI12MAcE14TqnDdQOdzyEonGzMepYnqBMYdsoAr8Q==}
hasBin: true
katex@0.16.33:
resolution: {integrity: sha512-q3N5u+1sY9Bu7T4nlXoiRBXWfwSefNGoKeOwekV+gw0cAXQlz2Ww6BLcmBxVDeXBMUDQv6fK5bcNaJLxob3ZQA==}
hasBin: true
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
@@ -5834,6 +5837,11 @@ packages:
engines: {node: '>= 18'}
hasBin: true
marked@17.0.3:
resolution: {integrity: sha512-jt1v2ObpyOKR8p4XaUJVk3YWRJ5n+i4+rjQopxvV32rSndTJXvIzuUdWWIy/1pFQMkQmvTXawzDNqOH/CUmx6A==}
engines: {node: '>= 20'}
hasBin: true
mdast-util-find-and-replace@3.0.2:
resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==}
@@ -6615,12 +6623,6 @@ packages:
react-is@17.0.2:
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
react-markdown@9.1.0:
resolution: {integrity: sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqeP+VlIfAZ3bphpcLTnSZXz6z9jcVC/TCwbfgdw==}
peerDependencies:
'@types/react': '>=18'
react: '>=18'
react-multi-email@1.0.25:
resolution: {integrity: sha512-Wmv28FvIk4nWgdpHzlIPonY4iSs7bPV35+fAiWYzSBhTo+vhXfglEhjY1WnjHQINW/Pibu2xlb/q1heVuytQHQ==}
peerDependencies:
@@ -6789,6 +6791,9 @@ packages:
resolution: {integrity: sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==}
hasBin: true
rehype-harden@1.1.8:
resolution: {integrity: sha512-Qn7vR1xrf6fZCrkm9TDWi/AB4ylrHy+jqsNm1EHOAmbARYA6gsnVJBq/sdBh6kmT4NEZxH5vgIjrscefJAOXcw==}
rehype-katex@7.0.1:
resolution: {integrity: sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==}
@@ -6798,6 +6803,9 @@ packages:
rehype-recma@1.0.0:
resolution: {integrity: sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw==}
rehype-sanitize@6.0.0:
resolution: {integrity: sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==}
remark-breaks@4.0.0:
resolution: {integrity: sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ==}
@@ -6819,6 +6827,9 @@ packages:
remark-stringify@11.0.0:
resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==}
remend@1.2.1:
resolution: {integrity: sha512-4wC12bgXsfKAjF1ewwkNIQz5sqewz/z1xgIgjEMb3r1pEytQ37F0Cm6i+OhbTWEvguJD7lhOUJhK5fSasw9f0w==}
require-from-string@2.0.2:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
@@ -7058,6 +7069,12 @@ packages:
prettier:
optional: true
streamdown@2.3.0:
resolution: {integrity: sha512-OqS3by/lt91lSicE8RQP2nTsYI6Q/dQgGP2vcyn9YesCmRHhNjswAuBAZA1z0F4+oBU3II/eV51LqjCqwTb1lw==}
peerDependencies:
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
strict-event-emitter@0.5.1:
resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==}
@@ -7185,6 +7202,9 @@ packages:
tailwind-merge@2.6.1:
resolution: {integrity: sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==}
tailwind-merge@3.5.0:
resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==}
tailwindcss@3.4.19:
resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==}
engines: {node: '>=14.0.0'}
@@ -10357,6 +10377,15 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@streamdown/math@1.0.2(react@19.2.4)':
dependencies:
katex: 0.16.33
react: 19.2.4
rehype-katex: 7.0.1
remark-math: 6.0.0
transitivePeerDependencies:
- supports-color
'@stylistic/eslint-plugin@https://pkg.pr.new/@stylistic/eslint-plugin@258f9d8(eslint@10.0.2(jiti@1.21.7))':
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@10.0.2(jiti@1.21.7))
@@ -13091,6 +13120,12 @@ snapshots:
web-namespaces: 2.0.1
zwitch: 2.0.4
hast-util-sanitize@5.0.2:
dependencies:
'@types/hast': 3.0.4
'@ungap/structured-clone': 1.3.0
unist-util-position: 5.0.0
hast-util-to-estree@3.1.3:
dependencies:
'@types/estree': 1.0.8
@@ -13496,6 +13531,10 @@ snapshots:
dependencies:
commander: 8.3.0
katex@0.16.33:
dependencies:
commander: 8.3.0
keyv@4.5.4:
dependencies:
json-buffer: 3.0.1
@@ -13716,6 +13755,8 @@ snapshots:
marked@15.0.12: {}
marked@17.0.3: {}
mdast-util-find-and-replace@3.0.2:
dependencies:
'@types/mdast': 4.0.4
@@ -14812,24 +14853,6 @@ snapshots:
react-is@17.0.2: {}
react-markdown@9.1.0(@types/react@19.2.9)(react@19.2.4):
dependencies:
'@types/hast': 3.0.4
'@types/mdast': 4.0.4
'@types/react': 19.2.9
devlop: 1.1.0
hast-util-to-jsx-runtime: 2.3.6
html-url-attributes: 3.0.1
mdast-util-to-hast: 13.2.1
react: 19.2.4
remark-parse: 11.0.0
remark-rehype: 11.1.2
unified: 11.0.5
unist-util-visit: 5.1.0
vfile: 6.0.3
transitivePeerDependencies:
- supports-color
react-multi-email@1.0.25(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
react: 19.2.4
@@ -15054,6 +15077,10 @@ snapshots:
dependencies:
jsesc: 3.1.0
rehype-harden@1.1.8:
dependencies:
unist-util-visit: 5.1.0
rehype-katex@7.0.1:
dependencies:
'@types/hast': 3.0.4
@@ -15078,6 +15105,11 @@ snapshots:
transitivePeerDependencies:
- supports-color
rehype-sanitize@6.0.0:
dependencies:
'@types/hast': 3.0.4
hast-util-sanitize: 5.0.2
remark-breaks@4.0.0:
dependencies:
'@types/mdast': 4.0.4
@@ -15134,6 +15166,8 @@ snapshots:
mdast-util-to-markdown: 2.1.2
unified: 11.0.5
remend@1.2.1: {}
require-from-string@2.0.2: {}
reselect@5.1.1: {}
@@ -15456,6 +15490,28 @@ snapshots:
- react-dom
- utf-8-validate
streamdown@2.3.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
clsx: 2.1.1
hast-util-to-jsx-runtime: 2.3.6
html-url-attributes: 3.0.1
marked: 17.0.3
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
rehype-harden: 1.1.8
rehype-raw: 7.0.0
rehype-sanitize: 6.0.0
remark-gfm: 4.0.1
remark-parse: 11.0.0
remark-rehype: 11.1.2
remend: 1.2.1
tailwind-merge: 3.5.0
unified: 11.0.5
unist-util-visit: 5.1.0
unist-util-visit-parents: 6.0.2
transitivePeerDependencies:
- supports-color
strict-event-emitter@0.5.1: {}
string-argv@0.3.2: {}
@@ -15570,6 +15626,8 @@ snapshots:
tailwind-merge@2.6.1: {}
tailwind-merge@3.5.0: {}
tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2):
dependencies:
'@alloc/quick-lru': 5.2.0

View File

@@ -6,6 +6,7 @@ const config = {
'./app/**/*.{js,ts,jsx,tsx}',
'./components/**/*.{js,ts,jsx,tsx}',
'./context/**/*.{js,ts,jsx,tsx}',
'./node_modules/streamdown/dist/*.js',
],
...commonConfig,
}