From cec437b35bab35a6ff5f76205913a72db0ef082b Mon Sep 17 00:00:00 2001 From: JzoNg Date: Fri, 24 Apr 2026 15:23:40 +0800 Subject: [PATCH] fix(web): fix human input form filled UI --- .../__tests__/hooks.spec.tsx | 10 +- .../base/chat/chat-with-history/hooks.tsx | 15 ++- .../base/chat/chat/__tests__/hooks.spec.tsx | 6 +- .../__tests__/submitted.spec.tsx | 53 +++++++- .../submitted-content-item.tsx | 121 ++++++++++++++++++ .../submitted-form-content.tsx | 34 +++++ .../human-input-content/submitted-utils.ts | 15 +++ .../answer/human-input-content/submitted.tsx | 19 ++- web/app/components/base/chat/chat/hooks.ts | 22 +++- web/app/components/base/chat/chat/type.ts | 2 + .../workflow-stream-handlers.spec.ts | 2 + .../result/workflow-stream-handlers.ts | 11 +- .../__tests__/hooks/sse-callbacks.spec.ts | 10 +- .../workflow/panel/debug-and-preview/hooks.ts | 22 +++- web/types/workflow.ts | 2 + 15 files changed, 311 insertions(+), 33 deletions(-) create mode 100644 web/app/components/base/chat/chat/answer/human-input-content/submitted-content-item.tsx create mode 100644 web/app/components/base/chat/chat/answer/human-input-content/submitted-form-content.tsx create mode 100644 web/app/components/base/chat/chat/answer/human-input-content/submitted-utils.ts diff --git a/web/app/components/base/chat/chat-with-history/__tests__/hooks.spec.tsx b/web/app/components/base/chat/chat-with-history/__tests__/hooks.spec.tsx index 1d60d8619c..d567858b36 100644 --- a/web/app/components/base/chat/chat-with-history/__tests__/hooks.spec.tsx +++ b/web/app/components/base/chat/chat-with-history/__tests__/hooks.spec.tsx @@ -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') }) diff --git a/web/app/components/base/chat/chat-with-history/hooks.tsx b/web/app/components/base/chat/chat-with-history/hooks.tsx index 41cd8a0ede..5b4c80d3a8 100644 --- a/web/app/components/base/chat/chat-with-history/hooks.tsx +++ b/web/app/components/base/chat/chat-with-history/hooks.tsx @@ -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, diff --git a/web/app/components/base/chat/chat/__tests__/hooks.spec.tsx b/web/app/components/base/chat/chat/__tests__/hooks.spec.tsx index 738ab79bca..3a63ca0886 100644 --- a/web/app/components/base/chat/chat/__tests__/hooks.spec.tsx +++ b/web/app/components/base/chat/chat/__tests__/hooks.spec.tsx @@ -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') diff --git a/web/app/components/base/chat/chat/answer/human-input-content/__tests__/submitted.spec.tsx b/web/app/components/base/chat/chat/answer/human-input-content/__tests__/submitted.spec.tsx index 8bd42c8dfd..b4ee491150 100644 --- a/web/app/components/base/chat/chat/answer/human-input-content/__tests__/submitted.spec.tsx +++ b/web/app/components/base/chat/chat/answer/human-input-content/__tests__/submitted.spec.tsx @@ -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( { />, ) - 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( + , + ) + + 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( +} + +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 ( + + ) + } + + 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 ( + + {typeof value === 'string' ? value : ''} + + ) + } + + if (isSelectFormInput(field)) { + const selectedValue = typeof value === 'string' ? value : '' + + return ( +
+ +
+ ) + } + + if (isFileFormInput(field)) { + if (typeof value === 'string' || Array.isArray(value)) + return null + + return ( +
+ +
+ ) + } + + if (isFileListFormInput(field)) { + if (typeof value === 'string' || !Array.isArray(value)) + return null + + return ( +
+ +
+ ) + } + + return null +} + +export default React.memo(SubmittedContentItem) diff --git a/web/app/components/base/chat/chat/answer/human-input-content/submitted-form-content.tsx b/web/app/components/base/chat/chat/answer/human-input-content/submitted-form-content.tsx new file mode 100644 index 0000000000..ac11001d5b --- /dev/null +++ b/web/app/components/base/chat/chat/answer/human-input-content/submitted-form-content.tsx @@ -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 +} + +const SubmittedFormContent = ({ + formContent, + formInputFields, + values, +}: SubmittedFormContentProps) => { + const contentList = splitByOutputVar(formContent) + + return ( +
+ {contentList.map((content, index) => ( + + ))} +
+ ) +} + +export default React.memo(SubmittedFormContent) diff --git a/web/app/components/base/chat/chat/answer/human-input-content/submitted-utils.ts b/web/app/components/base/chat/chat/answer/human-input-content/submitted-utils.ts new file mode 100644 index 0000000000..c11ebd9ab5 --- /dev/null +++ b/web/app/components/base/chat/chat/answer/human-input-content/submitted-utils.ts @@ -0,0 +1,15 @@ +import type { HumanInputFilledFormData, HumanInputFormData } from '@/types/workflow' + +export const enrichSubmittedHumanInputFormData = ( + filledFormData: HumanInputFilledFormData, + requiredFormData?: Pick, +): HumanInputFilledFormData => { + if (!requiredFormData) + return filledFormData + + return { + ...filledFormData, + form_content: requiredFormData.form_content, + inputs: requiredFormData.inputs, + } +} diff --git a/web/app/components/base/chat/chat/answer/human-input-content/submitted.tsx b/web/app/components/base/chat/chat/answer/human-input-content/submitted.tsx index 744d4d826a..3cf3a735b3 100644 --- a/web/app/components/base/chat/chat/answer/human-input-content/submitted.tsx +++ b/web/app/components/base/chat/chat/answer/human-input-content/submitted.tsx @@ -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 + ? ( + + ) + : form_data && Object.keys(form_data).length > 0 + ? + : + return ( <> - {form_data && Object.keys(form_data).length > 0 - ? - : } + {content} {/* Executed Action */} diff --git a/web/app/components/base/chat/chat/hooks.ts b/web/app/components/base/chat/chat/hooks.ts index 677862a795..6300dca69a 100644 --- a/web/app/components/base/chat/chat/hooks.ts +++ b/web/app/components/base/chat/chat/hooks.ts @@ -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[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[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, diff --git a/web/app/components/base/chat/chat/type.ts b/web/app/components/base/chat/chat/type.ts index b4d8c3c577..a327104de7 100644 --- a/web/app/components/base/chat/chat/type.ts +++ b/web/app/components/base/chat/chat/type.ts @@ -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 diff --git a/web/app/components/share/text-generation/result/__tests__/workflow-stream-handlers.spec.ts b/web/app/components/share/text-generation/result/__tests__/workflow-stream-handlers.spec.ts index 63c0f43de0..88d7769e1d 100644 --- a/web/app/components/share/text-generation/result/__tests__/workflow-stream-handlers.spec.ts +++ b/web/app/components/share/text-generation/result/__tests__/workflow-stream-handlers.spec.ts @@ -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({ diff --git a/web/app/components/share/text-generation/result/workflow-stream-handlers.ts b/web/app/components/share/text-generation/result/workflow-stream-handlers.ts index f7db4c6b24..31b2fa283f 100644 --- a/web/app/components/share/text-generation/result/workflow-stream-handlers.ts +++ b/web/app/components/share/text-generation/result/workflow-stream-handlers.ts @@ -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[number], ) => { return updateWorkflowProcess(current, (draft) => { + let requiredFormData: NonNullable[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) }) } diff --git a/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/sse-callbacks.spec.ts b/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/sse-callbacks.spec.ts index f8f3a02c5c..109078db80 100644 --- a/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/sse-callbacks.spec.ts +++ b/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/sse-callbacks.spec.ts @@ -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: [], + })) }) }) diff --git a/web/app/components/workflow/panel/debug-and-preview/hooks.ts b/web/app/components/workflow/panel/debug-and-preview/hooks.ts index 25592f5326..24f643ec87 100644 --- a/web/app/components/workflow/panel/debug-and-preview/hooks.ts +++ b/web/app/components/workflow/panel/debug-and-preview/hooks.ts @@ -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[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[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) } }) }, diff --git a/web/types/workflow.ts b/web/types/workflow.ts index 57f7c0a560..4036a40cb9 100644 --- a/web/types/workflow.ts +++ b/web/types/workflow.ts @@ -353,6 +353,8 @@ export type HumanInputFilledFormData = { rendered_content: string action_id: string action_text: string + form_content?: string + inputs?: FormInputItem[] form_data?: Record }