fix(web): human input form content submittion

This commit is contained in:
JzoNg 2026-04-24 11:21:58 +08:00
parent a8e663863d
commit 1c5d877372
15 changed files with 234 additions and 105 deletions

View File

@ -1,6 +1,6 @@
'use client'
import type { FC } from 'react'
import type { HumanInputFieldValue } from '@/app/components/base/chat/chat/answer/human-input-content/field-renderer'
import type { HumanInputFormSubmitData } from '@/app/components/base/chat/chat/answer/human-input-content/type'
import type { FeedbackType } from '@/app/components/base/chat/chat/type'
import type { WorkflowProcess } from '@/app/components/base/chat/types'
import type { SiteInfo } from '@/models/share'
@ -179,7 +179,7 @@ const GenerationItem: FC<IGenerationItemProps> = ({
// eslint-disable-next-line react/set-state-in-effect
setCurrentTab(getDefaultGenerationTab(workflowProcessData))
}, [workflowProcessData])
const handleSubmitHumanInputForm = useCallback(async (formToken: string, formData: { inputs: Record<string, HumanInputFieldValue>, action: string }) => {
const handleSubmitHumanInputForm = useCallback(async (formToken: string, formData: HumanInputFormSubmitData) => {
if (appSourceType === AppSourceType.installedApp)
await submitHumanInputFormService(formToken, formData)
else

View File

@ -1,6 +1,6 @@
'use client'
import type { FC } from 'react'
import type { HumanInputFieldValue } from '@/app/components/base/chat/chat/answer/human-input-content/field-renderer'
import type { HumanInputFormSubmitData } from '@/app/components/base/chat/chat/answer/human-input-content/type'
import type { WorkflowProcess } from '@/app/components/base/chat/types'
import type { SiteInfo } from '@/models/share'
import { cn } from '@langgenius/dify-ui/cn'
@ -18,7 +18,7 @@ type WorkflowBodyProps = {
depth: number
hideProcessDetail?: boolean
isError: boolean
onSubmitHumanInputForm: (formToken: string, formData: { inputs: Record<string, HumanInputFieldValue>, action: string }) => Promise<void>
onSubmitHumanInputForm: (formToken: string, formData: HumanInputFormSubmitData) => Promise<void>
onSwitchTab: (tab: string) => void
showResultTabs: boolean
siteInfo: SiteInfo | null

View File

@ -1,11 +1,10 @@
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
import type { HumanInputFormData } from '@/types/workflow'
import { act, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { UserActionButtonType } from '@/app/components/workflow/nodes/human-input/types'
import { InputVarType } from '@/app/components/workflow/types'
import { InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types'
import { TransferMethod } from '@/types/app'
import HumanInputForm from '../human-input-form'
@ -14,6 +13,36 @@ vi.mock('../content-item', () => ({
<div data-testid="mock-content-item">
{content}
<button data-testid="update-input" onClick={() => onInputChange('field1', 'new value')}>Update</button>
<button data-testid="update-select" onClick={() => onInputChange('field2', 'approved')}>Update Select</button>
<button
data-testid="update-single-file"
onClick={() => onInputChange('field4', {
id: 'file-2',
name: 'main.png',
size: 256,
type: 'image/png',
progress: 100,
transferMethod: TransferMethod.local_file,
supportFileType: 'image',
uploadedId: 'upload-file-2',
})}
>
Update Single File
</button>
<button
data-testid="update-pending-single-file"
onClick={() => onInputChange('field4', {
id: 'file-2',
name: 'main.png',
size: 256,
type: 'image/png',
progress: 50,
transferMethod: TransferMethod.local_file,
supportFileType: 'image',
})}
>
Update Pending Single File
</button>
<button
data-testid="update-input-file"
onClick={() => onInputChange('field3', [{
@ -24,10 +53,25 @@ vi.mock('../content-item', () => ({
progress: 100,
transferMethod: TransferMethod.local_file,
supportFileType: 'image',
uploadedId: 'upload-file-1',
}])}
>
Update File
</button>
<button
data-testid="update-pending-input-file"
onClick={() => onInputChange('field3', [{
id: 'file-1',
name: 'avatar.png',
size: 128,
type: 'image/png',
progress: 50,
transferMethod: TransferMethod.local_file,
supportFileType: 'image',
}])}
>
Update Pending File
</button>
</div>
),
}))
@ -93,7 +137,7 @@ describe('HumanInputForm', () => {
})
})
it('should submit non-string field values without coercion', async () => {
it('should submit file field values using the backend payload shape', async () => {
const user = userEvent.setup()
const mockOnSubmit = vi.fn().mockResolvedValue(undefined)
const formDataWithFileList: HumanInputFormData = {
@ -108,9 +152,9 @@ describe('HumanInputForm', () => {
{
type: InputVarType.multiFiles,
output_variable_name: 'field3',
allowed_file_extensions: [],
allowed_file_types: [],
allowed_file_upload_methods: [],
allowed_file_extensions: ['.png'],
allowed_file_types: [SupportUploadFileTypes.image],
allowed_file_upload_methods: [TransferMethod.local_file],
max_upload_count: 5,
},
] as FormInputItem[],
@ -127,14 +171,84 @@ describe('HumanInputForm', () => {
inputs: {
field1: 'new value',
field3: [{
id: 'file-1',
name: 'avatar.png',
size: 128,
type: 'image/png',
progress: 100,
transferMethod: TransferMethod.local_file,
supportFileType: 'image',
} satisfies FileEntity],
type: 'image',
transfer_method: TransferMethod.local_file,
url: '',
upload_file_id: 'upload-file-1',
}],
},
})
})
it('should disable buttons until select, file, and file list inputs have uploaded values', async () => {
const user = userEvent.setup()
const mockOnSubmit = vi.fn().mockResolvedValue(undefined)
const formDataWithRequiredInteractiveFields: HumanInputFormData = {
...mockFormData,
form_content: '{{#$output.field2#}} {{#$output.field3#}} {{#$output.field4#}}',
inputs: [
{
type: InputVarType.select,
output_variable_name: 'field2',
option_source: {
type: 'constant',
value: ['approved'],
selector: [],
},
},
{
type: InputVarType.multiFiles,
output_variable_name: 'field3',
allowed_file_extensions: ['.png'],
allowed_file_types: [SupportUploadFileTypes.image],
allowed_file_upload_methods: [TransferMethod.local_file],
max_upload_count: 5,
},
{
type: InputVarType.singleFile,
output_variable_name: 'field4',
allowed_file_extensions: ['.png'],
allowed_file_types: [SupportUploadFileTypes.image],
allowed_file_upload_methods: [TransferMethod.local_file],
},
] as FormInputItem[],
}
render(<HumanInputForm formData={formDataWithRequiredInteractiveFields} onSubmit={mockOnSubmit} />)
const submitButton = screen.getByRole('button', { name: 'Submit' })
expect(submitButton).toBeDisabled()
await user.click(screen.getAllByTestId('update-select')[0]!)
await user.click(screen.getAllByTestId('update-pending-single-file')[0]!)
await user.click(screen.getAllByTestId('update-input-file')[0]!)
expect(submitButton).toBeDisabled()
await user.click(screen.getAllByTestId('update-single-file')[0]!)
await user.click(screen.getAllByTestId('update-pending-input-file')[0]!)
expect(submitButton).toBeDisabled()
await user.click(screen.getAllByTestId('update-input-file')[0]!)
expect(submitButton).toBeEnabled()
await user.click(submitButton)
expect(mockOnSubmit).toHaveBeenCalledWith('token_123', {
action: 'action_1',
inputs: {
field2: 'approved',
field3: [{
type: 'image',
transfer_method: TransferMethod.local_file,
url: '',
upload_file_id: 'upload-file-1',
}],
field4: {
type: 'image',
transfer_method: TransferMethod.local_file,
url: '',
upload_file_id: 'upload-file-2',
},
},
})
})

View File

@ -7,7 +7,7 @@ import { Button } from '@langgenius/dify-ui/button'
import * as React from 'react'
import { useCallback, useState } from 'react'
import ContentItem from './content-item'
import { getButtonStyle, initializeInputs, splitByOutputVar } from './utils'
import { getButtonStyle, getProcessedHumanInputFormInputs, hasInvalidSelectOrFileInput, initializeInputs, splitByOutputVar } from './utils'
const HumanInputForm = ({
formData,
@ -28,10 +28,15 @@ const HumanInputForm = ({
const submit = async (formToken: string, actionID: string, inputs: Record<string, HumanInputFieldValue>) => {
setIsSubmitting(true)
await onSubmit?.(formToken, { inputs, action: actionID })
await onSubmit?.(formToken, {
inputs: getProcessedHumanInputFormInputs(formData.inputs, inputs) || {},
action: actionID,
})
setIsSubmitting(false)
}
const isActionDisabled = isSubmitting || hasInvalidSelectOrFileInput(formData.inputs, inputs)
return (
<>
{contentList.map((content, index) => (
@ -47,7 +52,7 @@ const HumanInputForm = ({
{formData.actions.map((action: UserAction) => (
<Button
key={action.id}
disabled={isSubmitting}
disabled={isActionDisabled}
variant={getButtonStyle(action.button_style) as ButtonProps['variant']}
onClick={() => submit(formToken, action.id, inputs)}
data-testid="action-button"

View File

@ -12,7 +12,7 @@ export type UnsubmittedHumanInputContentProps = {
showEmailTip?: boolean
isEmailDebugMode?: boolean
showDebugModeTip?: boolean
onSubmit?: (formToken: string, data: { inputs: Record<string, HumanInputFieldValue>, action: string }) => Promise<void>
onSubmit?: (formToken: string, data: HumanInputFormSubmitData) => Promise<void>
}
export type SubmittedHumanInputContentProps = {
@ -21,7 +21,12 @@ export type SubmittedHumanInputContentProps = {
export type HumanInputFormProps = {
formData: HumanInputFormData
onSubmit?: (formToken: string, data: { inputs: Record<string, HumanInputFieldValue>, action: string }) => Promise<void>
onSubmit?: (formToken: string, data: HumanInputFormSubmitData) => Promise<void>
}
export type HumanInputFormSubmitData = {
inputs: Record<string, unknown>
action: string
}
export type ContentItemProps = {

View File

@ -1,4 +1,5 @@
import type { HumanInputFieldValue } from './field-renderer'
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
import type { Locale } from '@/i18n-config'
import type { HumanInputResolvedValue } from '@/types/workflow'
@ -6,6 +7,7 @@ import dayjs from 'dayjs'
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'
import relativeTime from 'dayjs/plugin/relativeTime'
import utc from 'dayjs/plugin/utc'
import { fileIsUploaded, getProcessedFiles } from '@/app/components/base/file-uploader/utils'
import {
isFileFormInput,
isFileListFormInput,
@ -67,6 +69,73 @@ export const initializeInputs = (formInputs: FormInputItem[], defaultValues: Rec
return initialInputs
}
export const isHumanInputFileUploaded = (value: HumanInputFieldValue | undefined) => {
return !!value
&& !Array.isArray(value)
&& typeof value !== 'string'
&& !!fileIsUploaded(value as FileEntity)
}
export const hasUploadedHumanInputFiles = (value: HumanInputFieldValue | undefined) => {
return Array.isArray(value)
&& value.length > 0
&& value.every(file => !!fileIsUploaded(file))
}
export const hasInvalidSelectOrFileInput = (
formInputs: FormInputItem[],
values: Record<string, HumanInputFieldValue>,
) => {
return formInputs.some((input) => {
const value = values[input.output_variable_name]
if (isSelectFormInput(input))
return typeof value !== 'string' || value.length === 0
if (isFileFormInput(input))
return Array.isArray(value) ? !hasUploadedHumanInputFiles(value) : !isHumanInputFileUploaded(value)
if (isFileListFormInput(input))
return !hasUploadedHumanInputFiles(value)
return false
})
}
export const getProcessedHumanInputFormInputs = (
formInputs: FormInputItem[],
values: Record<string, HumanInputFieldValue> | undefined,
) => {
if (!values)
return undefined
const processedInputs: Record<string, unknown> = { ...values }
formInputs.forEach((input) => {
const value = values[input.output_variable_name]
if (isFileListFormInput(input)) {
processedInputs[input.output_variable_name] = Array.isArray(value)
? getProcessedFiles(value)
: []
return
}
if (isFileFormInput(input)) {
if (Array.isArray(value)) {
processedInputs[input.output_variable_name] = getProcessedFiles(value)[0]
return
}
processedInputs[input.output_variable_name] = value && typeof value !== 'string'
? getProcessedFiles([value as FileEntity])[0]
: undefined
}
})
return processedInputs
}
const localeMap: Record<string, string> = {
'en-US': 'en',
'zh-Hans': 'zh-cn',

View File

@ -1,4 +1,4 @@
import type { HumanInputFieldValue } from './human-input-content/field-renderer'
import type { HumanInputFormSubmitData } from './human-input-content/type'
import type { DeliveryMethod, HumanInputNodeType } from '@/app/components/workflow/nodes/human-input/types'
import type { Node } from '@/app/components/workflow/types'
import type { HumanInputFormData } from '@/types/workflow'
@ -9,7 +9,7 @@ import { UnsubmittedHumanInputContent } from './human-input-content/unsubmitted'
type HumanInputFormListProps = {
humanInputFormDataList: HumanInputFormData[]
onHumanInputFormSubmit?: (formToken: string, formData: { inputs: Record<string, HumanInputFieldValue>, action: string }) => Promise<void>
onHumanInputFormSubmit?: (formToken: string, formData: HumanInputFormSubmitData) => Promise<void>
getHumanInputNodeData?: (nodeID: string) => Node<HumanInputNodeType> | undefined
}

View File

@ -6,7 +6,7 @@ import type {
ChatConfig,
ChatItem,
} from '../../types'
import type { HumanInputFieldValue } from './human-input-content/field-renderer'
import type { HumanInputFormSubmitData } from './human-input-content/type'
import type { AppData } from '@/models/share'
import { cn } from '@langgenius/dify-ui/cn'
import { memo, useCallback, useEffect, useRef, useState } from 'react'
@ -41,7 +41,7 @@ type AnswerProps = {
noChatInput?: boolean
switchSibling?: (siblingMessageId: string) => void
hideAvatar?: boolean
onHumanInputFormSubmit?: (formToken: string, formData: { inputs: Record<string, HumanInputFieldValue>, action: string }) => Promise<void>
onHumanInputFormSubmit?: (formToken: string, formData: HumanInputFormSubmitData) => Promise<void>
}
const Answer: FC<AnswerProps> = ({
item,

View File

@ -10,7 +10,7 @@ import type {
OnRegenerate,
OnSend,
} from '../types'
import type { HumanInputFieldValue } from './answer/human-input-content/field-renderer'
import type { HumanInputFormSubmitData } from './answer/human-input-content/type'
import type { InputForm } from './type'
import type { Emoji } from '@/app/components/tools/types'
import type { AppData } from '@/models/share'
@ -70,7 +70,7 @@ export type ChatProps = {
sidebarCollapseState?: boolean
hideAvatar?: boolean
sendOnEnter?: boolean
onHumanInputFormSubmit?: (formToken: string, formData: { inputs: Record<string, HumanInputFieldValue>, action: string }) => Promise<void>
onHumanInputFormSubmit?: (formToken: string, formData: HumanInputFormSubmitData) => Promise<void>
getHumanInputNodeData?: (nodeID: string) => any
}

View File

@ -1,5 +1,5 @@
import type { FileEntity } from '../../file-uploader/types'
import type { HumanInputFieldValue } from '../chat/answer/human-input-content/field-renderer'
import type { HumanInputFormSubmitData } from '../chat/answer/human-input-content/type'
import type {
ChatConfig,
ChatItem,
@ -233,7 +233,7 @@ const ChatWrapper = () => {
}
}, [inputsForms.length, isMobile, currentConversationId, collapsed, allInputsHidden])
const handleSubmitHumanInputForm = useCallback(async (formToken: string, formData: { inputs: Record<string, HumanInputFieldValue>, action: string }) => {
const handleSubmitHumanInputForm = useCallback(async (formToken: string, formData: HumanInputFormSubmitData) => {
if (isInstalledApp)
await submitHumanInputFormService(formToken, formData)
else

View File

@ -1,7 +1,6 @@
'use client'
import type { ButtonProps } from '@langgenius/dify-ui/button'
import type { HumanInputFieldValue } from '@/app/components/base/chat/chat/answer/human-input-content/field-renderer'
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import type { UserAction } from '@/app/components/workflow/nodes/human-input/types'
import type { HumanInputFormData } from '@/types/workflow'
import { Button } from '@langgenius/dify-ui/button'
@ -11,9 +10,7 @@ import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import ContentItem from '@/app/components/base/chat/chat/answer/human-input-content/content-item'
import { getButtonStyle, initializeInputs, splitByOutputVar } from '@/app/components/base/chat/chat/answer/human-input-content/utils'
import { fileIsUploaded } from '@/app/components/base/file-uploader/utils'
import { isFileFormInput, isFileListFormInput, isSelectFormInput } from '@/app/components/workflow/nodes/human-input/types'
import { getButtonStyle, hasInvalidSelectOrFileInput, initializeInputs, splitByOutputVar } from '@/app/components/base/chat/chat/answer/human-input-content/utils'
type Props = {
nodeName: string
@ -23,19 +20,6 @@ type Props = {
onSubmit?: ({ inputs, action }: { inputs: Record<string, HumanInputFieldValue>, action: string }) => Promise<void>
}
const isUploadedFile = (value: HumanInputFieldValue | undefined) => {
return !!value
&& !Array.isArray(value)
&& typeof value !== 'string'
&& !!fileIsUploaded(value as FileEntity)
}
const hasUploadedFiles = (value: HumanInputFieldValue | undefined) => {
return Array.isArray(value)
&& value.length > 0
&& value.every(file => !!fileIsUploaded(file))
}
const FormContent = ({
nodeName,
data,
@ -56,20 +40,7 @@ const FormContent = ({
}))
}
const hasEmptySelectOrFileInput = data.inputs.some((input) => {
const value = inputs[input.output_variable_name]
if (isSelectFormInput(input))
return typeof value !== 'string' || value.length === 0
if (isFileFormInput(input))
return Array.isArray(value) ? !hasUploadedFiles(value) : !isUploadedFile(value)
if (isFileListFormInput(input))
return !hasUploadedFiles(value)
return false
})
const hasEmptySelectOrFileInput = hasInvalidSelectOrFileInput(data.inputs, inputs)
const submit = async (actionID: string) => {
setIsSubmitting(true)

View File

@ -1,17 +1,16 @@
import type { HumanInputNodeType } from '../types'
import type { HumanInputFieldValue } from '@/app/components/base/chat/chat/answer/human-input-content/field-renderer'
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import type { Props as FormProps } from '@/app/components/workflow/nodes/_base/components/before-run-form/form'
import type { InputVar } from '@/app/components/workflow/types'
import type { HumanInputFormData } from '@/types/workflow'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
import { getProcessedFiles } from '@/app/components/base/file-uploader/utils'
import { getProcessedHumanInputFormInputs } from '@/app/components/base/chat/chat/answer/human-input-content/utils'
import { fetchHumanInputNodeStepRunForm, submitHumanInputNodeStepRunForm } from '@/service/workflow'
import { AppModeEnum } from '@/types/app'
import useNodeCrud from '../../_base/hooks/use-node-crud'
import { isFileFormInput, isFileListFormInput, isParagraphFormInput } from '../types'
import { isParagraphFormInput } from '../types'
import { isOutput } from '../utils'
const i18nPrefix = 'nodes.humanInput'
@ -24,40 +23,6 @@ type Params = {
setRunInputData: (data: Record<string, string>) => void
}
const getProcessedHumanInputFormInputs = (
formInputs: HumanInputNodeType['inputs'],
values: Record<string, HumanInputFieldValue> | undefined,
) => {
if (!values)
return undefined
const processedInputs: Record<string, unknown> = { ...values }
formInputs.forEach((input) => {
const value = values[input.output_variable_name]
if (isFileListFormInput(input)) {
processedInputs[input.output_variable_name] = Array.isArray(value)
? getProcessedFiles(value)
: []
return
}
if (isFileFormInput(input)) {
if (Array.isArray(value)) {
processedInputs[input.output_variable_name] = getProcessedFiles(value)[0]
return
}
processedInputs[input.output_variable_name] = value && typeof value !== 'string'
? getProcessedFiles([value as FileEntity])[0]
: undefined
}
})
return processedInputs
}
const useSingleRunFormParams = ({
id,
payload,

View File

@ -1,6 +1,6 @@
import type { StartNodeType } from '../../nodes/start/types'
import type { ChatWrapperRefType } from './index'
import type { HumanInputFieldValue } from '@/app/components/base/chat/chat/answer/human-input-content/field-renderer'
import type { HumanInputFormSubmitData } from '@/app/components/base/chat/chat/answer/human-input-content/type'
import type { ChatItem, OnSend } from '@/app/components/base/chat/types'
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import { memo, useCallback, useEffect, useImperativeHandle, useMemo } from 'react'
@ -130,7 +130,7 @@ const ChatWrapper = (
})
}, [handleSwitchSibling, appDetail])
const doHumanInputFormSubmit = useCallback(async (formToken: string, formData: { inputs: Record<string, HumanInputFieldValue>, action: string }) => {
const doHumanInputFormSubmit = useCallback(async (formToken: string, formData: HumanInputFormSubmitData) => {
await handleSubmitHumanInputForm(formToken, formData)
}, [handleSubmitHumanInputForm])

View File

@ -1,4 +1,4 @@
import type { HumanInputFieldValue } from '@/app/components/base/chat/chat/answer/human-input-content/field-renderer'
import type { HumanInputFormSubmitData } from '@/app/components/base/chat/chat/answer/human-input-content/type'
import type { InputForm } from '@/app/components/base/chat/chat/type'
import type {
ChatItem,
@ -661,7 +661,7 @@ export const useChat = (
)
}, [threadMessages, chatTree.length, updateCurrentQAOnTree, handleResponding, formSettings?.inputsForm, handleRun, t, workflowStore, fetchInspectVars, invalidAllLastRun, config?.suggested_questions_after_answer?.enabled])
const handleSubmitHumanInputForm = async (formToken: string, formData: { inputs: Record<string, HumanInputFieldValue>, action: string }) => {
const handleSubmitHumanInputForm = async (formToken: string, formData: HumanInputFormSubmitData) => {
await submitHumanInputForm(formToken, formData)
}

View File

@ -1,4 +1,4 @@
import type { HumanInputFieldValue } from '@/app/components/base/chat/chat/answer/human-input-content/field-renderer'
import type { HumanInputFormSubmitData } from '@/app/components/base/chat/chat/answer/human-input-content/type'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { toast } from '@langgenius/dify-ui/toast'
@ -98,7 +98,7 @@ const WorkflowPreview = () => {
}
}, [resize, stopResizing])
const handleSubmitHumanInputForm = useCallback(async (formToken: string, formData: { inputs: Record<string, HumanInputFieldValue>, action: string }) => {
const handleSubmitHumanInputForm = useCallback(async (formToken: string, formData: HumanInputFormSubmitData) => {
await submitHumanInputForm(formToken, formData)
}, [])