import type { Dayjs } from 'dayjs' import type { ButtonProps } from '@/app/components/base/button' import * as React 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, toDayjs } from '@/app/components/base/date-and-time-picker/utils/dayjs' import Input from '@/app/components/base/input' import Textarea from '@/app/components/base/textarea' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/app/components/base/ui/select' enum DATA_FORMAT { TEXT = 'text', JSON = 'json', } enum SUPPORTED_TAGS { LABEL = 'label', INPUT = 'input', TEXTAREA = 'textarea', BUTTON = 'button', } enum SUPPORTED_TYPES { TEXT = 'text', PASSWORD = 'password', EMAIL = 'email', NUMBER = 'number', DATE = 'date', TIME = 'time', DATETIME = 'datetime', CHECKBOX = 'checkbox', SELECT = 'select', HIDDEN = 'hidden', } const SUPPORTED_TYPES_SET = new Set(Object.values(SUPPORTED_TYPES)) const SAFE_NAME_RE = /^[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([ 'primary', 'warning', 'secondary', 'secondary-accent', 'ghost', 'ghost-accent', 'tertiary', ]) const VALID_BUTTON_SIZES = new Set(['small', 'medium', 'large']) type HastText = { type: 'text' value: string } type HastElement = { type: 'element' tagName: string properties: Record children: Array } type FormValue = string | boolean | Dayjs | undefined type FormValues = Record 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 { 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(() => ({ source: elementChildren, edits: {}, })) const formValues = useMemo(() => { 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 => { const out = Object.create(null) as Record 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 (
{ e.preventDefault() e.stopPropagation() }} > {elementChildren.map((child, index) => { const key = getElementKey(child, index) if (child.tagName === SUPPORTED_TAGS.LABEL) { return ( ) } 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 ( updateValue(name, date)} onClear={() => updateValue(name, undefined)} /> ) } if (type === SUPPORTED_TYPES.TIME) { return ( updateValue(name, time)} onClear={() => updateValue(name, undefined)} /> ) } if (type === SUPPORTED_TYPES.CHECKBOX) { return (
updateValue(name, !formValues[name])} id={name} /> {str(child.properties.dataTip || child.properties['data-tip'])}
) } 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 ( ) } if (type === SUPPORTED_TYPES.HIDDEN) { return ( ) } return ( updateValue(name, e.target.value)} /> ) } if (child.tagName === SUPPORTED_TAGS.TEXTAREA) { const name = str(child.properties.name) if (!isSafeName(name)) return null return (