fix(web): improve markdown form rendering (#40070)

This commit is contained in:
Wu Tianwei 2026-08-06 13:49:24 +08:00 committed by GitHub
parent 046df1260a
commit e5a07e97a4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 313 additions and 9 deletions

View File

@ -14,6 +14,8 @@ MAX_ITERATIONS_NUM=99
TEXT_GENERATION_TIMEOUT_MS=60000
WORKFLOW_GENERATION_TIMEOUT_MS=180000
ALLOW_INLINE_STYLES=false
# Example: ()!*&()!*&-。.;+=—
MARKDOWN_FORM_FIELD_NAME_EXTRA_CHARS=
ALLOW_UNSAFE_DATA_SCHEME=false
MAX_TREE_DEPTH=50
MARKETPLACE_API_URL=https://marketplace.dify.ai

View File

@ -58,6 +58,10 @@ NEXT_PUBLIC_ALLOW_EMBED=
# Allow inline style attributes in Markdown rendering (self-hosted opt-in).
NEXT_PUBLIC_ALLOW_INLINE_STYLES=false
# Additional literal characters allowed in Markdown form field names.
# Example: ()!*&()!*&-。.;+=—
NEXT_PUBLIC_MARKDOWN_FORM_FIELD_NAME_EXTRA_CHARS=
# Allow rendering unsafe URLs which have "data:" scheme.
NEXT_PUBLIC_ALLOW_UNSAFE_DATA_SCHEME=false

View File

@ -1,5 +1,7 @@
describe('env runtime transport', () => {
const originalAgentV2Env = process.env.NEXT_PUBLIC_ENABLE_AGENT_V2
const originalMarkdownFormFieldNameExtraChars =
process.env.NEXT_PUBLIC_MARKDOWN_FORM_FIELD_NAME_EXTRA_CHARS
beforeEach(() => {
vi.clearAllMocks()
@ -7,12 +9,19 @@ describe('env runtime transport', () => {
vi.doUnmock('../utils/client')
document.body.removeAttribute('data-enable-agent-v2')
document.body.removeAttribute('data-enable-agent-v-2')
document.body.removeAttribute('data-markdown-form-field-name-extra-chars')
delete process.env.NEXT_PUBLIC_ENABLE_AGENT_V2
delete process.env.NEXT_PUBLIC_MARKDOWN_FORM_FIELD_NAME_EXTRA_CHARS
})
afterAll(() => {
if (originalAgentV2Env === undefined) delete process.env.NEXT_PUBLIC_ENABLE_AGENT_V2
else process.env.NEXT_PUBLIC_ENABLE_AGENT_V2 = originalAgentV2Env
if (originalMarkdownFormFieldNameExtraChars === undefined)
delete process.env.NEXT_PUBLIC_MARKDOWN_FORM_FIELD_NAME_EXTRA_CHARS
else
process.env.NEXT_PUBLIC_MARKDOWN_FORM_FIELD_NAME_EXTRA_CHARS =
originalMarkdownFormFieldNameExtraChars
})
it('should read NEXT_PUBLIC_ENABLE_AGENT_V2 from the browser runtime dataset key', async () => {
@ -37,4 +46,26 @@ describe('env runtime transport', () => {
expect(datasetMap['data-enable-agent-v2']).toBe(true)
expect(datasetMap['data-enable-agent-v-2']).toBeUndefined()
})
it('should read Markdown form field name extra characters from the browser runtime dataset', async () => {
document.body.setAttribute('data-markdown-form-field-name-extra-chars', '()!*&')
const { env } = await import('../env')
expect(env.NEXT_PUBLIC_MARKDOWN_FORM_FIELD_NAME_EXTRA_CHARS).toBe('()!*&')
})
it('should emit Markdown form field name extra characters in the server runtime dataset', async () => {
process.env.NEXT_PUBLIC_MARKDOWN_FORM_FIELD_NAME_EXTRA_CHARS = '()!*&'
vi.doMock('../utils/client', () => ({
isClient: false,
isServer: true,
}))
const { getDatasetMap } = await import('../env')
const datasetMap = getDatasetMap()
expect(datasetMap['data-markdown-form-field-name-extra-chars']).toBe('()!*&')
})
})

View File

@ -0,0 +1,63 @@
import type { ComponentProps } from 'react'
import { render, screen } from '@testing-library/react'
import MarkdownForm from '../form'
vi.mock('@/app/components/base/chat/chat/context', () => ({
useChatContext: () => ({}),
}))
vi.mock('@/config', async () => {
const actual = await vi.importActual<typeof import('@/config')>('@/config')
return {
...actual,
MARKDOWN_FORM_FIELD_NAME_EXTRA_CHARS: '',
}
})
describe('MarkdownForm default field name characters', () => {
it('should reject punctuation that requires explicit configuration', () => {
const node = {
type: 'element',
tagName: 'form',
properties: {},
children: [
{
type: 'element',
tagName: 'input',
properties: {
type: 'text',
name: '营业&售后SD',
placeholder: 'mixed-width-punctuation',
},
children: [],
},
{
type: 'element',
tagName: 'input',
properties: {
type: 'text',
name: '字段()!*&-',
placeholder: 'full-width-punctuation',
},
children: [],
},
{
type: 'element',
tagName: 'input',
properties: {
type: 'text',
name: 'field()!*&-',
placeholder: 'half-width-punctuation',
},
children: [],
},
],
} satisfies ComponentProps<typeof MarkdownForm>['node']
render(<MarkdownForm node={node} />)
expect(screen.queryByPlaceholderText('mixed-width-punctuation')).not.toBeInTheDocument()
expect(screen.queryByPlaceholderText('full-width-punctuation')).not.toBeInTheDocument()
expect(screen.queryByPlaceholderText('half-width-punctuation')).not.toBeInTheDocument()
})
})

View File

@ -48,6 +48,14 @@ vi.mock('@/app/components/base/date-and-time-picker/utils/dayjs', async () => {
}
})
vi.mock('@/config', async () => {
const actual = await vi.importActual<typeof import('@/config')>('@/config')
return {
...actual,
MARKDOWN_FORM_FIELD_NAME_EXTRA_CHARS: '()!*&()!*&-。.;+=—',
}
})
const createTextNode = (value: string): TextNode => ({
type: 'text',
value,
@ -144,6 +152,24 @@ describe('MarkdownForm', () => {
expect(mockOnSend).toHaveBeenCalledWith('name: Bob\nbio: Hi there')
})
})
it('should omit fields without initial values', async () => {
const user = userEvent.setup()
const node = createRootNode([
createElementNode('input', { type: 'text', name: 'name', value: 'Alice' }),
createElementNode('input', { type: 'text', name: 'department' }),
createElementNode('input', { type: 'checkbox', name: 'acceptTerms' }),
createElementNode('button', {}, [createTextNode('Submit')]),
])
render(<MarkdownForm node={node} />)
await user.click(screen.getByRole('button', { name: 'Submit' }))
await waitFor(() => {
expect(mockOnSend).toHaveBeenCalledWith('name: Alice')
})
})
})
// Emit serialized JSON when data-format requests JSON output.
@ -279,6 +305,29 @@ describe('MarkdownForm', () => {
// Checkbox interactions should update form state and be reflected in submission output.
describe('Checkbox interaction', () => {
it('should omit an untouched checkbox without an initial value', async () => {
const user = userEvent.setup()
const node = createRootNode(
[
createElementNode('input', {
type: 'checkbox',
name: 'acceptTerms',
dataTip: 'Accept terms',
}),
createElementNode('button', {}, [createTextNode('Submit')]),
],
{ dataFormat: 'json' },
)
render(<MarkdownForm node={node} />)
await user.click(screen.getByRole('button', { name: 'Submit' }))
await waitFor(() => {
expect(mockOnSend).toHaveBeenCalledWith('{}')
})
})
it('should toggle checkbox value and submit updated value', async () => {
const user = userEvent.setup()
const node = createRootNode([
@ -539,10 +588,15 @@ describe('MarkdownForm', () => {
// Unicode letters should be valid form field names.
describe('Unicode name support', () => {
it('should include fields whose names contain supported full-width and half-width punctuation', async () => {
it('should include fields whose names contain configured full-width and half-width punctuation', async () => {
const user = userEvent.setup()
const node = createRootNode(
[
createElementNode('input', {
type: 'hidden',
name: '营业&售后SD',
value: 'mixed-width',
}),
createElementNode('input', {
type: 'hidden',
name: '字段()!*&-',
@ -560,11 +614,48 @@ describe('MarkdownForm', () => {
await waitFor(() => {
expect(mockOnSend).toHaveBeenCalledWith(
'{"字段()!*&-":"full-width","field()!*&-":"half-width"}',
'{"营业&售后SD":"mixed-width","字段()!*&-":"full-width","field()!*&-":"half-width"}',
)
})
})
it('should include fields whose names contain configured extra characters', async () => {
const user = userEvent.setup()
const node = createRootNode(
[
createElementNode('input', {
type: 'hidden',
name: '字段。.;+=—',
value: 'configured',
}),
createElementNode('button', {}, [createTextNode('Submit')]),
],
{ dataFormat: 'json' },
)
render(<MarkdownForm node={node} />)
await user.click(screen.getByRole('button', { name: 'Submit' }))
await waitFor(() => {
expect(mockOnSend).toHaveBeenCalledWith('{"字段。.;+=—":"configured"}')
})
})
it('should reject extra characters that are not configured', () => {
const node = createRootNode([
createElementNode('input', {
type: 'text',
name: '字段/部门',
placeholder: 'unconfigured-character',
}),
])
render(<MarkdownForm node={node} />)
expect(screen.queryByPlaceholderText('unconfigured-character')).not.toBeInTheDocument()
})
it('should include Unicode-named fields from all supported controls in JSON output', async () => {
const user = userEvent.setup()
const node = createRootNode(

View File

@ -21,6 +21,7 @@ import {
toDayjs,
} from '@/app/components/base/date-and-time-picker/utils/dayjs'
import Input from '@/app/components/base/input'
import { MARKDOWN_FORM_FIELD_NAME_EXTRA_CHARS } from '@/config'
import { getMarkdownButtonAppearance } from './button-appearance'
const DATA_FORMAT = {
@ -54,20 +55,26 @@ const SUPPORTED_TYPES_SET = new Set<string>(Object.values(SUPPORTED_TYPES))
const SAFE_NAME_RE = (() => {
try {
return new RegExp('^\\p{L}[\\p{L}\\p{M}\\p{N}_()!*&-]*$', 'u')
return new RegExp('^\\p{L}[\\p{L}\\p{M}\\p{N}_-]*$', 'u')
} catch {
// Fallback for browsers without Unicode property escape support.
return /^[a-z][\w-]*$/i
}
})()
// Treat operator-provided characters literally instead of interpolating them into a regular expression.
const EXTRA_SAFE_NAME_CHARS = new Set(MARKDOWN_FORM_FIELD_NAME_EXTRA_CHARS)
const PROTOTYPE_POISON_KEYS = new Set(['__proto__', 'constructor', 'prototype'])
function isSafeName(name: unknown): name is string {
if (typeof name !== 'string' || name.length === 0 || name.length > 128) return false
const [firstChar, ...remainingChars] = Array.from(name)
return (
typeof name === 'string' &&
name.length > 0 &&
name.length <= 128 &&
SAFE_NAME_RE.test(name) &&
firstChar !== undefined &&
SAFE_NAME_RE.test(firstChar) &&
remainingChars.every(
(char) => SAFE_NAME_RE.test(`A${char}`) || EXTRA_SAFE_NAME_CHARS.has(char),
) &&
!PROTOTYPE_POISON_KEYS.has(name)
)
}
@ -126,7 +133,8 @@ function computeInitialFormValues(children: HastElement[]): FormValues {
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'
const hasInitialValue = checked != null || value != null
init[name] = hasInitialValue ? !!checked || value === true || value === 'true' : undefined
} else {
init[name] = child.properties.value != null ? str(child.properties.value) : undefined
}
@ -202,8 +210,9 @@ const MarkdownForm = ({ node }: { node: HastElement }) => {
const includeTime = child.properties.type === SUPPORTED_TYPES.DATETIME
value = formatDateForOutput(value as Dayjs, includeTime)
}
if (value === undefined) continue
if (typeof value === 'boolean') out[name] = value
else out[name] = value != null ? String(value) : undefined
else out[name] = String(value)
}
return out
}, [elementChildren, formValues])

View File

@ -0,0 +1,87 @@
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
const { mockOnSend } = vi.hoisted(() => ({
mockOnSend: vi.fn(),
}))
vi.mock('@/app/components/base/chat/chat/context', () => ({
useChatContext: () => ({ onSend: mockOnSend }),
}))
describe('StreamdownWrapper Markdown form field names', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.resetModules()
document.body.setAttribute(
'data-markdown-form-field-name-extra-chars',
'。.;+=—()!*&',
)
})
afterEach(() => {
document.body.removeAttribute('data-markdown-form-field-name-extra-chars')
})
it('should preserve and submit field names containing configured punctuation', async () => {
const user = userEvent.setup()
const { default: StreamdownWrapper } = await import('../streamdown-wrapper')
const content = `
<form data-format="json">
<input type="text" name=营业&售后SD value="mixed" placeholder="mixed-width" />
<input type="text" name=字段 value="full" placeholder="full-width" />
<input type="text" name=field()!*&- value="half" placeholder="half-width" />
<button>Submit</button>
</form>
`
render(<StreamdownWrapper latexContent={content} mode="static" />)
expect(screen.getByPlaceholderText('mixed-width')).toBeInTheDocument()
expect(screen.getByPlaceholderText('full-width')).toBeInTheDocument()
expect(screen.getByPlaceholderText('half-width')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Submit' }))
await waitFor(() => {
expect(mockOnSend).toHaveBeenCalledWith(
'{"营业&售后SD":"mixed","字段()!*&-":"full","field()!*&-":"half"}',
)
})
})
it('should not render Markdown punctuation after a multiline form', async () => {
const { default: StreamdownWrapper } = await import('../streamdown-wrapper')
const content = `<form data-format="json">
<input
type="checkbox"
name="财务。.;+=—"
data-tip="财务"
/>
<input
type="checkbox"
name="营业&售后SD"
data-tip="营业&售后"
/>
<input type="text" name="备注" />
<input type="text" name="normal-name" placeholder="正常字段" />
<input type="text" name="字段()!*&-" value="full-width" />
<input type="text" name="field()!*&-" value="half-width" />
<button type="submit" data-variant="primary"></button>
</form>`
render(<StreamdownWrapper latexContent={content} />)
expect(await screen.findByRole('button', { name: '提交' })).toBeInTheDocument()
expect(screen.queryByText('*')).not.toBeInTheDocument()
})
it('should keep incomplete Markdown repair enabled when no form is present', async () => {
const { default: StreamdownWrapper } = await import('../streamdown-wrapper')
render(<StreamdownWrapper latexContent="This is *italic" />)
const italic = await screen.findByText('italic')
expect(italic.tagName).toBe('EM')
})
})

View File

@ -35,6 +35,8 @@ type SanitizeSchema = {
[key: string]: unknown
}
const MARKDOWN_FORM_TAG_RE = /<form(?:\s|>)/i
const CodeBlock = dynamic(() => import('@/app/components/base/markdown-blocks/code-block'), {
ssr: false,
})
@ -167,6 +169,10 @@ const StreamdownWrapper = (props: StreamdownWrapperProps) => {
className,
mode = 'streaming',
} = props
// Remend treats Markdown punctuation inside raw HTML attributes as incomplete syntax.
// Form markup must reach the HTML parser unchanged or a field name such as `field()!*&-`
// gains a synthetic trailing `*` after the closing form tag.
const shouldParseIncompleteMarkdown = !MARKDOWN_FORM_TAG_RE.test(latexContent)
const remarkPlugins = useMemo(
() => [
@ -245,6 +251,7 @@ const StreamdownWrapper = (props: StreamdownWrapperProps) => {
components={components}
isAnimating={isAnimating}
mode={mode}
parseIncompleteMarkdown={shouldParseIncompleteMarkdown}
>
{latexContent}
</Streamdown>

View File

@ -268,6 +268,8 @@ export const WORKFLOW_GENERATION_TIMEOUT_MS = env.NEXT_PUBLIC_WORKFLOW_GENERATIO
export const LOOP_NODE_MAX_COUNT = env.NEXT_PUBLIC_LOOP_NODE_MAX_COUNT
export const MAX_ITERATIONS_NUM = env.NEXT_PUBLIC_MAX_ITERATIONS_NUM
export const MAX_TREE_DEPTH = env.NEXT_PUBLIC_MAX_TREE_DEPTH
export const MARKDOWN_FORM_FIELD_NAME_EXTRA_CHARS =
env.NEXT_PUBLIC_MARKDOWN_FORM_FIELD_NAME_EXTRA_CHARS
export const ALLOW_INLINE_STYLES = env.NEXT_PUBLIC_ALLOW_INLINE_STYLES
export const ALLOW_UNSAFE_DATA_SCHEME = env.NEXT_PUBLIC_ALLOW_UNSAFE_DATA_SCHEME

View File

@ -34,6 +34,7 @@ export NEXT_PUBLIC_WORKFLOW_GENERATION_TIMEOUT_MS=${NEXT_PUBLIC_WORKFLOW_GENERAT
export NEXT_PUBLIC_CSP_WHITELIST=${CSP_WHITELIST}
export NEXT_PUBLIC_ALLOW_EMBED=${ALLOW_EMBED}
export NEXT_PUBLIC_ALLOW_INLINE_STYLES=${ALLOW_INLINE_STYLES:-false}
export NEXT_PUBLIC_MARKDOWN_FORM_FIELD_NAME_EXTRA_CHARS="${NEXT_PUBLIC_MARKDOWN_FORM_FIELD_NAME_EXTRA_CHARS:-${MARKDOWN_FORM_FIELD_NAME_EXTRA_CHARS:-}}"
export NEXT_PUBLIC_ALLOW_UNSAFE_DATA_SCHEME=${ALLOW_UNSAFE_DATA_SCHEME:-false}
export NEXT_PUBLIC_TOP_K_MAX_VALUE=${TOP_K_MAX_VALUE}
export NEXT_PUBLIC_INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH=${INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH}

View File

@ -96,6 +96,10 @@ const clientSchema = {
*/
NEXT_PUBLIC_LOOP_NODE_MAX_COUNT: coercedNumber.default(100),
NEXT_PUBLIC_MAINTENANCE_NOTICE: z.string().optional(),
/**
* Additional literal characters allowed in Markdown form field names.
*/
NEXT_PUBLIC_MARKDOWN_FORM_FIELD_NAME_EXTRA_CHARS: z.string().default(''),
/**
* The API PREFIX for MARKETPLACE
*/
@ -249,6 +253,9 @@ export const env = createEnv({
NEXT_PUBLIC_MAINTENANCE_NOTICE: isServer
? process.env.NEXT_PUBLIC_MAINTENANCE_NOTICE
: getRuntimeEnvFromBody('maintenanceNotice'),
NEXT_PUBLIC_MARKDOWN_FORM_FIELD_NAME_EXTRA_CHARS: isServer
? process.env.NEXT_PUBLIC_MARKDOWN_FORM_FIELD_NAME_EXTRA_CHARS
: getRuntimeEnvFromBody('markdownFormFieldNameExtraChars'),
NEXT_PUBLIC_MARKETPLACE_API_PREFIX: isServer
? process.env.NEXT_PUBLIC_MARKETPLACE_API_PREFIX
: getRuntimeEnvFromBody('marketplaceApiPrefix'),