fix(web): fix human input form filled UI

This commit is contained in:
JzoNg 2026-04-24 15:23:40 +08:00
parent 1c5d877372
commit cec437b35b
15 changed files with 311 additions and 33 deletions

View File

@ -1143,7 +1143,7 @@ describe('useChatWithHistory', () => {
extra_contents: [
{
type: 'human_input',
submitted: false,
submitted: true,
form_definition: {
form_id: 'form-1',
node_id: 'node-1',
@ -1157,10 +1157,6 @@ describe('useChatWithHistory', () => {
expiration_time: 0,
},
workflow_run_id: 'wf-run-status-agnostic',
},
{
type: 'human_input',
submitted: true,
form_submission_data: {
node_id: 'node-1',
node_title: 'Human Input',
@ -1186,8 +1182,10 @@ describe('useChatWithHistory', () => {
})
const answerNode = result!.current.appPrevChatTree[0]?.children?.[0]
expect(answerNode?.humanInputFormDataList).toHaveLength(1)
expect(answerNode?.humanInputFormDataList).toHaveLength(0)
expect(answerNode?.humanInputFilledFormDataList).toHaveLength(1)
expect(answerNode?.humanInputFilledFormDataList?.[0]?.form_content).toBe('{{#$output.summary#}}')
expect(answerNode?.humanInputFilledFormDataList?.[0]?.inputs).toEqual([])
expect(answerNode?.workflow_run_id).toBe('wf-run-status-agnostic')
})

View File

@ -18,6 +18,7 @@ import { AppSourceType, delConversation, pinConversation, renameConversation, un
import { useInvalidateShareConversations, useShareChatList, useShareConversationName, useShareConversations } from '@/service/use-share'
import { TransferMethod } from '@/types/app'
import { addFileInfos, sortAgentSorts } from '../../../tools/utils'
import { enrichSubmittedHumanInputFormData } from '../chat/answer/human-input-content/submitted-utils'
import { CONVERSATION_ID_INFO } from '../constants'
import { buildChatItemTree, getProcessedSystemVariablesFromUrlParams, getRawInputsFromUrlParams, getRawUserVariablesFromUrlParams } from '../utils'
@ -40,17 +41,25 @@ function getFormattedChatList(messages: any[]) {
if (content.type !== 'human_input')
return
const formDefinition = 'form_definition' in content ? content.form_definition : undefined
if (!content.submitted) {
if (!('form_definition' in content) || !content.form_definition)
if (!formDefinition)
return
humanInputFormDataList.push(content.form_definition)
humanInputFormDataList.push(formDefinition)
workflowRunId = content.workflow_run_id || workflowRunId
return
}
if (!('form_submission_data' in content) || !content.form_submission_data)
return
humanInputFilledFormDataList.push(content.form_submission_data)
const currentFormIndex = humanInputFormDataList.findIndex(item => item.node_id === content.form_submission_data.node_id)
const requiredFormData = formDefinition || (currentFormIndex > -1
? humanInputFormDataList[currentFormIndex]
: undefined)
if (currentFormIndex > -1)
humanInputFormDataList.splice(currentFormIndex, 1)
workflowRunId = content.workflow_run_id || workflowRunId
humanInputFilledFormDataList.push(enrichSubmittedHumanInputFormData(content.form_submission_data, requiredFormData))
})
newChatList.push({
id: item.id,

View File

@ -411,7 +411,7 @@ describe('useChat', () => {
// Human input required
callbacks.onHumanInputRequired({ data: { node_id: 'n-human' } })
callbacks.onHumanInputRequired({ data: { node_id: 'n-human', updated: true } }) // update existing
callbacks.onHumanInputRequired({ data: { node_id: 'n-human', updated: true, form_content: '{{#$output.answer#}}', inputs: [] } }) // update existing
// setTimeout for timeout form
callbacks.onHumanInputFormTimeout({ data: { node_id: 'n-human', expiration_time: 123456 } })
@ -437,6 +437,10 @@ describe('useChat', () => {
const lastResponse = result.current.chatList[1]
expect(lastResponse!.humanInputFormDataList).toHaveLength(0) // Removed when filled
expect(lastResponse!.humanInputFilledFormDataList).toHaveLength(2)
expect(lastResponse!.humanInputFilledFormDataList![0]).toEqual(expect.objectContaining({
form_content: '{{#$output.answer#}}',
inputs: [],
}))
expect(sseGet).toHaveBeenCalled() // from workflowPaused
expect(lastResponse!.annotation?.id).toBe('anno-1')
expect(lastResponse!.content).toBe('Replaced content')

View File

@ -1,6 +1,8 @@
import type { HumanInputFilledFormData } from '@/types/workflow'
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { InputVarType } from '@/app/components/workflow/types'
import { TransferMethod } from '@/types/app'
import { SubmittedHumanInputContent } from '../submitted'
vi.mock('@/app/components/base/markdown', () => ({
@ -33,6 +35,12 @@ describe('SubmittedHumanInputContent Integration', () => {
render(
<SubmittedHumanInputContent formData={{
...mockFormData,
form_content: 'Decision: {{#$output.answer#}}',
inputs: [{
type: InputVarType.paragraph,
output_variable_name: 'answer',
default: { type: 'constant', value: '', selector: [] },
}],
form_data: {
answer: 'approved',
},
@ -40,11 +48,54 @@ describe('SubmittedHumanInputContent Integration', () => {
/>,
)
expect(screen.getByTestId('submitted-field-values')).toBeInTheDocument()
expect(screen.getByTestId('submitted-form-content')).toBeInTheDocument()
expect(screen.getByTestId('submitted-field-answer')).toHaveTextContent('approved')
expect(screen.queryByTestId('submitted-content')).not.toBeInTheDocument()
})
it('should render submitted select and file fields with the original form layout', () => {
render(
<SubmittedHumanInputContent formData={{
...mockFormData,
form_content: '{{#$output.decision#}} {{#$output.attachment#}}',
inputs: [
{
type: InputVarType.select,
output_variable_name: 'decision',
option_source: { type: 'constant', value: ['approve', 'reject'], selector: [] },
},
{
type: InputVarType.singleFile,
output_variable_name: 'attachment',
allowed_file_extensions: [],
allowed_file_types: [],
allowed_file_upload_methods: [],
},
],
form_data: {
decision: 'approve',
attachment: {
related_id: 'file-1',
upload_file_id: 'upload-1',
filename: 'decision.pdf',
extension: 'pdf',
size: 128,
mime_type: 'application/pdf',
transfer_method: TransferMethod.local_file,
type: 'document',
url: 'https://example.com/decision.pdf',
remote_url: '',
},
},
}}
/>,
)
expect(screen.getByRole('combobox', { name: 'decision' })).toBeDisabled()
expect(screen.getByRole('combobox', { name: 'decision' })).toHaveTextContent('approve')
expect(screen.getByTestId('submitted-field-attachment')).toHaveTextContent('decision.pdf')
})
it('should fallback to rendered markdown when structured form data is empty', () => {
render(
<SubmittedHumanInputContent formData={{

View File

@ -0,0 +1,121 @@
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
import type { HumanInputFormValue } from '@/types/workflow'
import {
Select,
SelectContent,
SelectItem,
SelectItemIndicator,
SelectItemText,
SelectTrigger,
} from '@langgenius/dify-ui/select'
import * as React from 'react'
import { FileList } from '@/app/components/base/file-uploader'
import { getProcessedFilesFromResponse } from '@/app/components/base/file-uploader/utils'
import { Markdown } from '@/app/components/base/markdown'
import {
isFileFormInput,
isFileListFormInput,
isParagraphFormInput,
isSelectFormInput,
} from '@/app/components/workflow/nodes/human-input/types'
type SubmittedContentItemProps = {
content: string
formInputFields: FormInputItem[]
values: Record<string, HumanInputFormValue>
}
const outputVarRegex = /\{\{#\$output\.([^#]+)#\}\}/
const isOutputField = (content: string) => outputVarRegex.test(content)
const extractFieldName = (content: string) => {
const match = outputVarRegex.exec(content)
return match ? match[1]! : ''
}
const SubmittedContentItem = ({
content,
formInputFields,
values,
}: SubmittedContentItemProps) => {
if (!isOutputField(content)) {
return (
<Markdown content={content} />
)
}
const fieldName = extractFieldName(content)
const field = formInputFields.find(field => field.output_variable_name === fieldName)
const value = values[fieldName]
if (!field || value == null)
return null
if (isParagraphFormInput(field)) {
return (
<span
className="body-md-regular break-words text-text-primary"
data-testid={`submitted-field-${fieldName}`}
>
{typeof value === 'string' ? value : ''}
</span>
)
}
if (isSelectFormInput(field)) {
const selectedValue = typeof value === 'string' ? value : ''
return (
<div className="py-3" data-testid={`submitted-field-${fieldName}`}>
<Select value={selectedValue} disabled>
<SelectTrigger size="large" className="w-full" aria-label={field.output_variable_name} disabled>
{selectedValue}
</SelectTrigger>
<SelectContent listClassName="max-h-[140px] overflow-y-auto">
{field.option_source.value.map(option => (
<SelectItem key={option} value={option}>
<SelectItemText>{option}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}
if (isFileFormInput(field)) {
if (typeof value === 'string' || Array.isArray(value))
return null
return (
<div className="py-3" data-testid={`submitted-field-${fieldName}`}>
<FileList
files={getProcessedFilesFromResponse([value])}
showDeleteAction={false}
showDownloadAction
/>
</div>
)
}
if (isFileListFormInput(field)) {
if (typeof value === 'string' || !Array.isArray(value))
return null
return (
<div className="py-3" data-testid={`submitted-field-${fieldName}`}>
<FileList
files={getProcessedFilesFromResponse(value)}
showDeleteAction={false}
showDownloadAction
/>
</div>
)
}
return null
}
export default React.memo(SubmittedContentItem)

View File

@ -0,0 +1,34 @@
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
import type { HumanInputFormValue } from '@/types/workflow'
import * as React from 'react'
import SubmittedContentItem from './submitted-content-item'
import { splitByOutputVar } from './utils'
type SubmittedFormContentProps = {
formContent: string
formInputFields: FormInputItem[]
values: Record<string, HumanInputFormValue>
}
const SubmittedFormContent = ({
formContent,
formInputFields,
values,
}: SubmittedFormContentProps) => {
const contentList = splitByOutputVar(formContent)
return (
<div data-testid="submitted-form-content">
{contentList.map((content, index) => (
<SubmittedContentItem
key={index}
content={content}
formInputFields={formInputFields}
values={values}
/>
))}
</div>
)
}
export default React.memo(SubmittedFormContent)

View File

@ -0,0 +1,15 @@
import type { HumanInputFilledFormData, HumanInputFormData } from '@/types/workflow'
export const enrichSubmittedHumanInputFormData = (
filledFormData: HumanInputFilledFormData,
requiredFormData?: Pick<HumanInputFormData, 'form_content' | 'inputs'>,
): HumanInputFilledFormData => {
if (!requiredFormData)
return filledFormData
return {
...filledFormData,
form_content: requiredFormData.form_content,
inputs: requiredFormData.inputs,
}
}

View File

@ -3,11 +3,12 @@ import { useMemo } from 'react'
import ExecutedAction from './executed-action'
import SubmittedContent from './submitted-content'
import SubmittedFieldValues from './submitted-field-values'
import SubmittedFormContent from './submitted-form-content'
export const SubmittedHumanInputContent = ({
formData,
}: SubmittedHumanInputContentProps) => {
const { rendered_content, action_id, action_text, form_data } = formData
const { rendered_content, action_id, action_text, form_content, form_data, inputs } = formData
const executedAction = useMemo(() => {
return {
@ -16,11 +17,21 @@ export const SubmittedHumanInputContent = ({
}
}, [action_id, action_text])
const content = form_content && inputs && form_data && Object.keys(form_data).length > 0
? (
<SubmittedFormContent
formContent={form_content}
formInputFields={inputs}
values={form_data}
/>
)
: form_data && Object.keys(form_data).length > 0
? <SubmittedFieldValues values={form_data} />
: <SubmittedContent content={rendered_content} />
return (
<>
{form_data && Object.keys(form_data).length > 0
? <SubmittedFieldValues values={form_data} />
: <SubmittedContent content={rendered_content} />}
{content}
{/* Executed Action */}
<ExecutedAction executedAction={executedAction} />
</>

View File

@ -26,6 +26,7 @@ import {
import { useTranslation } from 'react-i18next'
import { v4 as uuidV4 } from 'uuid'
import { AudioPlayerManager } from '@/app/components/base/audio-btn/audio.player.manager'
import { enrichSubmittedHumanInputFormData } from '@/app/components/base/chat/chat/answer/human-input-content/submitted-utils'
import {
getProcessedFiles,
getProcessedFilesFromResponse,
@ -538,16 +539,20 @@ export const useChat = (
},
onHumanInputFormFilled: ({ data: humanInputFilledFormData }) => {
updateChatTreeNode(messageId, (responseItem) => {
let requiredFormData: NonNullable<ChatItem['humanInputFormDataList']>[number] | undefined
if (responseItem.humanInputFormDataList?.length) {
const currentFormIndex = responseItem.humanInputFormDataList.findIndex(item => item.node_id === humanInputFilledFormData.node_id)
if (currentFormIndex > -1)
if (currentFormIndex > -1) {
requiredFormData = responseItem.humanInputFormDataList[currentFormIndex]
responseItem.humanInputFormDataList.splice(currentFormIndex, 1)
}
}
const enrichedHumanInputFilledFormData = enrichSubmittedHumanInputFormData(humanInputFilledFormData, requiredFormData)
if (!responseItem.humanInputFilledFormDataList) {
responseItem.humanInputFilledFormDataList = [humanInputFilledFormData]
responseItem.humanInputFilledFormDataList = [enrichedHumanInputFilledFormData]
}
else {
responseItem.humanInputFilledFormDataList.push(humanInputFilledFormData)
responseItem.humanInputFilledFormDataList.push(enrichedHumanInputFilledFormData)
}
})
},
@ -1108,15 +1113,20 @@ export const useChat = (
}
},
onHumanInputFormFilled: ({ data: humanInputFilledFormData }) => {
let requiredFormData: NonNullable<ChatItem['humanInputFormDataList']>[number] | undefined
if (responseItem.humanInputFormDataList?.length) {
const currentFormIndex = responseItem.humanInputFormDataList!.findIndex(item => item.node_id === humanInputFilledFormData.node_id)
responseItem.humanInputFormDataList.splice(currentFormIndex, 1)
if (currentFormIndex > -1) {
requiredFormData = responseItem.humanInputFormDataList[currentFormIndex]
responseItem.humanInputFormDataList.splice(currentFormIndex, 1)
}
}
const enrichedHumanInputFilledFormData = enrichSubmittedHumanInputFormData(humanInputFilledFormData, requiredFormData)
if (!responseItem.humanInputFilledFormDataList) {
responseItem.humanInputFilledFormDataList = [humanInputFilledFormData]
responseItem.humanInputFilledFormDataList = [enrichedHumanInputFilledFormData]
}
else {
responseItem.humanInputFilledFormDataList.push(humanInputFilledFormData)
responseItem.humanInputFilledFormDataList.push(enrichedHumanInputFilledFormData)
}
updateCurrentQAOnTree({
placeholderQuestionId,

View File

@ -76,7 +76,9 @@ export type PendingHumanInputExtraContent = {
export type SubmittedHumanInputExtraContent = {
type: 'human_input'
submitted: true
form_definition?: HumanInputFormData
form_submission_data: HumanInputFilledFormData
workflow_run_id?: string
}
export type ExtraContent = PendingHumanInputExtraContent | SubmittedHumanInputExtraContent

View File

@ -139,6 +139,8 @@ describe('workflow-stream-handlers helpers', () => {
expect(workflowProcessData.humanInputFilledFormDataList).toEqual([
expect.objectContaining({
action_text: 'Submit',
form_content: 'content',
inputs: [],
}),
])
expect(workflowProcessData.tracing[0]).toEqual(expect.objectContaining({

View File

@ -3,6 +3,7 @@ import type { WorkflowProcess } from '@/app/components/base/chat/types'
import type { IOtherOptions } from '@/service/base'
import type { HumanInputFormTimeoutData, NodeTracing, WorkflowFinishedResponse } from '@/types/workflow'
import { produce } from 'immer'
import { enrichSubmittedHumanInputFormData } from '@/app/components/base/chat/chat/answer/human-input-content/submitted-utils'
import { getFilesInLogs } from '@/app/components/base/file-uploader/utils'
import { NodeRunningStatus, WorkflowRunningStatus } from '@/app/components/workflow/types'
import { sseGet } from '@/service/base'
@ -204,16 +205,20 @@ const updateHumanInputFilled = (
data: NonNullable<WorkflowProcess['humanInputFilledFormDataList']>[number],
) => {
return updateWorkflowProcess(current, (draft) => {
let requiredFormData: NonNullable<WorkflowProcess['humanInputFormDataList']>[number] | undefined
if (draft.humanInputFormDataList?.length) {
const currentFormIndex = draft.humanInputFormDataList.findIndex(item => item.node_id === data.node_id)
if (currentFormIndex > -1)
if (currentFormIndex > -1) {
requiredFormData = draft.humanInputFormDataList[currentFormIndex]
draft.humanInputFormDataList.splice(currentFormIndex, 1)
}
}
const enrichedData = enrichSubmittedHumanInputFormData(data, requiredFormData)
if (!draft.humanInputFilledFormDataList)
draft.humanInputFilledFormDataList = [data]
draft.humanInputFilledFormDataList = [enrichedData]
else
draft.humanInputFilledFormDataList.push(data)
draft.humanInputFilledFormDataList.push(enrichedData)
})
}

View File

@ -784,7 +784,7 @@ describe('useChat handleSend SSE callbacks', () => {
act(() => {
capturedCallbacks.onHumanInputRequired({
data: { node_id: 'human-node', form_token: 'token-1' },
data: { node_id: 'human-node', form_token: 'token-1', form_content: '{{#$output.answer#}}', inputs: [] },
})
})
@ -801,7 +801,7 @@ describe('useChat handleSend SSE callbacks', () => {
act(() => {
capturedCallbacks.onHumanInputRequired({
data: { node_id: 'human-node', form_token: 'token-1' },
data: { node_id: 'human-node', form_token: 'token-1', form_content: '{{#$output.answer#}}', inputs: [] },
})
})
@ -862,7 +862,7 @@ describe('useChat handleSend SSE callbacks', () => {
act(() => {
capturedCallbacks.onHumanInputRequired({
data: { node_id: 'human-node', form_token: 'token-1' },
data: { node_id: 'human-node', form_token: 'token-1', form_content: '{{#$output.answer#}}', inputs: [] },
})
})
@ -877,6 +877,10 @@ describe('useChat handleSend SSE callbacks', () => {
expect(answer!.humanInputFilledFormDataList).toHaveLength(1)
expect(answer!.humanInputFilledFormDataList![0]!.node_id).toBe('human-node')
expect((answer!.humanInputFilledFormDataList![0] as any).form_data).toEqual({ answer: 'yes' })
expect(answer!.humanInputFilledFormDataList![0]).toEqual(expect.objectContaining({
form_content: '{{#$output.answer#}}',
inputs: [],
}))
})
})

View File

@ -19,6 +19,7 @@ import {
} from 'react'
import { useTranslation } from 'react-i18next'
import { useStoreApi } from 'reactflow'
import { enrichSubmittedHumanInputFormData } from '@/app/components/base/chat/chat/answer/human-input-content/submitted-utils'
import {
getProcessedInputs,
processOpeningStatement,
@ -619,15 +620,20 @@ export const useChat = (
}
},
onHumanInputFormFilled: ({ data }) => {
let requiredFormData: NonNullable<ChatItem['humanInputFormDataList']>[number] | undefined
if (responseItem.humanInputFormDataList?.length) {
const currentFormIndex = responseItem.humanInputFormDataList.findIndex(item => item.node_id === data.node_id)
responseItem.humanInputFormDataList.splice(currentFormIndex, 1)
if (currentFormIndex > -1) {
requiredFormData = responseItem.humanInputFormDataList[currentFormIndex]
responseItem.humanInputFormDataList.splice(currentFormIndex, 1)
}
}
const enrichedData = enrichSubmittedHumanInputFormData(data, requiredFormData)
if (!responseItem.humanInputFilledFormDataList) {
responseItem.humanInputFilledFormDataList = [data]
responseItem.humanInputFilledFormDataList = [enrichedData]
}
else {
responseItem.humanInputFilledFormDataList.push(data)
responseItem.humanInputFilledFormDataList.push(enrichedData)
}
updateCurrentQAOnTree({
placeholderQuestionId,
@ -886,16 +892,20 @@ export const useChat = (
},
onHumanInputFormFilled: ({ data: humanInputFilledFormData }) => {
updateChatTreeNode(messageId, (responseItem) => {
let requiredFormData: NonNullable<ChatItem['humanInputFormDataList']>[number] | undefined
if (responseItem.humanInputFormDataList?.length) {
const currentFormIndex = responseItem.humanInputFormDataList.findIndex(item => item.node_id === humanInputFilledFormData.node_id)
if (currentFormIndex > -1)
if (currentFormIndex > -1) {
requiredFormData = responseItem.humanInputFormDataList[currentFormIndex]
responseItem.humanInputFormDataList.splice(currentFormIndex, 1)
}
}
const enrichedHumanInputFilledFormData = enrichSubmittedHumanInputFormData(humanInputFilledFormData, requiredFormData)
if (!responseItem.humanInputFilledFormDataList) {
responseItem.humanInputFilledFormDataList = [humanInputFilledFormData]
responseItem.humanInputFilledFormDataList = [enrichedHumanInputFilledFormData]
}
else {
responseItem.humanInputFilledFormDataList.push(humanInputFilledFormData)
responseItem.humanInputFilledFormDataList.push(enrichedHumanInputFilledFormData)
}
})
},

View File

@ -353,6 +353,8 @@ export type HumanInputFilledFormData = {
rendered_content: string
action_id: string
action_text: string
form_content?: string
inputs?: FormInputItem[]
form_data?: Record<string, HumanInputFormValue>
}