mirror of
https://github.com/langgenius/dify.git
synced 2026-07-21 18:58:35 +08:00
429 lines
13 KiB
TypeScript
429 lines
13 KiB
TypeScript
import type { ButtonProps } from '@langgenius/dify-ui/button'
|
||
import type { Dayjs } from 'dayjs'
|
||
import { Button } from '@langgenius/dify-ui/button'
|
||
import { Checkbox } from '@langgenius/dify-ui/checkbox'
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectItemIndicator,
|
||
SelectItemText,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from '@langgenius/dify-ui/select'
|
||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||
import * as React from 'react'
|
||
import { useCallback, useMemo, useState } from 'react'
|
||
import { useChatContext } from '@/app/components/base/chat/chat/context'
|
||
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,
|
||
toDayjs,
|
||
} from '@/app/components/base/date-and-time-picker/utils/dayjs'
|
||
import Input from '@/app/components/base/input'
|
||
|
||
const DATA_FORMAT = {
|
||
TEXT: 'text',
|
||
JSON: 'json',
|
||
} as const
|
||
|
||
const SUPPORTED_TAGS = {
|
||
LABEL: 'label',
|
||
INPUT: 'input',
|
||
TEXTAREA: 'textarea',
|
||
BUTTON: 'button',
|
||
} as const
|
||
|
||
const SUPPORTED_TYPES = {
|
||
TEXT: 'text',
|
||
PASSWORD: 'password',
|
||
EMAIL: 'email',
|
||
NUMBER: 'number',
|
||
DATE: 'date',
|
||
TIME: 'time',
|
||
DATETIME: 'datetime',
|
||
CHECKBOX: 'checkbox',
|
||
SELECT: 'select',
|
||
HIDDEN: 'hidden',
|
||
} as const
|
||
|
||
type SupportedType = (typeof SUPPORTED_TYPES)[keyof typeof SUPPORTED_TYPES]
|
||
|
||
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')
|
||
} catch {
|
||
// Fallback for browsers without Unicode property escape support.
|
||
return /^[a-z][\w-]*$/i
|
||
}
|
||
})()
|
||
const PROTOTYPE_POISON_KEYS = new Set(['__proto__', 'constructor', 'prototype'])
|
||
|
||
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)
|
||
)
|
||
}
|
||
|
||
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'])
|
||
|
||
type HastText = {
|
||
type: 'text'
|
||
value: string
|
||
}
|
||
|
||
type HastElement = {
|
||
type: 'element'
|
||
tagName: string
|
||
properties: Record<string, unknown>
|
||
children: Array<HastElement | HastText>
|
||
}
|
||
|
||
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 getLabelTarget(node: HastElement): string {
|
||
return str(node.properties.htmlFor || node.properties.for || node.properties.name)
|
||
}
|
||
|
||
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 {
|
||
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"
|
||
onSubmit={(e) => {
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
}}
|
||
>
|
||
{elementChildren.map((child, index) => {
|
||
const key = getElementKey(child, index)
|
||
if (child.tagName === SUPPORTED_TAGS.LABEL) {
|
||
return (
|
||
<label
|
||
key={key}
|
||
htmlFor={getLabelTarget(child)}
|
||
className="my-2 system-md-semibold text-text-secondary"
|
||
>
|
||
{getTextContent(child)}
|
||
</label>
|
||
)
|
||
}
|
||
|
||
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 SupportedType
|
||
|
||
if (type === SUPPORTED_TYPES.DATE || type === SUPPORTED_TYPES.DATETIME) {
|
||
return (
|
||
<DatePicker
|
||
key={key}
|
||
value={formValues[name] as Dayjs | undefined}
|
||
needTimePicker={type === SUPPORTED_TYPES.DATETIME}
|
||
onChange={(date) => updateValue(name, date)}
|
||
onClear={() => updateValue(name, undefined)}
|
||
/>
|
||
)
|
||
}
|
||
if (type === SUPPORTED_TYPES.TIME) {
|
||
return (
|
||
<TimePicker
|
||
key={key}
|
||
value={formValues[name] as Dayjs | string | undefined}
|
||
onChange={(time) => updateValue(name, time)}
|
||
onClear={() => updateValue(name, undefined)}
|
||
/>
|
||
)
|
||
}
|
||
if (type === SUPPORTED_TYPES.CHECKBOX) {
|
||
const label = str(child.properties.dataTip || child.properties['data-tip'])
|
||
const hasExternalLabel = elementChildren.some(
|
||
(node) => node.tagName === SUPPORTED_TAGS.LABEL && getLabelTarget(node) === name,
|
||
)
|
||
const checkboxAriaLabel = label || (hasExternalLabel ? undefined : name)
|
||
return (
|
||
<div className="mt-2 flex h-6 items-center space-x-2" key={key}>
|
||
<Checkbox
|
||
id={name}
|
||
checked={!!formValues[name]}
|
||
aria-label={checkboxAriaLabel}
|
||
onCheckedChange={(checked) => updateValue(name, checked)}
|
||
/>
|
||
{label && <span>{label}</span>}
|
||
</div>
|
||
)
|
||
}
|
||
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={key}
|
||
defaultValue={formValues[name] as string | undefined}
|
||
onValueChange={(val) => {
|
||
if (val != null) updateValue(name, val)
|
||
}}
|
||
>
|
||
<SelectTrigger className="w-full">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{options.map((option) => (
|
||
<SelectItem key={option} value={option}>
|
||
<SelectItemText>{option}</SelectItemText>
|
||
<SelectItemIndicator />
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
)
|
||
}
|
||
|
||
if (type === SUPPORTED_TYPES.HIDDEN) {
|
||
return (
|
||
<input
|
||
key={key}
|
||
type="hidden"
|
||
name={name}
|
||
value={str(formValues[name] ?? child.properties.value)}
|
||
/>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<Input
|
||
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
|
||
aria-label={name}
|
||
key={key}
|
||
name={name}
|
||
placeholder={str(child.properties.placeholder)}
|
||
value={str(formValues[name])}
|
||
onValueChange={(value) => updateValue(name, value)}
|
||
/>
|
||
)
|
||
}
|
||
|
||
if (child.tagName === SUPPORTED_TAGS.BUTTON) {
|
||
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={key}
|
||
disabled={isSubmitting}
|
||
onClick={onSubmit}
|
||
>
|
||
<span className="text-[13px]">{getTextContent(child)}</span>
|
||
</Button>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<p key={key}>
|
||
Unsupported tag:
|
||
{child.tagName}
|
||
</p>
|
||
)
|
||
})}
|
||
</form>
|
||
)
|
||
}
|
||
MarkdownForm.displayName = 'MarkdownForm'
|
||
export default MarkdownForm
|