mirror of
https://github.com/langgenius/dify.git
synced 2026-03-26 05:29:50 +08:00
refactor: replace react markdown with streamdown (#32971)
Co-authored-by: Stephen Zhou <hi@hyoban.cc> Co-authored-by: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
This commit is contained in:
parent
2a468da440
commit
75bbb616ea
2
.github/dependabot.yml
vendored
2
.github/dependabot.yml
vendored
@ -30,8 +30,6 @@ updates:
|
||||
update-types: ["version-update:semver-major"]
|
||||
- dependency-name: "tailwindcss"
|
||||
update-types: ["version-update:semver-major"]
|
||||
- dependency-name: "react-markdown"
|
||||
update-types: ["version-update:semver-major"]
|
||||
- dependency-name: "react-syntax-highlighter"
|
||||
update-types: ["version-update:semver-major"]
|
||||
- dependency-name: "react-window"
|
||||
|
||||
@ -303,7 +303,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" />)
|
||||
: (<div className="i-ri-play-large-fill h-5 w-5" />)}
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -112,7 +112,7 @@ const ChatInputArea = ({
|
||||
|
||||
if (onSend) {
|
||||
const { files, setFiles } = filesStore.getState()
|
||||
if (files.find(item => item.transferMethod === TransferMethod.local_file && !item.uploadedId)) {
|
||||
if (files.some(item => item.transferMethod === TransferMethod.local_file && !item.uploadedId)) {
|
||||
notify({ type: 'info', message: t('errorMessage.waitForFileUpload', { ns: 'appDebug' }) })
|
||||
return
|
||||
}
|
||||
@ -215,14 +215,14 @@ const ChatInputArea = ({
|
||||
<div className="relative flex w-full grow items-center">
|
||||
<div
|
||||
ref={textValueRef}
|
||||
className="body-lg-regular pointer-events-none invisible absolute h-auto w-auto whitespace-pre p-1 leading-6"
|
||||
className="pointer-events-none invisible absolute h-auto w-auto whitespace-pre p-1 leading-6 body-lg-regular"
|
||||
>
|
||||
{query}
|
||||
</div>
|
||||
<Textarea
|
||||
ref={ref => textareaRef.current = ref as any}
|
||||
className={cn(
|
||||
'body-lg-regular w-full resize-none bg-transparent p-1 leading-6 text-text-primary outline-none',
|
||||
'w-full resize-none bg-transparent p-1 leading-6 text-text-primary outline-none body-lg-regular',
|
||||
)}
|
||||
placeholder={decode(t(readonly ? 'chat.inputDisabledPlaceholder' : 'chat.inputPlaceholder', { ns: 'common', botName }) || '')}
|
||||
autoFocus
|
||||
|
||||
@ -299,7 +299,7 @@ export const useChat = (
|
||||
updateChatTreeNode(messageId, (responseItem) => {
|
||||
const lastThought = responseItem.agent_thoughts?.[responseItem.agent_thoughts?.length - 1]
|
||||
if (lastThought) {
|
||||
responseItem.agent_thoughts![responseItem.agent_thoughts!.length - 1].message_files = [...(lastThought as any).message_files, convertedFile]
|
||||
responseItem.agent_thoughts!.at(-1)!.message_files = [...(lastThought as any).message_files, convertedFile]
|
||||
}
|
||||
else {
|
||||
const currentFiles = (responseItem.message_files as FileEntity[] | undefined) ?? []
|
||||
@ -321,8 +321,8 @@ export const useChat = (
|
||||
responseItem.agent_thoughts.push(thought)
|
||||
}
|
||||
else {
|
||||
const lastThought = responseItem.agent_thoughts[responseItem.agent_thoughts.length - 1]
|
||||
if (lastThought.id === thought.id) {
|
||||
const lastThought = responseItem.agent_thoughts.at(-1)
|
||||
if (lastThought?.id === thought.id) {
|
||||
thought.thought = lastThought.thought
|
||||
thought.message_files = lastThought.message_files
|
||||
responseItem.agent_thoughts[responseItem.agent_thoughts.length - 1] = thought
|
||||
@ -743,7 +743,7 @@ export const useChat = (
|
||||
content: isUseAgentThought ? '' : newResponseItem.answer,
|
||||
log: [
|
||||
...newResponseItem.message,
|
||||
...(newResponseItem.message[newResponseItem.message.length - 1].role !== 'assistant'
|
||||
...(newResponseItem.message.at(-1).role !== 'assistant'
|
||||
? [
|
||||
{
|
||||
role: 'assistant',
|
||||
@ -809,7 +809,7 @@ export const useChat = (
|
||||
const lastThought = responseItem.agent_thoughts?.[responseItem.agent_thoughts?.length - 1]
|
||||
if (lastThought) {
|
||||
const thought = lastThought as { message_files?: FileEntity[] }
|
||||
responseItem.agent_thoughts![responseItem.agent_thoughts!.length - 1].message_files = [...(thought.message_files ?? []), convertedFile]
|
||||
responseItem.agent_thoughts!.at(-1)!.message_files = [...(thought.message_files ?? []), convertedFile]
|
||||
}
|
||||
// For non-agent mode, add files directly to responseItem.message_files
|
||||
else {
|
||||
@ -836,7 +836,7 @@ export const useChat = (
|
||||
response.agent_thoughts.push(thought)
|
||||
}
|
||||
else {
|
||||
const lastThought = response.agent_thoughts[response.agent_thoughts.length - 1]
|
||||
const lastThought = response.agent_thoughts.at(-1)
|
||||
// thought changed but still the same thought, so update.
|
||||
if (lastThought.id === thought.id) {
|
||||
thought.thought = lastThought.thought
|
||||
|
||||
@ -246,7 +246,7 @@ const Chat: FC<ChatProps> = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (!sidebarCollapseState) {
|
||||
const timer = setTimeout(() => handleWindowResize(), 200)
|
||||
const timer = setTimeout(handleWindowResize, 200)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [handleWindowResize, sidebarCollapseState])
|
||||
@ -285,7 +285,7 @@ const Chat: FC<ChatProps> = ({
|
||||
{
|
||||
chatList.map((item, index) => {
|
||||
if (item.isAnswer) {
|
||||
const isLast = item.id === chatList[chatList.length - 1]?.id
|
||||
const isLast = item.id === chatList.at(-1)?.id
|
||||
return (
|
||||
<Answer
|
||||
appData={appData}
|
||||
|
||||
@ -262,7 +262,7 @@ const ChatWrapper = () => {
|
||||
background={appData?.site.icon_background}
|
||||
imageUrl={appData?.site.icon_url}
|
||||
/>
|
||||
<div className="body-lg-regular grow rounded-2xl bg-chat-bubble-bg px-4 py-3 text-text-primary">
|
||||
<div className="grow rounded-2xl bg-chat-bubble-bg px-4 py-3 text-text-primary body-lg-regular">
|
||||
<Markdown content={welcomeMessage.content} />
|
||||
<SuggestedQuestions item={welcomeMessage} />
|
||||
</div>
|
||||
@ -280,7 +280,7 @@ const ChatWrapper = () => {
|
||||
imageUrl={appData?.site.icon_url}
|
||||
/>
|
||||
<div className="max-w-[768px] px-4">
|
||||
<Markdown className="!body-2xl-regular !text-text-tertiary" content={welcomeMessage.content} />
|
||||
<Markdown className="!text-text-tertiary !body-2xl-regular" content={welcomeMessage.content} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -3,6 +3,9 @@ import userEvent from '@testing-library/user-event'
|
||||
import dayjs from '@/app/components/base/date-and-time-picker/utils/dayjs'
|
||||
import MarkdownForm from '../form'
|
||||
|
||||
const UNSUPPORTED_TAG_ARTICLE_RE = /Unsupported tag:\s*article/
|
||||
const UNSUPPORTED_TAG_RE = /Unsupported tag/
|
||||
|
||||
type TextNode = {
|
||||
type: 'text'
|
||||
value: string
|
||||
@ -16,6 +19,8 @@ type ElementNode = {
|
||||
}
|
||||
|
||||
type RootNode = {
|
||||
type: 'element'
|
||||
tagName: 'form'
|
||||
properties: Record<string, unknown>
|
||||
children: Array<ElementNode | TextNode>
|
||||
}
|
||||
@ -63,6 +68,8 @@ const createRootNode = (
|
||||
children: Array<ElementNode | TextNode>,
|
||||
properties: Record<string, unknown> = {},
|
||||
): RootNode => ({
|
||||
type: 'element',
|
||||
tagName: 'form',
|
||||
properties,
|
||||
children,
|
||||
})
|
||||
@ -89,7 +96,7 @@ describe('MarkdownForm', () => {
|
||||
expect(screen.getByPlaceholderText('Enter name')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('Enter bio')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Submit' })).toBeInTheDocument()
|
||||
expect(screen.getByText(/Unsupported tag:\s*article/)).toBeInTheDocument()
|
||||
expect(screen.getByText(UNSUPPORTED_TAG_ARTICLE_RE)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -236,7 +243,7 @@ describe('MarkdownForm', () => {
|
||||
|
||||
render(<MarkdownForm node={node} />)
|
||||
|
||||
const triggerText = await screen.findByTitle('Paris')
|
||||
const triggerText = await screen.findByText('Paris')
|
||||
await user.click(triggerText)
|
||||
await user.click(await screen.findByText('Tokyo'))
|
||||
await user.click(screen.getByRole('button', { name: 'Submit' }))
|
||||
@ -441,6 +448,226 @@ describe('MarkdownForm', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Inputs and textareas with unsafe names should be silently dropped.
|
||||
describe('Unsafe name rejection', () => {
|
||||
it('should not render input with prototype-poisoning name', () => {
|
||||
const node = createRootNode([
|
||||
createElementNode('input', { type: 'text', name: '__proto__', placeholder: 'poison' }),
|
||||
createElementNode('button', {}, [createTextNode('Submit')]),
|
||||
])
|
||||
|
||||
render(<MarkdownForm node={node} />)
|
||||
|
||||
expect(screen.queryByPlaceholderText('poison')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render textarea with prototype-poisoning name', () => {
|
||||
const node = createRootNode([
|
||||
createElementNode('textarea', { name: 'constructor', placeholder: 'poison' }),
|
||||
createElementNode('button', {}, [createTextNode('Submit')]),
|
||||
])
|
||||
|
||||
render(<MarkdownForm node={node} />)
|
||||
|
||||
expect(screen.queryByPlaceholderText('poison')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render input when name exceeds 128 characters', () => {
|
||||
const longName = 'a'.repeat(129)
|
||||
const node = createRootNode([
|
||||
createElementNode('input', { type: 'text', name: longName, placeholder: 'long-name' }),
|
||||
])
|
||||
|
||||
render(<MarkdownForm node={node} />)
|
||||
|
||||
expect(screen.queryByPlaceholderText('long-name')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render input when name starts with a digit', () => {
|
||||
const node = createRootNode([
|
||||
createElementNode('input', { type: 'text', name: '1invalid', placeholder: 'bad-name' }),
|
||||
])
|
||||
|
||||
render(<MarkdownForm node={node} />)
|
||||
|
||||
expect(screen.queryByPlaceholderText('bad-name')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not include unsafe-named fields in submission output', async () => {
|
||||
const user = userEvent.setup()
|
||||
const node = createRootNode(
|
||||
[
|
||||
createElementNode('input', { type: 'text', name: 'valid', value: 'ok' }),
|
||||
createElementNode('input', { type: 'text', name: 'prototype', value: 'bad' }),
|
||||
createElementNode('button', {}, [createTextNode('Submit')]),
|
||||
],
|
||||
{ dataFormat: 'json' },
|
||||
)
|
||||
|
||||
render(<MarkdownForm node={node} />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Submit' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnSend).toHaveBeenCalledWith('{"valid":"ok"}')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Double-click protection: button disables after the first submit.
|
||||
describe('Double submit prevention', () => {
|
||||
it('should disable submit button after first click', async () => {
|
||||
const user = userEvent.setup()
|
||||
const node = createRootNode([
|
||||
createElementNode('input', { type: 'text', name: 'name', value: 'Alice' }),
|
||||
createElementNode('button', {}, [createTextNode('Submit')]),
|
||||
])
|
||||
|
||||
render(<MarkdownForm node={node} />)
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Submit' })
|
||||
await user.click(button)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(button).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onSend only once on rapid double click', async () => {
|
||||
const user = userEvent.setup()
|
||||
const node = createRootNode([
|
||||
createElementNode('input', { type: 'text', name: 'name', value: 'Alice' }),
|
||||
createElementNode('button', {}, [createTextNode('Submit')]),
|
||||
])
|
||||
|
||||
render(<MarkdownForm node={node} />)
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Submit' })
|
||||
await user.click(button)
|
||||
await user.click(button)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnSend).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// onSend errors should reset submitting state so the user can retry.
|
||||
describe('Submit error handling', () => {
|
||||
it('should reset isSubmitting when onSend throws', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockOnSend.mockImplementation(() => {
|
||||
throw new Error('send failed')
|
||||
})
|
||||
|
||||
const node = createRootNode([
|
||||
createElementNode('input', { type: 'text', name: 'name', value: 'Alice' }),
|
||||
createElementNode('button', {}, [createTextNode('Submit')]),
|
||||
])
|
||||
|
||||
render(<MarkdownForm node={node} />)
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Submit' })
|
||||
await user.click(button)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(button).not.toBeDisabled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Button variant and size props should only apply whitelisted values.
|
||||
describe('Button variant and size', () => {
|
||||
it('should render button with valid variant and size', () => {
|
||||
const node = createRootNode([
|
||||
createElementNode('button', { dataVariant: 'primary', dataSize: 'large' }, [createTextNode('Go')]),
|
||||
])
|
||||
|
||||
render(<MarkdownForm node={node} />)
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Go' })
|
||||
expect(button).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should ignore invalid variant and size values', () => {
|
||||
const node = createRootNode([
|
||||
createElementNode('button', { dataVariant: 'danger', dataSize: 'xl' }, [createTextNode('Go')]),
|
||||
])
|
||||
|
||||
render(<MarkdownForm node={node} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Go' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Standard input types (password, email, number) use the generic Input branch.
|
||||
describe('Standard input types', () => {
|
||||
it('should render password input with masked value', () => {
|
||||
const node = createRootNode([
|
||||
createElementNode('input', { type: 'password', name: 'secret', placeholder: 'Password' }),
|
||||
])
|
||||
|
||||
render(<MarkdownForm node={node} />)
|
||||
|
||||
const input = screen.getByPlaceholderText('Password')
|
||||
expect(input).toHaveAttribute('type', 'password')
|
||||
})
|
||||
|
||||
it('should render email input', () => {
|
||||
const node = createRootNode([
|
||||
createElementNode('input', { type: 'email', name: 'email', placeholder: 'Email' }),
|
||||
])
|
||||
|
||||
render(<MarkdownForm node={node} />)
|
||||
|
||||
expect(screen.getByPlaceholderText('Email')).toHaveAttribute('type', 'email')
|
||||
})
|
||||
|
||||
it('should render number input', () => {
|
||||
const node = createRootNode([
|
||||
createElementNode('input', { type: 'number', name: 'age', placeholder: 'Age' }),
|
||||
])
|
||||
|
||||
render(<MarkdownForm node={node} />)
|
||||
|
||||
expect(screen.getByPlaceholderText('Age')).toHaveAttribute('type', 'number')
|
||||
})
|
||||
|
||||
it('should submit typed value from password input', async () => {
|
||||
const user = userEvent.setup()
|
||||
const node = createRootNode(
|
||||
[
|
||||
createElementNode('input', { type: 'password', name: 'secret', placeholder: 'Password' }),
|
||||
createElementNode('button', {}, [createTextNode('Submit')]),
|
||||
],
|
||||
{ dataFormat: 'json' },
|
||||
)
|
||||
|
||||
render(<MarkdownForm node={node} />)
|
||||
|
||||
await user.type(screen.getByPlaceholderText('Password'), 'mypass')
|
||||
await user.click(screen.getByRole('button', { name: 'Submit' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnSend).toHaveBeenCalledWith('{"secret":"mypass"}')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Inputs whose type is not in SUPPORTED_TYPES_SET should not render.
|
||||
describe('Unsupported input type', () => {
|
||||
it('should not render input with unsupported type like range', () => {
|
||||
const node = createRootNode([
|
||||
createElementNode('input', { type: 'range', name: 'slider' }),
|
||||
])
|
||||
|
||||
render(<MarkdownForm node={node} />)
|
||||
|
||||
expect(screen.queryByRole('slider')).not.toBeInTheDocument()
|
||||
expect(screen.getByText(UNSUPPORTED_TAG_RE)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Fallback branches for edge cases in tag rendering.
|
||||
describe('Fallback branches', () => {
|
||||
it('should render label with empty text when children array is empty', () => {
|
||||
|
||||
@ -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'
|
||||
@ -26,13 +27,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 = {
|
||||
@ -65,7 +67,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
|
||||
@ -76,9 +78,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(
|
||||
@ -94,9 +94,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,
|
||||
@ -115,12 +113,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}>
|
||||
@ -133,9 +129,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,
|
||||
@ -156,9 +150,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(
|
||||
@ -183,12 +175,10 @@ describe('PluginParagraph', () => {
|
||||
it('should render div instead of p when image is not the first child', () => {
|
||||
vi.mocked(hasImageChild).mockReturnValue(true)
|
||||
|
||||
const node: MockNode = {
|
||||
children: [
|
||||
{ tagName: 'span' },
|
||||
{ tagName: 'img', properties: { src: 'test.png' } },
|
||||
],
|
||||
}
|
||||
const node = mockNode([
|
||||
{ tagName: 'span' },
|
||||
{ tagName: 'img', properties: { src: 'test.png' } },
|
||||
])
|
||||
|
||||
render(
|
||||
<PluginParagraph node={node}>
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
|
||||
@ -1,14 +1,16 @@
|
||||
import type { Dayjs } from 'dayjs'
|
||||
import type { ButtonProps } from '@/app/components/base/button'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { useChatContext } from '@/app/components/base/chat/chat/context'
|
||||
import Checkbox from '@/app/components/base/checkbox'
|
||||
import DatePicker from '@/app/components/base/date-and-time-picker/date-picker'
|
||||
import TimePicker from '@/app/components/base/date-and-time-picker/time-picker'
|
||||
import { formatDateForOutput } from '@/app/components/base/date-and-time-picker/utils/dayjs'
|
||||
import { formatDateForOutput, toDayjs } from '@/app/components/base/date-and-time-picker/utils/dayjs'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Select from '@/app/components/base/select'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/app/components/base/ui/select'
|
||||
|
||||
enum DATA_FORMAT {
|
||||
TEXT = 'text',
|
||||
@ -32,238 +34,359 @@ enum SUPPORTED_TYPES {
|
||||
SELECT = 'select',
|
||||
HIDDEN = 'hidden',
|
||||
}
|
||||
const MarkdownForm = ({ node }: any) => {
|
||||
const { onSend } = useChatContext()
|
||||
|
||||
const [formValues, setFormValues] = useState<{ [key: string]: any }>({})
|
||||
const SUPPORTED_TYPES_SET = new Set<string>(Object.values(SUPPORTED_TYPES))
|
||||
|
||||
useEffect(() => {
|
||||
const initialValues: { [key: string]: any } = {}
|
||||
node.children.forEach((child: any) => {
|
||||
if ([SUPPORTED_TAGS.INPUT, SUPPORTED_TAGS.TEXTAREA].includes(child.tagName)) {
|
||||
initialValues[child.properties.name]
|
||||
= (child.tagName === SUPPORTED_TAGS.INPUT && child.properties.type === SUPPORTED_TYPES.HIDDEN)
|
||||
? (child.properties.value || '')
|
||||
: child.properties.value
|
||||
}
|
||||
})
|
||||
setFormValues(initialValues)
|
||||
}, [node.children])
|
||||
const SAFE_NAME_RE = /^[a-z][\w-]*$/i
|
||||
const PROTOTYPE_POISON_KEYS = new Set(['__proto__', 'constructor', 'prototype'])
|
||||
|
||||
const getFormValues = (children: any) => {
|
||||
const values: { [key: string]: any } = {}
|
||||
children.forEach((child: any) => {
|
||||
if ([SUPPORTED_TAGS.INPUT, SUPPORTED_TAGS.TEXTAREA].includes(child.tagName)) {
|
||||
let value = formValues[child.properties.name]
|
||||
function isSafeName(name: unknown): name is string {
|
||||
return typeof name === 'string'
|
||||
&& name.length > 0
|
||||
&& name.length <= 128
|
||||
&& SAFE_NAME_RE.test(name)
|
||||
&& !PROTOTYPE_POISON_KEYS.has(name)
|
||||
}
|
||||
|
||||
if (child.tagName === SUPPORTED_TAGS.INPUT
|
||||
&& (child.properties.type === SUPPORTED_TYPES.DATE || child.properties.type === SUPPORTED_TYPES.DATETIME)) {
|
||||
if (value && typeof value.format === 'function') {
|
||||
// Format date output consistently
|
||||
const includeTime = child.properties.type === SUPPORTED_TYPES.DATETIME
|
||||
value = formatDateForOutput(value, includeTime)
|
||||
}
|
||||
}
|
||||
const VALID_BUTTON_VARIANTS = new Set<string>([
|
||||
'primary',
|
||||
'warning',
|
||||
'secondary',
|
||||
'secondary-accent',
|
||||
'ghost',
|
||||
'ghost-accent',
|
||||
'tertiary',
|
||||
])
|
||||
const VALID_BUTTON_SIZES = new Set<string>(['small', 'medium', 'large'])
|
||||
|
||||
values[child.properties.name] = value
|
||||
}
|
||||
})
|
||||
return values
|
||||
}
|
||||
type HastText = {
|
||||
type: 'text'
|
||||
value: string
|
||||
}
|
||||
|
||||
const onSubmit = (e: any) => {
|
||||
e.preventDefault()
|
||||
const format = node.properties.dataFormat || DATA_FORMAT.TEXT
|
||||
const result = getFormValues(node.children)
|
||||
type HastElement = {
|
||||
type: 'element'
|
||||
tagName: string
|
||||
properties: Record<string, unknown>
|
||||
children: Array<HastElement | HastText>
|
||||
}
|
||||
|
||||
if (format === DATA_FORMAT.JSON) {
|
||||
onSend?.(JSON.stringify(result))
|
||||
type FormValue = string | boolean | Dayjs | undefined
|
||||
type FormValues = Record<string, FormValue>
|
||||
type EditState = {
|
||||
source: HastElement[]
|
||||
edits: FormValues
|
||||
}
|
||||
|
||||
function getTextContent(node: HastElement): string {
|
||||
const textChild = node.children.find((c): c is HastText => c.type === 'text')
|
||||
return textChild?.value ?? ''
|
||||
}
|
||||
|
||||
function str(val: unknown): string {
|
||||
if (val == null)
|
||||
return ''
|
||||
return String(val)
|
||||
}
|
||||
|
||||
function computeInitialFormValues(children: HastElement[]): FormValues {
|
||||
const init: FormValues = Object.create(null) as FormValues
|
||||
for (const child of children) {
|
||||
if (child.tagName !== SUPPORTED_TAGS.INPUT && child.tagName !== SUPPORTED_TAGS.TEXTAREA)
|
||||
continue
|
||||
const name = child.properties.name
|
||||
if (!isSafeName(name))
|
||||
continue
|
||||
|
||||
const type = child.tagName === SUPPORTED_TAGS.INPUT ? str(child.properties.type) : ''
|
||||
|
||||
if (type === SUPPORTED_TYPES.HIDDEN) {
|
||||
init[name] = str(child.properties.value)
|
||||
}
|
||||
else if (type === SUPPORTED_TYPES.DATE || type === SUPPORTED_TYPES.DATETIME || type === SUPPORTED_TYPES.TIME) {
|
||||
const raw = child.properties.value
|
||||
init[name] = raw != null ? toDayjs(String(raw)) : undefined
|
||||
}
|
||||
else if (type === SUPPORTED_TYPES.CHECKBOX) {
|
||||
const { checked, value } = child.properties
|
||||
init[name] = !!checked || value === true || value === 'true'
|
||||
}
|
||||
else {
|
||||
const textResult = Object.entries(result)
|
||||
.map(([key, value]) => `${key}: ${value}`)
|
||||
.join('\n')
|
||||
onSend?.(textResult)
|
||||
init[name] = child.properties.value != null ? str(child.properties.value) : undefined
|
||||
}
|
||||
}
|
||||
return init
|
||||
}
|
||||
|
||||
function getElementKey(child: HastElement, index: number): string {
|
||||
const tag = child.tagName
|
||||
const name = str(child.properties.name)
|
||||
const htmlFor = str(child.properties.htmlFor)
|
||||
const type = str(child.properties.type)
|
||||
|
||||
if (tag === SUPPORTED_TAGS.LABEL)
|
||||
return `label-${index}-${htmlFor || name}`
|
||||
if (tag === SUPPORTED_TAGS.INPUT)
|
||||
return `input-${index}-${type}-${name}`
|
||||
if (tag === SUPPORTED_TAGS.TEXTAREA)
|
||||
return `textarea-${index}-${name}`
|
||||
if (tag === SUPPORTED_TAGS.BUTTON)
|
||||
return `button-${index}-${getTextContent(child)}`
|
||||
return `${tag}-${index}`
|
||||
}
|
||||
|
||||
const MarkdownForm = ({ node }: { node: HastElement }) => {
|
||||
const typedNode = node
|
||||
const { onSend } = useChatContext()
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const elementChildren = useMemo(
|
||||
() => typedNode.children.filter((c): c is HastElement => c.type === 'element'),
|
||||
[typedNode.children],
|
||||
)
|
||||
|
||||
const baseFormValues = useMemo(
|
||||
() => computeInitialFormValues(elementChildren),
|
||||
[elementChildren],
|
||||
)
|
||||
|
||||
const [editState, setEditState] = useState<EditState>(() => ({
|
||||
source: elementChildren,
|
||||
edits: {},
|
||||
}))
|
||||
|
||||
const formValues = useMemo<FormValues>(() => {
|
||||
if (editState.source === elementChildren)
|
||||
return { ...baseFormValues, ...editState.edits }
|
||||
return baseFormValues
|
||||
}, [editState, baseFormValues, elementChildren])
|
||||
|
||||
const updateValue = useCallback((name: string, value: FormValue) => {
|
||||
if (!isSafeName(name))
|
||||
return
|
||||
setEditState(prev => ({
|
||||
source: elementChildren,
|
||||
edits: {
|
||||
...(prev.source === elementChildren ? prev.edits : {}),
|
||||
[name]: value,
|
||||
},
|
||||
}))
|
||||
}, [elementChildren])
|
||||
|
||||
const getFormOutput = useCallback((): Record<string, string | boolean | undefined> => {
|
||||
const out = Object.create(null) as Record<string, string | boolean | undefined>
|
||||
for (const child of elementChildren) {
|
||||
if (child.tagName !== SUPPORTED_TAGS.INPUT && child.tagName !== SUPPORTED_TAGS.TEXTAREA)
|
||||
continue
|
||||
const name = child.properties.name
|
||||
if (!isSafeName(name))
|
||||
continue
|
||||
let value: FormValue = formValues[name]
|
||||
if (
|
||||
child.tagName === SUPPORTED_TAGS.INPUT
|
||||
&& (child.properties.type === SUPPORTED_TYPES.DATE || child.properties.type === SUPPORTED_TYPES.DATETIME)
|
||||
&& value != null
|
||||
&& typeof value === 'object'
|
||||
&& 'format' in value
|
||||
) {
|
||||
const includeTime = child.properties.type === SUPPORTED_TYPES.DATETIME
|
||||
value = formatDateForOutput(value as Dayjs, includeTime)
|
||||
}
|
||||
if (typeof value === 'boolean')
|
||||
out[name] = value
|
||||
else
|
||||
out[name] = value != null ? String(value) : undefined
|
||||
}
|
||||
return out
|
||||
}, [elementChildren, formValues])
|
||||
|
||||
const onSubmit = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
if (isSubmitting)
|
||||
return
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const format = str(typedNode.properties.dataFormat) || DATA_FORMAT.TEXT
|
||||
const result = getFormOutput()
|
||||
if (format === DATA_FORMAT.JSON) {
|
||||
onSend?.(JSON.stringify(result))
|
||||
}
|
||||
else {
|
||||
const textResult = Object.entries(result)
|
||||
.map(([key, value]) => `${key}: ${value}`)
|
||||
.join('\n')
|
||||
onSend?.(textResult)
|
||||
}
|
||||
}
|
||||
catch {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}, [isSubmitting, typedNode.properties.dataFormat, getFormOutput, onSend])
|
||||
|
||||
return (
|
||||
<form
|
||||
autoComplete="off"
|
||||
className="flex flex-col self-stretch"
|
||||
data-testid="markdown-form"
|
||||
onSubmit={(e: any) => {
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}}
|
||||
>
|
||||
{node.children.filter((i: any) => i.type === 'element').map((child: any, index: number) => {
|
||||
{elementChildren.map((child, index) => {
|
||||
const key = getElementKey(child, index)
|
||||
if (child.tagName === SUPPORTED_TAGS.LABEL) {
|
||||
return (
|
||||
<label
|
||||
key={index}
|
||||
htmlFor={child.properties.htmlFor || child.properties.name}
|
||||
key={key}
|
||||
htmlFor={str(child.properties.htmlFor || child.properties.name)}
|
||||
className="my-2 text-text-secondary system-md-semibold"
|
||||
data-testid="label-field"
|
||||
>
|
||||
{child.children[0]?.value || ''}
|
||||
{getTextContent(child)}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
if (child.tagName === SUPPORTED_TAGS.INPUT && Object.values(SUPPORTED_TYPES).includes(child.properties.type)) {
|
||||
if (child.properties.type === SUPPORTED_TYPES.DATE || child.properties.type === SUPPORTED_TYPES.DATETIME) {
|
||||
|
||||
if (child.tagName === SUPPORTED_TAGS.INPUT && SUPPORTED_TYPES_SET.has(str(child.properties.type))) {
|
||||
const name = str(child.properties.name)
|
||||
if (!isSafeName(name))
|
||||
return null
|
||||
|
||||
const type = str(child.properties.type) as SUPPORTED_TYPES
|
||||
|
||||
if (type === SUPPORTED_TYPES.DATE || type === SUPPORTED_TYPES.DATETIME) {
|
||||
return (
|
||||
<DatePicker
|
||||
key={index}
|
||||
value={formValues[child.properties.name]}
|
||||
needTimePicker={child.properties.type === SUPPORTED_TYPES.DATETIME}
|
||||
onChange={(date) => {
|
||||
setFormValues(prevValues => ({
|
||||
...prevValues,
|
||||
[child.properties.name]: date,
|
||||
}))
|
||||
}}
|
||||
onClear={() => {
|
||||
setFormValues(prevValues => ({
|
||||
...prevValues,
|
||||
[child.properties.name]: undefined,
|
||||
}))
|
||||
}}
|
||||
key={key}
|
||||
value={formValues[name] as Dayjs | undefined}
|
||||
needTimePicker={type === SUPPORTED_TYPES.DATETIME}
|
||||
onChange={date => updateValue(name, date)}
|
||||
onClear={() => updateValue(name, undefined)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (child.properties.type === SUPPORTED_TYPES.TIME) {
|
||||
if (type === SUPPORTED_TYPES.TIME) {
|
||||
return (
|
||||
<TimePicker
|
||||
key={index}
|
||||
value={formValues[child.properties.name]}
|
||||
onChange={(time) => {
|
||||
setFormValues(prevValues => ({
|
||||
...prevValues,
|
||||
[child.properties.name]: time,
|
||||
}))
|
||||
}}
|
||||
onClear={() => {
|
||||
setFormValues(prevValues => ({
|
||||
...prevValues,
|
||||
[child.properties.name]: undefined,
|
||||
}))
|
||||
}}
|
||||
key={key}
|
||||
value={formValues[name] as Dayjs | string | undefined}
|
||||
onChange={time => updateValue(name, time)}
|
||||
onClear={() => updateValue(name, undefined)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (child.properties.type === SUPPORTED_TYPES.CHECKBOX) {
|
||||
if (type === SUPPORTED_TYPES.CHECKBOX) {
|
||||
return (
|
||||
<div className="mt-2 flex h-6 items-center space-x-2" key={index}>
|
||||
<div className="mt-2 flex h-6 items-center space-x-2" key={key}>
|
||||
<Checkbox
|
||||
key={index}
|
||||
checked={formValues[child.properties.name]}
|
||||
onCheck={() => {
|
||||
setFormValues(prevValues => ({
|
||||
...prevValues,
|
||||
[child.properties.name]: !prevValues[child.properties.name],
|
||||
}))
|
||||
}}
|
||||
id={child.properties.name}
|
||||
checked={!!formValues[name]}
|
||||
onCheck={() => updateValue(name, !formValues[name])}
|
||||
id={name}
|
||||
/>
|
||||
<span>{child.properties.dataTip || child.properties['data-tip'] || ''}</span>
|
||||
<span>{str(child.properties.dataTip || child.properties['data-tip'])}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (child.properties.type === SUPPORTED_TYPES.SELECT) {
|
||||
if (type === SUPPORTED_TYPES.SELECT) {
|
||||
const rawOptions = child.properties.dataOptions || child.properties['data-options'] || []
|
||||
let options: string[] = []
|
||||
if (typeof rawOptions === 'string') {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(rawOptions)
|
||||
if (Array.isArray(parsed))
|
||||
options = parsed.filter((o): o is string => typeof o === 'string')
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Failed to parse data-options JSON:', rawOptions, error)
|
||||
options = []
|
||||
}
|
||||
}
|
||||
else if (Array.isArray(rawOptions)) {
|
||||
options = rawOptions.filter((o): o is string => typeof o === 'string')
|
||||
}
|
||||
return (
|
||||
<Select
|
||||
key={index}
|
||||
allowSearch={false}
|
||||
className="w-full"
|
||||
items={(() => {
|
||||
let options = child.properties.dataOptions || child.properties['data-options'] || []
|
||||
if (typeof options === 'string') {
|
||||
try {
|
||||
options = JSON.parse(options)
|
||||
}
|
||||
catch (e) {
|
||||
console.error('Failed to parse options:', e)
|
||||
options = []
|
||||
}
|
||||
}
|
||||
return options.map((option: string) => ({
|
||||
name: option,
|
||||
value: option,
|
||||
}))
|
||||
})()}
|
||||
defaultValue={formValues[child.properties.name]}
|
||||
onSelect={(item) => {
|
||||
setFormValues(prevValues => ({
|
||||
...prevValues,
|
||||
[child.properties.name]: item.value,
|
||||
}))
|
||||
}}
|
||||
/>
|
||||
key={key}
|
||||
defaultValue={formValues[name] as string | undefined}
|
||||
onValueChange={val => updateValue(name, val as string)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map(option => (
|
||||
<SelectItem key={option} value={option}>{option}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
if (child.properties.type === SUPPORTED_TYPES.HIDDEN) {
|
||||
if (type === SUPPORTED_TYPES.HIDDEN) {
|
||||
return (
|
||||
<input
|
||||
key={index}
|
||||
key={key}
|
||||
type="hidden"
|
||||
name={child.properties.name}
|
||||
value={formValues[child.properties.name] || child.properties.value || ''}
|
||||
name={name}
|
||||
value={str(formValues[name] ?? child.properties.value)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
key={index}
|
||||
type={child.properties.type}
|
||||
name={child.properties.name}
|
||||
placeholder={child.properties.placeholder}
|
||||
value={formValues[child.properties.name]}
|
||||
onChange={(e) => {
|
||||
setFormValues(prevValues => ({
|
||||
...prevValues,
|
||||
[child.properties.name]: e.target.value,
|
||||
}))
|
||||
}}
|
||||
key={key}
|
||||
type={type}
|
||||
name={name}
|
||||
placeholder={str(child.properties.placeholder)}
|
||||
value={str(formValues[name])}
|
||||
onChange={e => updateValue(name, e.target.value)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (child.tagName === SUPPORTED_TAGS.TEXTAREA) {
|
||||
const name = str(child.properties.name)
|
||||
if (!isSafeName(name))
|
||||
return null
|
||||
return (
|
||||
<Textarea
|
||||
key={index}
|
||||
name={child.properties.name}
|
||||
placeholder={child.properties.placeholder}
|
||||
value={formValues[child.properties.name]}
|
||||
onChange={(e) => {
|
||||
setFormValues(prevValues => ({
|
||||
...prevValues,
|
||||
[child.properties.name]: e.target.value,
|
||||
}))
|
||||
}}
|
||||
key={key}
|
||||
name={name}
|
||||
placeholder={str(child.properties.placeholder)}
|
||||
value={str(formValues[name])}
|
||||
onChange={e => updateValue(name, e.target.value)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (child.tagName === SUPPORTED_TAGS.BUTTON) {
|
||||
const variant = child.properties.dataVariant
|
||||
const size = child.properties.dataSize
|
||||
const rawVariant = str(child.properties.dataVariant)
|
||||
const rawSize = str(child.properties.dataSize)
|
||||
const variant = VALID_BUTTON_VARIANTS.has(rawVariant)
|
||||
? rawVariant as ButtonProps['variant']
|
||||
: undefined
|
||||
const size = VALID_BUTTON_SIZES.has(rawSize)
|
||||
? rawSize as ButtonProps['size']
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant={variant}
|
||||
size={size}
|
||||
className="mt-4"
|
||||
key={index}
|
||||
key={key}
|
||||
disabled={isSubmitting}
|
||||
onClick={onSubmit}
|
||||
>
|
||||
<span className="text-[13px]">{child.children[0]?.value || ''}</span>
|
||||
<span className="text-[13px]">{getTextContent(child)}</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<p key={index}>
|
||||
<p key={key}>
|
||||
Unsupported tag:
|
||||
{child.tagName}
|
||||
</p>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -1,11 +1,10 @@
|
||||
import type { SimplePluginInfo } from '../markdown/react-markdown-wrapper'
|
||||
import type { SimplePluginInfo } from '../markdown/streamdown-wrapper'
|
||||
/**
|
||||
* @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 * 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>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@ -1,19 +1,25 @@
|
||||
import type { SimplePluginInfo } from '../markdown/react-markdown-wrapper'
|
||||
import type { ExtraProps } from 'streamdown'
|
||||
import type { SimplePluginInfo } from '../markdown/streamdown-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, hasImageChild } 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
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
@ -1,4 +1,5 @@
|
||||
import type { SimplePluginInfo } from '../react-markdown-wrapper'
|
||||
import type { StreamdownProps } from 'streamdown'
|
||||
import type { SimplePluginInfo } from '../streamdown-wrapper'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { Markdown } from '../index'
|
||||
|
||||
@ -16,9 +17,11 @@ vi.mock('next/dynamic', () => ({
|
||||
type CapturedProps = {
|
||||
latexContent: string
|
||||
pluginInfo?: SimplePluginInfo
|
||||
customComponents?: Record<string, unknown>
|
||||
customComponents?: StreamdownProps['components']
|
||||
customDisallowedElements?: string[]
|
||||
rehypePlugins?: unknown[]
|
||||
rehypePlugins?: StreamdownProps['rehypePlugins']
|
||||
isAnimating?: StreamdownProps['isAnimating']
|
||||
mode?: StreamdownProps['mode']
|
||||
}
|
||||
|
||||
const getLastWrapperProps = (): CapturedProps => {
|
||||
@ -99,7 +102,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()
|
||||
@ -120,4 +123,16 @@ describe('Markdown', () => {
|
||||
const props = getLastWrapperProps()
|
||||
expect(props.rehypePlugins).toBe(rehypePlugins)
|
||||
})
|
||||
|
||||
it('should pass isAnimating through', () => {
|
||||
render(<Markdown content="content" isAnimating={true} />)
|
||||
const props = getLastWrapperProps()
|
||||
expect(props.isAnimating).toBe(true)
|
||||
})
|
||||
|
||||
it('should pass mode through', () => {
|
||||
render(<Markdown content="content" mode="streaming" />)
|
||||
const props = getLastWrapperProps()
|
||||
expect(props.mode).toBe('streaming')
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import type { PropsWithChildren, ReactNode } from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { ReactMarkdownWrapper } from '../react-markdown-wrapper'
|
||||
import StreamdownWrapper from '../streamdown-wrapper'
|
||||
|
||||
const TILDE_RANGE_RE = /0\.3~8mm/
|
||||
|
||||
vi.mock('@/app/components/base/markdown-blocks', () => ({
|
||||
AudioBlock: ({ children }: PropsWithChildren) => <div data-testid="audio-block">{children}</div>,
|
||||
@ -20,7 +22,7 @@ vi.mock('@/app/components/base/markdown-blocks/code-block', () => ({
|
||||
default: ({ children }: PropsWithChildren) => <code>{children}</code>,
|
||||
}))
|
||||
|
||||
describe('ReactMarkdownWrapper', () => {
|
||||
describe('StreamdownWrapper', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
@ -31,11 +33,11 @@ describe('ReactMarkdownWrapper', () => {
|
||||
const content = 'Range: 0.3~8mm'
|
||||
|
||||
// Act
|
||||
render(<ReactMarkdownWrapper latexContent={content} />)
|
||||
render(<StreamdownWrapper latexContent={content} />)
|
||||
|
||||
// Assert - check that ~ is rendered as text, not as strikethrough (del element)
|
||||
// The content should contain the tilde as literal text
|
||||
expect(screen.getByText(/0\.3~8mm/)).toBeInTheDocument()
|
||||
expect(screen.getByText(TILDE_RANGE_RE)).toBeInTheDocument()
|
||||
expect(document.querySelector('del')).toBeNull()
|
||||
})
|
||||
|
||||
@ -44,7 +46,7 @@ describe('ReactMarkdownWrapper', () => {
|
||||
const content = 'This is ~~strikethrough~~ text'
|
||||
|
||||
// Act
|
||||
render(<ReactMarkdownWrapper latexContent={content} />)
|
||||
render(<StreamdownWrapper latexContent={content} />)
|
||||
|
||||
// Assert - del element should be present for double tildes
|
||||
const delElement = document.querySelector('del')
|
||||
@ -57,7 +59,7 @@ describe('ReactMarkdownWrapper', () => {
|
||||
const content = 'PCB thickness: 0.3~8mm and ~~removed feature~~ text'
|
||||
|
||||
// Act
|
||||
render(<ReactMarkdownWrapper latexContent={content} />)
|
||||
render(<StreamdownWrapper latexContent={content} />)
|
||||
|
||||
// Assert
|
||||
// Only double tildes should create strikethrough
|
||||
@ -66,7 +68,7 @@ describe('ReactMarkdownWrapper', () => {
|
||||
expect(delElements[0].textContent).toBe('removed feature')
|
||||
|
||||
// Single tilde should remain as literal text
|
||||
expect(screen.getByText(/0\.3~8mm/)).toBeInTheDocument()
|
||||
expect(screen.getByText(TILDE_RANGE_RE)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -76,7 +78,7 @@ describe('ReactMarkdownWrapper', () => {
|
||||
const content = 'Hello World'
|
||||
|
||||
// Act
|
||||
render(<ReactMarkdownWrapper latexContent={content} />)
|
||||
render(<StreamdownWrapper latexContent={content} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Hello World')).toBeInTheDocument()
|
||||
@ -87,11 +89,11 @@ describe('ReactMarkdownWrapper', () => {
|
||||
const content = '**bold text**'
|
||||
|
||||
// Act
|
||||
render(<ReactMarkdownWrapper latexContent={content} />)
|
||||
render(<StreamdownWrapper latexContent={content} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('bold text')).toBeInTheDocument()
|
||||
expect(document.querySelector('strong')).not.toBeNull()
|
||||
expect(document.querySelector('[data-streamdown="strong"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('should render italic text', () => {
|
||||
@ -99,7 +101,7 @@ describe('ReactMarkdownWrapper', () => {
|
||||
const content = '*italic text*'
|
||||
|
||||
// Act
|
||||
render(<ReactMarkdownWrapper latexContent={content} />)
|
||||
render(<StreamdownWrapper latexContent={content} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('italic text')).toBeInTheDocument()
|
||||
@ -108,7 +110,7 @@ describe('ReactMarkdownWrapper', () => {
|
||||
|
||||
it('should render standard Image component when pluginInfo is not provided', () => {
|
||||
// Act
|
||||
render(<ReactMarkdownWrapper latexContent="" />)
|
||||
render(<StreamdownWrapper latexContent="" />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('img')).toBeInTheDocument()
|
||||
@ -119,7 +121,7 @@ describe('ReactMarkdownWrapper', () => {
|
||||
const content = '```javascript\nconsole.log("hello")\n```'
|
||||
|
||||
// Act
|
||||
render(<ReactMarkdownWrapper latexContent={content} />)
|
||||
render(<StreamdownWrapper latexContent={content} />)
|
||||
|
||||
// Assert
|
||||
// We mocked code block to return <code>{children}</code>
|
||||
@ -135,7 +137,7 @@ describe('ReactMarkdownWrapper', () => {
|
||||
const pluginInfo = { pluginUniqueIdentifier: 'test-plugin', pluginId: 'plugin-1' }
|
||||
|
||||
// Act
|
||||
render(<ReactMarkdownWrapper latexContent={content} pluginInfo={pluginInfo} />)
|
||||
render(<StreamdownWrapper latexContent={content} pluginInfo={pluginInfo} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('plugin-img')).toBeInTheDocument()
|
||||
@ -154,7 +156,7 @@ describe('ReactMarkdownWrapper', () => {
|
||||
}
|
||||
|
||||
// Act
|
||||
render(<ReactMarkdownWrapper latexContent="[link](https://example.com)" customComponents={customComponents} />)
|
||||
render(<StreamdownWrapper latexContent="[link](https://example.com)" customComponents={customComponents} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('custom-link')).toBeInTheDocument()
|
||||
@ -162,28 +164,30 @@ describe('ReactMarkdownWrapper', () => {
|
||||
|
||||
it('should disallow customDisallowedElements', () => {
|
||||
// Act - disallow strong (which is usually **bold**)
|
||||
render(<ReactMarkdownWrapper latexContent="**bold**" customDisallowedElements={['strong']} />)
|
||||
render(<StreamdownWrapper latexContent="**bold**" customDisallowedElements={['strong']} />)
|
||||
|
||||
// Assert - strong element shouldn't be rendered (it will be stripped out)
|
||||
expect(document.querySelector('strong')).toBeNull()
|
||||
expect(document.querySelector('[data-streamdown="strong"]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Rehype AST modification', () => {
|
||||
it('should remove ref attributes from elements', () => {
|
||||
// Act
|
||||
render(<ReactMarkdownWrapper latexContent={'<div ref="someRef">content</div>'} />)
|
||||
render(<StreamdownWrapper latexContent={'<div ref="someRef">content</div>'} />)
|
||||
|
||||
// Assert - If ref isn't stripped, it gets passed to React DOM causing warnings, but here we just ensure content renders
|
||||
// Assert - ref attribute should be removed
|
||||
expect(screen.getByText('content')).toBeInTheDocument()
|
||||
expect(document.querySelector('[ref="someRef"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('should convert invalid tag names to text nodes', () => {
|
||||
// Act - <custom-element> is invalid because it contains a hyphen
|
||||
render(<ReactMarkdownWrapper latexContent="<custom-element>content</custom-element>" />)
|
||||
it('should strip disallowed tags but preserve their text content', () => {
|
||||
// Act - <custom-element> is not in the allowed tag list
|
||||
render(<StreamdownWrapper latexContent="<custom-element>content</custom-element>" />)
|
||||
|
||||
// Assert - The AST node is changed to text with value `<custom-element`
|
||||
expect(screen.getByText(/<custom-element/)).toBeInTheDocument()
|
||||
// Assert - rehype-sanitize strips the tag but keeps inner text
|
||||
expect(screen.getByText('content')).toBeInTheDocument()
|
||||
expect(document.querySelector('custom-element')).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,11 +1,15 @@
|
||||
import type { ReactMarkdownWrapperProps, SimplePluginInfo } from './react-markdown-wrapper'
|
||||
import type { SimplePluginInfo, StreamdownWrapperProps } from './streamdown-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 StreamdownWrapper = dynamic(() => import('./streamdown-wrapper'), { ssr: false })
|
||||
|
||||
const preprocess = flow([preprocessThinkTag, preprocessLaTeX])
|
||||
|
||||
const EMPTY_COMPONENTS = {} as const
|
||||
|
||||
/**
|
||||
* @fileoverview Main Markdown rendering component.
|
||||
@ -18,24 +22,39 @@ export type MarkdownProps = {
|
||||
content: string
|
||||
className?: string
|
||||
pluginInfo?: SimplePluginInfo
|
||||
} & Pick<ReactMarkdownWrapperProps, 'customComponents' | 'customDisallowedElements' | 'rehypePlugins'>
|
||||
} & Pick<
|
||||
StreamdownWrapperProps,
|
||||
'customComponents' | 'customDisallowedElements' | 'remarkPlugins' | 'rehypePlugins' | 'isAnimating' | 'mode'
|
||||
>
|
||||
|
||||
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,
|
||||
remarkPlugins,
|
||||
rehypePlugins,
|
||||
mode,
|
||||
className,
|
||||
} = props
|
||||
const latexContent = useMemo(() => preprocess(content), [content])
|
||||
|
||||
return (
|
||||
<div className={cn('markdown-body', '!text-text-primary', props.className)}>
|
||||
<ReactMarkdown
|
||||
<div className={cn('markdown-body', '!text-text-primary', className)}>
|
||||
<StreamdownWrapper
|
||||
pluginInfo={pluginInfo}
|
||||
latexContent={latexContent}
|
||||
customComponents={customComponents}
|
||||
customDisallowedElements={props.customDisallowedElements}
|
||||
rehypePlugins={props.rehypePlugins}
|
||||
customDisallowedElements={customDisallowedElements}
|
||||
remarkPlugins={remarkPlugins}
|
||||
rehypePlugins={rehypePlugins}
|
||||
isAnimating={isAnimating}
|
||||
mode={mode}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
Markdown.displayName = 'Markdown'
|
||||
|
||||
@ -1,81 +0,0 @@
|
||||
import type { FC } from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
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 { 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 { 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
|
||||
rehypePlugins?: any// js: PluggableList[]
|
||||
}
|
||||
|
||||
export const ReactMarkdownWrapper: FC<ReactMarkdownWrapperProps> = (props) => {
|
||||
const { customComponents, latexContent, pluginInfo } = props
|
||||
|
||||
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 || []),
|
||||
]}
|
||||
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>
|
||||
)
|
||||
}
|
||||
223
web/app/components/base/markdown/streamdown-wrapper.tsx
Normal file
223
web/app/components/base/markdown/streamdown-wrapper.tsx
Normal file
@ -0,0 +1,223 @@
|
||||
import type { ComponentType } from 'react'
|
||||
import type { Components, StreamdownProps } from 'streamdown'
|
||||
import { createMathPlugin } from '@streamdown/math'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { memo, useMemo } from 'react'
|
||||
import RemarkBreaks from 'remark-breaks'
|
||||
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]
|
||||
|
||||
type AttributeDefinition = string | [string, ...(string | boolean | RegExp)[]]
|
||||
|
||||
type SanitizeSchema = {
|
||||
tagNames?: string[]
|
||||
attributes?: Record<string, AttributeDefinition[]>
|
||||
required?: Record<string, Record<string, unknown>>
|
||||
clobber?: string[]
|
||||
clobberPrefix?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
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`).
|
||||
*
|
||||
* Prefer explicit attribute lists over wildcards (e.g. `data*`) to
|
||||
* minimise the attack surface when LLM-generated content is rendered.
|
||||
*/
|
||||
const ALLOWED_TAGS: Record<string, string[]> = {
|
||||
button: ['dataVariant', 'dataSize', 'dataMessage', 'dataLink'],
|
||||
form: ['dataFormat'],
|
||||
input: ['type', 'name', 'value', 'placeholder', 'checked', 'dataTip', 'dataOptions'],
|
||||
textarea: ['name', 'placeholder', 'value'],
|
||||
label: ['htmlFor'],
|
||||
details: ['dataThink'],
|
||||
video: ['src'],
|
||||
audio: ['src'],
|
||||
source: ['src'],
|
||||
mark: [],
|
||||
sub: [],
|
||||
sup: [],
|
||||
kbd: [],
|
||||
// custom tags from human input node
|
||||
variable: ['dataPath'],
|
||||
section: ['dataName'],
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
const [sanitizePlugin, defaultSanitizeSchema]
|
||||
= defaultRehypePlugins.sanitize as [Pluggable, SanitizeSchema]
|
||||
|
||||
const tagNamesSet = new Set([
|
||||
...(defaultSanitizeSchema.tagNames ?? []),
|
||||
...Object.keys(ALLOWED_TAGS),
|
||||
])
|
||||
|
||||
const mergedAttributes: Record<string, AttributeDefinition[]> = {
|
||||
...(defaultSanitizeSchema.attributes ?? {}),
|
||||
}
|
||||
|
||||
for (const tag of Object.keys(ALLOWED_TAGS)) {
|
||||
const existing = mergedAttributes[tag]
|
||||
if (existing) {
|
||||
// When we add an unrestricted attribute (bare string), remove any
|
||||
// existing restricted tuple for the same name. hast-util-sanitize's
|
||||
// `findDefinition` returns the *first* match, so a restricted tuple
|
||||
// like `['type','checkbox']` would shadow our unrestricted `'type'`.
|
||||
const overrideNames = new Set(ALLOWED_TAGS[tag])
|
||||
const filtered = existing.filter((entry) => {
|
||||
const name = typeof entry === 'string' ? entry : entry[0]
|
||||
return !overrideNames.has(name as string)
|
||||
})
|
||||
mergedAttributes[tag] = [...filtered, ...ALLOWED_TAGS[tag]]
|
||||
}
|
||||
else {
|
||||
mergedAttributes[tag] = ALLOWED_TAGS[tag]
|
||||
}
|
||||
}
|
||||
|
||||
// The default schema forces `input` to be `{disabled:true, type:'checkbox'}`
|
||||
// via `required`. Drop that so form inputs keep their original attributes.
|
||||
const { input: _inputRequired, ...requiredRest }
|
||||
= (defaultSanitizeSchema.required ?? {})
|
||||
|
||||
// `name` is in the default `clobber` list, which prefixes every `name` value
|
||||
// with `user-content-`. Form fields need the original `name`, and our form
|
||||
// component validates names with `isSafeName()`, so remove it.
|
||||
const clobber = (defaultSanitizeSchema.clobber ?? []).filter(k => k !== 'name')
|
||||
|
||||
const customSchema: SanitizeSchema = {
|
||||
...defaultSanitizeSchema,
|
||||
tagNames: [...tagNamesSet],
|
||||
attributes: mergedAttributes,
|
||||
required: requiredRest,
|
||||
clobber,
|
||||
}
|
||||
|
||||
return [
|
||||
defaultRehypePlugins.raw,
|
||||
...(extraPlugins ?? []),
|
||||
[sanitizePlugin, customSchema] as Pluggable,
|
||||
defaultRehypePlugins.harden,
|
||||
]
|
||||
}
|
||||
|
||||
export type SimplePluginInfo = {
|
||||
pluginUniqueIdentifier: string
|
||||
pluginId: string
|
||||
}
|
||||
|
||||
export type StreamdownWrapperProps = {
|
||||
latexContent: string
|
||||
customDisallowedElements?: string[]
|
||||
customComponents?: Components
|
||||
pluginInfo?: SimplePluginInfo
|
||||
remarkPlugins?: StreamdownProps['remarkPlugins']
|
||||
rehypePlugins?: StreamdownProps['rehypePlugins']
|
||||
isAnimating?: boolean
|
||||
className?: string
|
||||
mode?: StreamdownProps['mode']
|
||||
}
|
||||
|
||||
const StreamdownWrapper = (props: StreamdownWrapperProps) => {
|
||||
const {
|
||||
customComponents,
|
||||
latexContent,
|
||||
pluginInfo,
|
||||
isAnimating,
|
||||
className,
|
||||
mode = 'streaming',
|
||||
} = props
|
||||
|
||||
const remarkPlugins = useMemo(
|
||||
() => [
|
||||
[Array.isArray(defaultRemarkPlugins.gfm) ? defaultRemarkPlugins.gfm[0] : defaultRemarkPlugins.gfm, { singleTilde: false }] as Pluggable,
|
||||
RemarkBreaks,
|
||||
...(props.remarkPlugins ?? []),
|
||||
],
|
||||
[props.remarkPlugins],
|
||||
)
|
||||
|
||||
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 as ComponentType,
|
||||
details: ThinkBlock as ComponentType,
|
||||
...customComponents,
|
||||
}),
|
||||
[pluginInfo, customComponents],
|
||||
)
|
||||
|
||||
return (
|
||||
<Streamdown
|
||||
className={className}
|
||||
remarkPlugins={remarkPlugins}
|
||||
rehypePlugins={rehypePlugins}
|
||||
plugins={plugins}
|
||||
urlTransform={customUrlTransform}
|
||||
disallowedElements={disallowedElements}
|
||||
components={components}
|
||||
isAnimating={isAnimating}
|
||||
mode={mode}
|
||||
>
|
||||
{latexContent}
|
||||
</Streamdown>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(StreamdownWrapper)
|
||||
@ -2,6 +2,14 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import mermaid from 'mermaid'
|
||||
import Flowchart from '../index'
|
||||
|
||||
const HAND_DRAWN_RE = /handDrawn/i
|
||||
const HAND_DRAWN_EXACT_RE = /handDrawn/
|
||||
const CLASSIC_RE = /classic/i
|
||||
const SWITCH_LIGHT_RE = /switchLight$/
|
||||
const SWITCH_DARK_RE = /switchDark$/
|
||||
const RENDERING_FAILED_RE = /Rendering failed/i
|
||||
const UNKNOWN_ERROR_RE = /Unknown error\. Please check the console\./i
|
||||
|
||||
vi.mock('mermaid', () => ({
|
||||
default: {
|
||||
initialize: vi.fn(),
|
||||
@ -101,7 +109,7 @@ describe('Mermaid Flowchart Component', () => {
|
||||
|
||||
await waitFor(() => screen.getByText('test-svg'), { timeout: 3000 })
|
||||
|
||||
const handDrawnBtn = screen.getByText(/handDrawn/i)
|
||||
const handDrawnBtn = screen.getByText(HAND_DRAWN_RE)
|
||||
await act(async () => {
|
||||
fireEvent.click(handDrawnBtn)
|
||||
})
|
||||
@ -110,7 +118,7 @@ describe('Mermaid Flowchart Component', () => {
|
||||
expect(screen.getByText('test-svg-api')).toBeInTheDocument()
|
||||
}, { timeout: 3000 })
|
||||
|
||||
const classicBtn = screen.getByText(/classic/i)
|
||||
const classicBtn = screen.getByText(CLASSIC_RE)
|
||||
await act(async () => {
|
||||
fireEvent.click(classicBtn)
|
||||
})
|
||||
@ -148,13 +156,13 @@ describe('Mermaid Flowchart Component', () => {
|
||||
const initialApiRenderCalls = vi.mocked(mermaid.mermaidAPI.render).mock.calls.length
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText(/classic/i))
|
||||
fireEvent.click(screen.getByText(CLASSIC_RE))
|
||||
})
|
||||
expect(vi.mocked(mermaid.render).mock.calls.length).toBe(initialRenderCalls)
|
||||
expect(vi.mocked(mermaid.mermaidAPI.render).mock.calls.length).toBe(initialApiRenderCalls)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText(/handDrawn/i))
|
||||
fireEvent.click(screen.getByText(HAND_DRAWN_RE))
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('test-svg-api')).toBeInTheDocument()
|
||||
@ -162,7 +170,7 @@ describe('Mermaid Flowchart Component', () => {
|
||||
|
||||
const afterFirstHandDrawnApiCalls = vi.mocked(mermaid.mermaidAPI.render).mock.calls.length
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText(/handDrawn/i))
|
||||
fireEvent.click(screen.getByText(HAND_DRAWN_RE))
|
||||
})
|
||||
expect(vi.mocked(mermaid.mermaidAPI.render).mock.calls.length).toBe(afterFirstHandDrawnApiCalls)
|
||||
})
|
||||
@ -180,14 +188,14 @@ describe('Mermaid Flowchart Component', () => {
|
||||
fireEvent.click(toggleBtn)
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button')).toHaveAttribute('title', expect.stringMatching(/switchLight$/))
|
||||
expect(screen.getByRole('button')).toHaveAttribute('title', expect.stringMatching(SWITCH_LIGHT_RE))
|
||||
}, { timeout: 3000 })
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button')).toHaveAttribute('title', expect.stringMatching(/switchDark$/))
|
||||
expect(screen.getByRole('button')).toHaveAttribute('title', expect.stringMatching(SWITCH_DARK_RE))
|
||||
}, { timeout: 3000 })
|
||||
})
|
||||
|
||||
@ -202,7 +210,7 @@ describe('Mermaid Flowchart Component', () => {
|
||||
}, { timeout: 3000 })
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText(/handDrawn/i))
|
||||
fireEvent.click(screen.getByText(HAND_DRAWN_RE))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
@ -253,7 +261,7 @@ describe('Mermaid Flowchart Component', () => {
|
||||
const uniqueCode = 'graph TD\n X-->Y\n Y-->Z'
|
||||
render(<Flowchart PrimitiveCode={uniqueCode} />)
|
||||
|
||||
const errorMessage = await screen.findByText(/Rendering failed/i)
|
||||
const errorMessage = await screen.findByText(RENDERING_FAILED_RE)
|
||||
expect(errorMessage).toBeInTheDocument()
|
||||
}
|
||||
finally {
|
||||
@ -267,7 +275,7 @@ describe('Mermaid Flowchart Component', () => {
|
||||
|
||||
try {
|
||||
render(<Flowchart PrimitiveCode={'graph TD\n P-->Q\n Q-->R'} />)
|
||||
expect(await screen.findByText(/Unknown error\. Please check the console\./i)).toBeInTheDocument()
|
||||
expect(await screen.findByText(UNKNOWN_ERROR_RE)).toBeInTheDocument()
|
||||
}
|
||||
finally {
|
||||
consoleSpy.mockRestore()
|
||||
@ -510,10 +518,10 @@ describe('Mermaid Flowchart Component Module Isolation', () => {
|
||||
|
||||
// Wait for initial render to complete
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/handDrawn/)).toBeInTheDocument()
|
||||
expect(screen.getByText(HAND_DRAWN_EXACT_RE)).toBeInTheDocument()
|
||||
}, { timeout: 3000 })
|
||||
|
||||
const handDrawnBtn = screen.getByText(/handDrawn/)
|
||||
const handDrawnBtn = screen.getByText(HAND_DRAWN_EXACT_RE)
|
||||
await act(async () => {
|
||||
fireEvent.click(handDrawnBtn)
|
||||
})
|
||||
@ -743,7 +751,7 @@ describe('Mermaid Flowchart Component Module Isolation', () => {
|
||||
const { default: FlowchartFresh } = await import('../index')
|
||||
const { rerender } = render(<FlowchartFresh PrimitiveCode="graph" />)
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText(/handDrawn/i))
|
||||
fireEvent.click(screen.getByText(HAND_DRAWN_RE))
|
||||
rerender(<FlowchartFresh PrimitiveCode={mockCode} />)
|
||||
await vi.advanceTimersByTimeAsync(350)
|
||||
})
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import { cleanUpSvgCode, isMermaidCodeComplete, prepareMermaidCode, processSvgForTheme, sanitizeMermaidCode, svgToBase64, waitForDOMElement } from '../utils'
|
||||
|
||||
const FILL_HEX_RE = /fill="#[a-fA-F0-9]{6}"/g
|
||||
|
||||
describe('cleanUpSvgCode', () => {
|
||||
it('should replace old-style <br> tags with self-closing <br/>', () => {
|
||||
const result = cleanUpSvgCode('<br>test<br>')
|
||||
@ -179,7 +181,7 @@ describe('processSvgForTheme', () => {
|
||||
it('should handle multiple node colors in cyclic manner', () => {
|
||||
const svg = '<rect fill="#ffffff" class="node-1"/><rect fill="#ffffff" class="node-2"/><rect fill="#ffffff" class="node-3"/>'
|
||||
const result = processSvgForTheme(svg, true, false, themes)
|
||||
const fillMatches = result.match(/fill="#[a-fA-F0-9]{6}"/g)
|
||||
const fillMatches = result.match(FILL_HEX_RE)
|
||||
expect(fillMatches).toContain('fill="#121212"')
|
||||
expect(fillMatches).toContain('fill="#222222"')
|
||||
expect(fillMatches?.filter(f => f === 'fill="#121212"').length).toBe(2)
|
||||
|
||||
@ -1,23 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { TanStackDevtools } from '@tanstack/react-devtools'
|
||||
import { formDevtoolsPlugin } from '@tanstack/react-form-devtools'
|
||||
import { ReactQueryDevtoolsPanel } from '@tanstack/react-query-devtools'
|
||||
import * as React from 'react'
|
||||
|
||||
export function TanStackDevtoolsWrapper() {
|
||||
return (
|
||||
<TanStackDevtools
|
||||
plugins={[
|
||||
// Query Devtools (Official Plugin)
|
||||
{
|
||||
name: 'React Query',
|
||||
render: () => <ReactQueryDevtoolsPanel />,
|
||||
},
|
||||
|
||||
// Form Devtools (Official Plugin)
|
||||
formDevtoolsPlugin(),
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -1,21 +1,27 @@
|
||||
'use client'
|
||||
|
||||
import { lazy, Suspense } from 'react'
|
||||
import { IS_DEV } from '@/config'
|
||||
import { TanStackDevtools } from '@tanstack/react-devtools'
|
||||
import { formDevtoolsPlugin } from '@tanstack/react-form-devtools'
|
||||
|
||||
const TanStackDevtoolsWrapper = lazy(() =>
|
||||
import('./devtools').then(module => ({
|
||||
default: module.TanStackDevtoolsWrapper,
|
||||
})),
|
||||
)
|
||||
import { ReactQueryDevtoolsPanel } from '@tanstack/react-query-devtools'
|
||||
import { IS_DEV } from '@/config'
|
||||
|
||||
export const TanStackDevtoolsLoader = () => {
|
||||
if (!IS_DEV)
|
||||
return null
|
||||
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<TanStackDevtoolsWrapper />
|
||||
</Suspense>
|
||||
<TanStackDevtools
|
||||
plugins={[
|
||||
// Query Devtools (Official Plugin)
|
||||
{
|
||||
name: 'React Query',
|
||||
render: () => <ReactQueryDevtoolsPanel />,
|
||||
},
|
||||
|
||||
// Form Devtools (Official Plugin)
|
||||
formDevtoolsPlugin(),
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -192,7 +192,7 @@ const Result: FC<IResultProps> = ({
|
||||
|
||||
const prompt_variables = promptConfig?.prompt_variables
|
||||
if (!prompt_variables || prompt_variables?.length === 0) {
|
||||
if (completionFiles.find(item => item.transfer_method === TransferMethod.local_file && !item.upload_file_id)) {
|
||||
if (completionFiles.some(item => item.transfer_method === TransferMethod.local_file && !item.upload_file_id)) {
|
||||
notify({ type: 'info', message: t('errorMessage.waitForFileUpload', { ns: 'appDebug' }) })
|
||||
return false
|
||||
}
|
||||
@ -219,7 +219,7 @@ const Result: FC<IResultProps> = ({
|
||||
return false
|
||||
}
|
||||
|
||||
if (completionFiles.find(item => item.transfer_method === TransferMethod.local_file && !item.upload_file_id)) {
|
||||
if (completionFiles.some(item => item.transfer_method === TransferMethod.local_file && !item.upload_file_id)) {
|
||||
notify({ type: 'info', message: t('errorMessage.waitForFileUpload', { ns: 'appDebug' }) })
|
||||
return false
|
||||
}
|
||||
|
||||
@ -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'
|
||||
@ -14,6 +13,7 @@ import { useStore } from '@/app/components/workflow/store'
|
||||
import useNodes from '@/app/components/workflow/store/workflow/use-nodes'
|
||||
import { Note, rehypeNotes, rehypeVariable, Variable } from './variable-in-markdown'
|
||||
|
||||
const NODE_ID_RE = /#([^#.]+)([.#])/g
|
||||
const i18nPrefix = 'nodes.humanInput'
|
||||
|
||||
type FormContentPreviewProps = {
|
||||
@ -47,25 +47,25 @@ 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 size-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?.dataPath ?? '')
|
||||
let newPath = path
|
||||
if (path) {
|
||||
newPath = path.replace(/#([^#.]+)([.#])/g, (match, nodeId, sep) => {
|
||||
newPath = path.replace(NODE_ID_RE, (match, nodeId, sep) => {
|
||||
return `#${nodeName(nodeId)}${sep}`
|
||||
})
|
||||
}
|
||||
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?.dataName ?? '')
|
||||
const input = formInputs.find(i => i.output_variable_name === name)
|
||||
if (!input) {
|
||||
return (
|
||||
@ -92,7 +92,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>
|
||||
)
|
||||
|
||||
@ -24,7 +24,7 @@ export function rehypeVariable() {
|
||||
parts.push({
|
||||
type: 'element',
|
||||
tagName: 'variable',
|
||||
properties: { 'data-path': m[0].trim() },
|
||||
properties: { dataPath: m[0].trim() },
|
||||
children: [],
|
||||
})
|
||||
|
||||
@ -77,7 +77,7 @@ export function rehypeNotes() {
|
||||
parts.push({
|
||||
type: 'element',
|
||||
tagName: 'section',
|
||||
properties: { 'data-name': name },
|
||||
properties: { dataName: name },
|
||||
children: [],
|
||||
})
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -599,6 +555,33 @@
|
||||
border-bottom: 1px solid var(--color-divider-subtle);
|
||||
}
|
||||
|
||||
/* streamdown table: bridge shadcn/ui tokens to Dify design system */
|
||||
[data-streamdown="table-wrapper"] {
|
||||
border-color: var(--color-divider-subtle);
|
||||
}
|
||||
|
||||
[data-streamdown="table-wrapper"] > div:has(> [data-streamdown="table"]) {
|
||||
border: none;
|
||||
}
|
||||
|
||||
[data-streamdown="table-wrapper"] > div:first-child button {
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
[data-streamdown="table-wrapper"] > div:first-child button:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
[data-streamdown="table-wrapper"] > div:first-child > div > div {
|
||||
background-color: var(--color-components-panel-bg);
|
||||
border-color: var(--color-divider-subtle);
|
||||
}
|
||||
|
||||
[data-streamdown="table-wrapper"] > div:first-child > div > div button:hover {
|
||||
color: var(--color-components-menu-item-text-hover);
|
||||
background-color: var(--color-components-menu-item-bg-hover);
|
||||
}
|
||||
|
||||
.markdown-body table img {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
@ -2101,9 +2101,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
|
||||
}
|
||||
@ -2132,15 +2129,9 @@
|
||||
}
|
||||
},
|
||||
"app/components/base/chat/chat/chat-input-area/index.tsx": {
|
||||
"e18e/prefer-array-some": {
|
||||
"count": 1
|
||||
},
|
||||
"e18e/prefer-static-regex": {
|
||||
"count": 1
|
||||
},
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 2
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 3
|
||||
}
|
||||
@ -2176,9 +2167,6 @@
|
||||
}
|
||||
},
|
||||
"app/components/base/chat/chat/hooks.ts": {
|
||||
"e18e/prefer-array-at": {
|
||||
"count": 5
|
||||
},
|
||||
"react-hooks-extra/no-direct-set-state-in-use-effect": {
|
||||
"count": 2
|
||||
},
|
||||
@ -2187,12 +2175,6 @@
|
||||
}
|
||||
},
|
||||
"app/components/base/chat/chat/index.tsx": {
|
||||
"e18e/prefer-array-at": {
|
||||
"count": 1
|
||||
},
|
||||
"e18e/prefer-timer-args": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks-extra/no-direct-set-state-in-use-effect": {
|
||||
"count": 1
|
||||
},
|
||||
@ -2229,9 +2211,6 @@
|
||||
}
|
||||
},
|
||||
"app/components/base/chat/embedded-chatbot/chat-wrapper.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 2
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 7
|
||||
}
|
||||
@ -3212,11 +3191,6 @@
|
||||
"count": 12
|
||||
}
|
||||
},
|
||||
"app/components/base/markdown-blocks/__tests__/form.spec.tsx": {
|
||||
"e18e/prefer-static-regex": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/base/markdown-blocks/__tests__/music.spec.tsx": {
|
||||
"e18e/prefer-static-regex": {
|
||||
"count": 2
|
||||
@ -3244,29 +3218,10 @@
|
||||
"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
|
||||
}
|
||||
},
|
||||
"app/components/base/markdown-blocks/form.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks-extra/no-direct-set-state-in-use-effect": {
|
||||
"count": 1
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 11
|
||||
}
|
||||
},
|
||||
"app/components/base/markdown-blocks/img.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/base/markdown-blocks/link.tsx": {
|
||||
"e18e/prefer-static-regex": {
|
||||
"count": 1
|
||||
@ -3288,19 +3243,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": {
|
||||
@ -3336,11 +3278,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"app/components/base/markdown/__tests__/react-markdown-wrapper.spec.tsx": {
|
||||
"e18e/prefer-static-regex": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"app/components/base/markdown/error-boundary.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 3
|
||||
@ -3354,24 +3291,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/base/markdown/react-markdown-wrapper.tsx": {
|
||||
"e18e/prefer-static-regex": {
|
||||
"count": 1
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 9
|
||||
}
|
||||
},
|
||||
"app/components/base/mermaid/__tests__/index.spec.tsx": {
|
||||
"e18e/prefer-static-regex": {
|
||||
"count": 13
|
||||
}
|
||||
},
|
||||
"app/components/base/mermaid/__tests__/utils.spec.ts": {
|
||||
"e18e/prefer-static-regex": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/base/mermaid/index.tsx": {
|
||||
"e18e/prefer-static-regex": {
|
||||
"count": 3
|
||||
@ -8903,9 +8822,6 @@
|
||||
}
|
||||
},
|
||||
"app/components/share/text-generation/result/index.tsx": {
|
||||
"e18e/prefer-array-some": {
|
||||
"count": 2
|
||||
},
|
||||
"react-hooks-extra/no-direct-set-state-in-use-effect": {
|
||||
"count": 3
|
||||
},
|
||||
@ -10673,14 +10589,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"app/components/workflow/nodes/human-input/components/form-content-preview.tsx": {
|
||||
"e18e/prefer-static-regex": {
|
||||
"count": 1
|
||||
},
|
||||
"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
|
||||
|
||||
@ -85,6 +85,7 @@
|
||||
"@orpc/tanstack-query": "1.13.6",
|
||||
"@remixicon/react": "4.9.0",
|
||||
"@sentry/react": "10.42.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.6",
|
||||
"react-hotkeys-hook": "5.2.4",
|
||||
"react-i18next": "16.5.6",
|
||||
"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.4",
|
||||
"sharp": "0.34.5",
|
||||
"sortablejs": "1.15.7",
|
||||
"streamdown": "2.3.0",
|
||||
"string-ts": "2.3.1",
|
||||
"tailwind-merge": "2.6.1",
|
||||
"tldts": "7.0.25",
|
||||
|
||||
136
web/pnpm-lock.yaml
generated
136
web/pnpm-lock.yaml
generated
@ -125,6 +125,9 @@ importers:
|
||||
'@sentry/react':
|
||||
specifier: 10.42.0
|
||||
version: 10.42.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
|
||||
@ -287,9 +290,6 @@ importers:
|
||||
react-i18next:
|
||||
specifier: 16.5.6
|
||||
version: 16.5.6(i18next@25.8.16(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.14)(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)
|
||||
@ -317,21 +317,9 @@ importers:
|
||||
reactflow:
|
||||
specifier: 11.11.4
|
||||
version: 11.11.4(@types/react@19.2.14)(immer@11.1.4)(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
|
||||
@ -344,6 +332,9 @@ importers:
|
||||
sortablejs:
|
||||
specifier: 1.15.7
|
||||
version: 1.15.7
|
||||
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
|
||||
@ -2823,6 +2814,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@5.10.0':
|
||||
resolution: {integrity: sha512-nPK52ZHvot8Ju/0A4ucSX1dcPV2/1clx0kLcH5wDmrE4naKso7TUC/voUyU1O9OTKTrR6MYip6LP0ogEMQ9jPQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
@ -5123,6 +5119,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==}
|
||||
|
||||
@ -5495,6 +5494,10 @@ packages:
|
||||
resolution: {integrity: sha512-cjHooZUmIAUmDsHBN+1n8LaZdpmbj03LtYeYPyuYB7OuloiaeaV6N4LcfjcnHVzGWjVQmKrxxTrpDcmSzEZQwQ==}
|
||||
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==}
|
||||
|
||||
@ -5723,6 +5726,11 @@ packages:
|
||||
engines: {node: '>= 20'}
|
||||
hasBin: true
|
||||
|
||||
marked@17.0.4:
|
||||
resolution: {integrity: sha512-NOmVMM+KAokHMvjWmC5N/ZOvgmSWuqJB8FoYI019j4ogb/PeRMKoKIjReZ2w3376kkA8dSJIP8uD993Kxc0iRQ==}
|
||||
engines: {node: '>= 20'}
|
||||
hasBin: true
|
||||
|
||||
mdast-util-find-and-replace@3.0.2:
|
||||
resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==}
|
||||
|
||||
@ -6484,12 +6492,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:
|
||||
@ -6651,6 +6653,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==}
|
||||
|
||||
@ -6660,6 +6665,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==}
|
||||
|
||||
@ -6681,6 +6689,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'}
|
||||
@ -6909,6 +6920,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==}
|
||||
|
||||
@ -7028,6 +7045,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'}
|
||||
@ -10111,6 +10131,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@5.10.0(eslint@10.0.3(jiti@1.21.7))':
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.9.1(eslint@10.0.3(jiti@1.21.7))
|
||||
@ -12812,6 +12841,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
|
||||
@ -13199,6 +13234,10 @@ snapshots:
|
||||
|
||||
jsx-ast-utils-x@0.1.0: {}
|
||||
|
||||
katex@0.16.33:
|
||||
dependencies:
|
||||
commander: 8.3.0
|
||||
|
||||
katex@0.16.38:
|
||||
dependencies:
|
||||
commander: 8.3.0
|
||||
@ -13415,6 +13454,8 @@ snapshots:
|
||||
|
||||
marked@16.4.2: {}
|
||||
|
||||
marked@17.0.4: {}
|
||||
|
||||
mdast-util-find-and-replace@3.0.2:
|
||||
dependencies:
|
||||
'@types/mdast': 4.0.4
|
||||
@ -14509,24 +14550,6 @@ snapshots:
|
||||
|
||||
react-is@17.0.2: {}
|
||||
|
||||
react-markdown@9.1.0(@types/react@19.2.14)(react@19.2.4):
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
'@types/mdast': 4.0.4
|
||||
'@types/react': 19.2.14
|
||||
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
|
||||
@ -14727,6 +14750,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
|
||||
@ -14751,6 +14778,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
|
||||
@ -14807,6 +14839,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: {}
|
||||
@ -15099,6 +15133,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.4
|
||||
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: {}
|
||||
@ -15206,6 +15262,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
|
||||
|
||||
@ -6,6 +6,8 @@ const config = {
|
||||
'./app/**/*.{js,ts,jsx,tsx}',
|
||||
'./components/**/*.{js,ts,jsx,tsx}',
|
||||
'./context/**/*.{js,ts,jsx,tsx}',
|
||||
'./node_modules/streamdown/dist/*.js',
|
||||
'./node_modules/@streamdown/math/dist/*.js',
|
||||
],
|
||||
...commonConfig,
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user