form content

This commit is contained in:
JzoNg 2025-08-12 13:59:28 +08:00
parent 36acd0b9dd
commit bdf1e9ed3b
5 changed files with 206 additions and 12 deletions

View File

@ -0,0 +1,104 @@
import React from 'react'
import { Markdown } from '@/app/components/base/markdown'
import Select from '@/app/components/base/select'
import Textarea from '@/app/components/base/textarea'
import Input from '@/app/components/base/input'
import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader'
import { getProcessedFiles } from '@/app/components/base/file-uploader/utils'
import { DEFAULT_VALUE_MAX_LEN } from '@/config'
import type { GeneratedFormInputItem } from '@/app/components/workflow/nodes/human-input/types'
type Props = {
content: string
formInputFields: GeneratedFormInputItem[]
inputs: Record<string, any>
onInputChange: (name: string, value: any) => void
}
const ContentItem = ({ content, formInputFields, inputs, onInputChange }: Props) => {
const isInputField = (field: string) => {
const outputVarRegex = /{{#\$output\.[^#]+#}}/
return outputVarRegex.test(field)
}
const extractFieldName = (str: string) => {
const outputVarRegex = /{{#\$output\.([^#]+)#}}/
const match = str.match(outputVarRegex)
return match ? match[1] : ''
}
if (!isInputField(content)) {
return (
<Markdown content={content} />
)
}
const fieldName = extractFieldName(content)
const formInputField = formInputFields.find(field => field.output_variable_name === fieldName)
if (!formInputField) return null
return (
<div className='py-3'>
{formInputField.type === 'select' && (
<Select
className='w-full'
defaultValue={inputs[fieldName]}
onSelect={i => onInputChange(fieldName, i.value)}
items={(formInputField.options || []).map(i => ({ name: i, value: i }))}
allowSearch={false}
/>
)}
{formInputField.type === 'text-input' && (
<Input
type="text"
placeholder={formInputField.placeholder}
value={inputs[fieldName]}
onChange={(e) => { onInputChange(fieldName, e.target.value) }}
maxLength={formInputField.max_length || DEFAULT_VALUE_MAX_LEN}
/>
)}
{formInputField.type === 'paragraph' && (
<Textarea
className='h-[104px] sm:text-xs'
placeholder={formInputField.placeholder}
value={inputs[fieldName]}
onChange={(e) => { onInputChange(fieldName, e.target.value) }}
/>
)}
{formInputField.type === 'number' && (
<Input
type="number"
value={inputs[fieldName]}
onChange={(e) => { onInputChange(fieldName, e.target.value) }}
/>
)}
{formInputField.type === 'file' && (
<FileUploaderInAttachmentWrapper
onChange={(files) => { onInputChange(fieldName, getProcessedFiles(files)[0]) }}
fileConfig={{
number_limits: 1,
allowed_file_extensions: formInputField.allowed_file_extensions,
allowed_file_types: formInputField.allowed_file_types,
allowed_file_upload_methods: formInputField.allowed_file_upload_methods as any,
// fileUploadConfig: (visionConfig as any).fileUploadConfig,
}}
/>
)}
{formInputField.type === 'file-list' && (
<FileUploaderInAttachmentWrapper
onChange={(files) => { onInputChange(fieldName, getProcessedFiles(files)) }}
fileConfig={{
number_limits: formInputField.max_length,
allowed_file_extensions: formInputField.allowed_file_extensions,
allowed_file_types: formInputField.allowed_file_types,
allowed_file_upload_methods: formInputField.allowed_file_upload_methods as any,
// fileUploadConfig: (visionConfig as any).fileUploadConfig,
}}
/>
)}
</div>
)
}
export default ContentItem

View File

@ -9,18 +9,19 @@ import {
} from '@remixicon/react'
import Loading from '@/app/components/base/loading'
import AppIcon from '@/app/components/base/app-icon'
import { Markdown } from '@/app/components/base/markdown'
import Button from '@/app/components/base/button'
import DifyLogo from '@/app/components/base/logo/dify-logo'
import ContentItem from './content-item'
import { UserActionButtonType } from '@/app/components/workflow/nodes/human-input/types'
import type { FormInputItem, UserAction } from '@/app/components/workflow/nodes/human-input/types'
import type { GeneratedFormInputItem, UserAction } from '@/app/components/workflow/nodes/human-input/types'
import { getHumanInputForm, submitHumanInputForm } from '@/service/share'
import { asyncRunSafe } from '@/utils'
import cn from '@/utils/classnames'
export type FormData = {
site: any
form_content: string
inputs: FormInputItem[]
inputs: GeneratedFormInputItem[]
user_actions: UserAction[]
timeout: number
timeout_unit: 'hour' | 'day'
@ -32,9 +33,10 @@ const FormContent = () => {
const { token } = useParams<{ token: string }>()
useDocumentTitle('')
const [isLoading, setIsLoading] = useState(true)
const [isLoading, setIsLoading] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [formData, setFormData] = useState<FormData>()
const [contentList, setContentList] = useState<string[]>([])
const [inputs, setInputs] = useState({})
const [success, setSuccess] = useState(false)
const [expired, setExpired] = useState(false)
@ -53,10 +55,42 @@ const FormContent = () => {
return 'ghost'
}
const splitByOutputVar = (content: string): string[] => {
const outputVarRegex = /({{#\$output\.[^#]+#}})/g
const parts = content.split(outputVarRegex)
return parts.filter(part => part.length > 0)
}
const initializeInputs = (formInputs: GeneratedFormInputItem[]) => {
const initialInputs: Record<string, any> = {}
formInputs.forEach((item) => {
if (item.type === 'text-input' || item.type === 'paragraph')
initialInputs[item.output_variable_name] = ''
else
initialInputs[item.output_variable_name] = undefined
})
setInputs(initialInputs)
}
const initializeContentList = (formContent: string) => {
const parts = splitByOutputVar(formContent)
setContentList(parts)
}
// use immer
const handleInputsChange = (name: string, value: any) => {
setInputs(prev => ({
...prev,
[name]: value,
}))
}
const getForm = async (token: string) => {
try {
const data = await getHumanInputForm(token)
setFormData(data)
initializeInputs(data.inputs)
initializeContentList(data.form_content)
setIsLoading(false)
}
catch (error) {
@ -70,10 +104,15 @@ const FormContent = () => {
await submitHumanInputForm(token, { inputs, action: actionID })
setSuccess(true)
}
catch (error) {
console.error(error)
setExpired(true)
setSubmitted(true)
catch (e: any) {
if (e.status === 400) {
const [, errRespData] = await asyncRunSafe<{ error_code: string }>(e.json())
const { error_code } = errRespData || {}
if (error_code === 'human_input_form_expired')
setExpired(true)
if (error_code === 'human_input_form_submitted')
setSubmitted(true)
}
}
finally {
setIsSubmitting(false)
@ -185,7 +224,15 @@ const FormContent = () => {
</div>
<div className='h-0 w-full grow overflow-y-auto'>
<div className='border-components-divider-subtle rounded-[20px] border bg-chat-bubble-bg p-4 shadow-lg backdrop-blur-sm'>
<Markdown content={formData.form_content || ''} />
{contentList.map((content, index) => (
<ContentItem
key={index}
content={content}
formInputFields={formData.inputs}
inputs={inputs}
onInputChange={handleInputsChange}
/>
))}
<div className='flex flex-wrap gap-1 py-1'>
{formData.user_actions.map((action: any) => (
<Button

View File

@ -1,4 +1,5 @@
import { UserActionButtonType } from '@/app/components/workflow/nodes/human-input/types'
import { InputVarType } from '@/app/components/workflow/types'
export const MOCK_DATA = {
site: {
@ -52,7 +53,32 @@ export const MOCK_DATA = {
{{#$output.content#}}
`,
// 对每一个字段的描述,参考上方 FormInput 的定义。
inputs: ['<FormInput>'],
inputs: [
{
output_variable_name: 'content',
type: InputVarType.paragraph,
label: 'Name',
options: [],
max_length: 4096,
placeholder: 'Enter your name',
},
{
output_variable_name: 'location',
type: InputVarType.textInput,
label: 'Location',
// placeholder: 'Enter your location',
options: [],
max_length: 4096,
},
{
output_variable_name: 'season',
type: InputVarType.textInput,
label: 'Favorite Season',
// placeholder: 'Enter your favorite season',
options: [],
max_length: 4096,
},
],
// 用户对这个表单可采取的操作,参考上方 UserAction 的定义。
user_actions: [
{

View File

@ -50,6 +50,11 @@ export type FormInputItem = {
output_variable_name: string
// only text-input and paragraph support placeholder
placeholder?: FormInputItemPlaceholder
options: any[]
max_length: number
allowed_file_extensions?: string[]
allowed_file_types?: string[]
allowed_file_upload_methods?: string[]
}
export enum UserActionButtonType {
@ -64,3 +69,15 @@ export type UserAction = {
title: string
button_style: UserActionButtonType
}
export type GeneratedFormInputItem = {
type: InputVarType
output_variable_name: string
// only text-input and paragraph support placeholder
placeholder?: string
options: any[]
max_length: number
allowed_file_extensions?: string[]
allowed_file_types?: string[]
allowed_file_upload_methods?: string[]
}

View File

@ -34,7 +34,7 @@ import type {
} from '@/models/share'
import type { ChatConfig } from '@/app/components/base/chat/types'
import type { AccessMode } from '@/models/access-control'
import type { FormInputItem, UserAction } from '@/app/components/workflow/nodes/human-input/types'
import type { GeneratedFormInputItem, UserAction } from '@/app/components/workflow/nodes/human-input/types'
function getAction(action: 'get' | 'post' | 'del' | 'patch', isInstalledApp: boolean) {
switch (action) {
@ -312,7 +312,7 @@ export const getHumanInputForm = (token: string) => {
return get<{
site: any
form_content: string
inputs: FormInputItem[]
inputs: GeneratedFormInputItem[]
user_actions: UserAction[]
timeout: number
timeout_unit: 'hour' | 'day'