refactor: update human input form handling to support async submission and improve placeholder resolution

This commit is contained in:
twwu 2025-12-24 12:13:32 +08:00
parent ddfd1cb1f5
commit 3c0fd213bf
12 changed files with 168 additions and 182 deletions

View File

@ -1,111 +1,60 @@
import React from 'react'
import React, { useMemo } 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 FormInputBoolean from '@/app/components/workflow/nodes/_base/components/form-input-boolean'
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'
import type { ContentItemProps } from './type'
type Props = {
content: string
formInputFields: GeneratedFormInputItem[]
inputs: Record<string, any>
onInputChange: (name: string, value: any) => void
}
const ContentItem = ({ content, formInputFields, inputs, onInputChange }: Props) => {
const ContentItem = ({
content,
formInputFields,
inputs,
resolvedPlaceholderValues,
onInputChange,
}: ContentItemProps) => {
const isInputField = (field: string) => {
const outputVarRegex = /{{#\$output\.[^#]+#}}/
return outputVarRegex.test(field)
}
const extractFieldName = (str: string) => {
const extractFieldName = (str: string): string => {
const outputVarRegex = /{{#\$output\.([^#]+)#}}/
const match = str.match(outputVarRegex)
return match ? match[1] : ''
}
const fieldName = useMemo(() => {
return extractFieldName(content)
}, [content])
const formInputField = useMemo(() => {
return formInputFields.find(field => field.output_variable_name === fieldName)
}, [formInputFields, fieldName])
const placeholder = useMemo(() => {
return formInputField?.placeholder.type === 'variable'
? resolvedPlaceholderValues?.[fieldName]
: formInputField?.placeholder.value
}, [formInputField, resolvedPlaceholderValues, fieldName])
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}
placeholder={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 === 'checkbox' && (
<FormInputBoolean
value={inputs[fieldName] as boolean}
onChange={value => onInputChange(fieldName, 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
export default React.memo(ContentItem)

View File

@ -1,32 +1,18 @@
'use client'
import React, { useState } from 'react'
import React, { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import ContentItem from './content-item'
import type { GeneratedFormInputItem, UserAction } from '@/app/components/workflow/nodes/human-input/types'
import { getButtonStyle, initializeInputs, splitByOutputVar } from './utils'
export type FormData = {
form_id: string
site: any
form_content: string
inputs: GeneratedFormInputItem[]
user_actions: UserAction[]
timeout: number
timeout_unit: 'hour' | 'day'
}
export type Props = {
formData: FormData
showTimeout?: boolean
onSubmit?: (formID: string, data: any) => void
}
import type { HumanInputFormProps } from './type'
const HumanInputForm = ({
formData,
showTimeout,
timeout,
timeoutUnit,
onSubmit,
}: Props) => {
}: HumanInputFormProps) => {
const { t } = useTranslation()
const formID = formData.form_id
@ -35,12 +21,12 @@ const HumanInputForm = ({
const [inputs, setInputs] = useState(defaultInputs)
const [isSubmitting, setIsSubmitting] = useState(false)
const handleInputsChange = (name: string, value: any) => {
const handleInputsChange = useCallback((name: string, value: any) => {
setInputs(prev => ({
...prev,
[name]: value,
}))
}
}, [])
const submit = async (formID: string, actionID: string, inputs: Record<string, any>) => {
setIsSubmitting(true)
@ -55,12 +41,13 @@ const HumanInputForm = ({
key={index}
content={content}
formInputFields={formData.inputs}
resolvedPlaceholderValues={formData.resolved_placeholder_values || {}}
inputs={inputs}
onInputChange={handleInputsChange}
/>
))}
<div className='flex flex-wrap gap-1 py-1'>
{formData.user_actions.map((action: any) => (
{formData.actions.map((action: any) => (
<Button
key={action.id}
disabled={isSubmitting}
@ -73,7 +60,7 @@ const HumanInputForm = ({
</div>
{showTimeout && (
<div className='system-xs-regular mt-1 text-text-tertiary'>
{formData.timeout_unit === 'day' ? t('share.humanInput.timeoutDay', { count: formData.timeout }) : t('share.humanInput.timeoutHour', { count: formData.timeout })}
{timeoutUnit === 'day' ? t('share.humanInput.timeoutDay', { count: timeout }) : t('share.humanInput.timeoutHour', { count: timeout })}
</div>
)}
</>

View File

@ -1,40 +1,30 @@
import { useTranslation } from 'react-i18next'
import HumanInputForm from './human-input-form'
import type { FormData } from './human-input-form'
import { useChatContext } from '../../context'
import type { HumanInputFormData } from '@/types/workflow'
import type { DeliveryMethod } from '@/app/components/workflow/nodes/human-input/types'
import { DeliveryMethodType } from '@/app/components/workflow/nodes/human-input/types'
import Divider from '@/app/components/base/divider'
import type { HumanInputContentProps } from './type'
type Props = {
formData: HumanInputFormData
showTimeout?: boolean
onSubmit?: (formID: string, data: any) => void
}
const HumanInputContent = ({ formData, onSubmit }: Props) => {
const HumanInputContent = ({
formData,
showEmailTip = false,
showDebugModeTip = false,
showTimeout = false,
onSubmit,
}: HumanInputContentProps) => {
const { t } = useTranslation()
const {
getHumanInputNodeData,
} = useChatContext()
const deliveryMethodsConfig = getHumanInputNodeData?.(formData.node_id as any)?.data.delivery_methods || []
const isWebappEnabled = deliveryMethodsConfig.some((method: DeliveryMethod) => method.type === DeliveryMethodType.WebApp && method.enabled)
const isEmailEnabled = deliveryMethodsConfig.some((method: DeliveryMethod) => method.type === DeliveryMethodType.Email && method.enabled)
return (
<>
<HumanInputForm
formData={formData as any as FormData}
formData={formData}
showTimeout={showTimeout}
onSubmit={onSubmit}
/>
{(!isWebappEnabled || isEmailEnabled) && (
{(showEmailTip || showDebugModeTip) && (
<>
<Divider className='!my-2 w-[30px]' />
<div className='space-y-1 pt-1'>
{isEmailEnabled && <div className='system-xs-regular text-text-secondary'>{t('humanInputEmailTip')}</div>}
{!isWebappEnabled && <div className='system-xs-medium text-text-warning'>{t('humanInputWebappTip')}</div>}
{showEmailTip && <div className='system-xs-regular text-text-secondary'>{t('humanInputEmailTip')}</div>}
{showDebugModeTip && <div className='system-xs-medium text-text-warning'>{t('humanInputWebappTip')}</div>}
</div>
</>
)}

View File

@ -0,0 +1,34 @@
import type { GeneratedFormInputItem } from '@/app/components/workflow/nodes/human-input/types'
import type { HumanInputFormData } from '@/types/workflow'
export type ExecutedAction = {
id: string
title: string
}
export type HumanInputContentProps = {
formData: HumanInputFormData
executedAction?: ExecutedAction
showEmailTip?: boolean
showDebugModeTip?: boolean
showTimeout?: boolean
timeout?: number
timeoutUnit?: 'hour' | 'day'
onSubmit?: (formID: string, data: any) => Promise<void>
}
export type HumanInputFormProps = {
formData: HumanInputFormData
showTimeout?: boolean
timeout?: number
timeoutUnit?: 'hour' | 'day'
onSubmit?: (formID: string, data: any) => Promise<void>
}
export type ContentItemProps = {
content: string
formInputFields: GeneratedFormInputItem[]
inputs: Record<string, string>
resolvedPlaceholderValues?: Record<string, string>
onInputChange: (name: string, value: any) => void
}

View File

@ -1,5 +1,5 @@
import { UserActionButtonType } from '@/app/components/workflow/nodes/human-input/types'
import type { GeneratedFormInputItem } from '@/app/components/workflow/nodes/human-input/types'
import { UserActionButtonType } from '@/app/components/workflow/nodes/human-input/types'
export const getButtonStyle = (style: UserActionButtonType) => {
if (style === UserActionButtonType.Primary)

View File

@ -2,7 +2,7 @@ import type {
FC,
ReactNode,
} from 'react'
import { memo, useCallback, useEffect, useRef, useState } from 'react'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type {
ChatConfig,
@ -23,6 +23,9 @@ import { cn } from '@/utils/classnames'
import { FileList } from '@/app/components/base/file-uploader'
import ContentSwitch from '../content-switch'
import HumanInputContent from './human-input-content'
import { useChatContext } from '../context'
import type { DeliveryMethod } from '@/app/components/workflow/nodes/human-input/types'
import { DeliveryMethodType } from '@/app/components/workflow/nodes/human-input/types'
type AnswerProps = {
item: ChatItem
@ -38,7 +41,7 @@ type AnswerProps = {
noChatInput?: boolean
switchSibling?: (siblingMessageId: string) => void
hideAvatar?: boolean
onHumanInputFormSubmit?: (formID: string, formData: any) => void
onHumanInputFormSubmit?: (formID: string, formData: any) => Promise<void>
}
const Answer: FC<AnswerProps> = ({
item,
@ -75,6 +78,26 @@ const Answer: FC<AnswerProps> = ({
const containerRef = useRef<HTMLDivElement>(null)
const contentRef = useRef<HTMLDivElement>(null)
const {
getHumanInputNodeData,
} = useChatContext()
const deliveryMethodsConfig = useMemo(() => {
const deliveryMethodsConfig = getHumanInputNodeData?.(humanInputFormData?.node_id as any)?.data.delivery_methods || []
if (!deliveryMethodsConfig.length) {
return {
showEmailTip: false,
showDebugModeTip: false,
}
}
const isWebappEnabled = deliveryMethodsConfig.some((method: DeliveryMethod) => method.type === DeliveryMethodType.WebApp && method.enabled)
const isEmailEnabled = deliveryMethodsConfig.some((method: DeliveryMethod) => method.type === DeliveryMethodType.Email && method.enabled)
return {
showEmailTip: isEmailEnabled,
showDebugModeTip: !isWebappEnabled,
}
}, [getHumanInputNodeData, humanInputFormData?.node_id])
const getContainerWidth = () => {
if (containerRef.current)
setContainerWidth(containerRef.current?.clientWidth + 16)
@ -176,7 +199,9 @@ const Answer: FC<AnswerProps> = ({
}
{humanInputFormData && (
<HumanInputContent
formData={humanInputFormData as any} // TODO type
formData={humanInputFormData}
showEmailTip={deliveryMethodsConfig.showEmailTip}
showDebugModeTip={deliveryMethodsConfig.showDebugModeTip}
onSubmit={onHumanInputFormSubmit}
/>
)}

View File

@ -73,7 +73,7 @@ export type ChatProps = {
inputDisabled?: boolean
sidebarCollapseState?: boolean
hideAvatar?: boolean
onHumanInputFormSubmit?: (formID: string, formData: any) => void
onHumanInputFormSubmit?: (formID: string, formData: any) => Promise<void>
getHumanInputNodeData?: (nodeID: string) => any
}

View File

@ -77,11 +77,9 @@ export type UserAction = {
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[]
placeholder: {
selector: ValueSelector
type: 'variable' | 'constant'
value: string
}
}

View File

@ -321,7 +321,7 @@ export const handleStream = (
else if (bufferObj.event === 'human_input_required') {
onHumanInputRequired?.(bufferObj as HumanInputRequiredResponse)
}
else if (bufferObj.event === 'workflow_suspended') {
else if (bufferObj.event === 'workflow_paused') {
onWorkflowSuspended?.(bufferObj as WorkflowSuspendedResponse)
}
else if (bufferObj.event === 'datasource_processing') {
@ -656,44 +656,46 @@ export const sseGet = async (
}
return
}
return handleStream(res, (str: string, isFirstMessage: boolean, moreInfo: IOnDataMoreInfo) => {
if (moreInfo.errorMessage) {
onError?.(moreInfo.errorMessage, moreInfo.errorCode)
// TypeError: Cannot assign to read only property ... will happen in page leave, so it should be ignored.
if (moreInfo.errorMessage !== 'AbortError: The user aborted a request.' && !moreInfo.errorMessage.includes('TypeError: Cannot assign to read only property'))
Toast.notify({ type: 'error', message: moreInfo.errorMessage })
return
}
onData?.(str, isFirstMessage, moreInfo)
},
onCompleted,
onThought,
onMessageEnd,
onMessageReplace,
onFile,
onWorkflowStarted,
onWorkflowFinished,
onNodeStarted,
onNodeFinished,
onIterationStart,
onIterationNext,
onIterationFinish,
onLoopStart,
onLoopNext,
onLoopFinish,
onNodeRetry,
onParallelBranchStarted,
onParallelBranchFinished,
onTextChunk,
onTTSChunk,
onTTSEnd,
onTextReplace,
onAgentLog,
onHumanInputRequired,
onWorkflowSuspended,
onDataSourceNodeProcessing,
onDataSourceNodeCompleted,
onDataSourceNodeError,
return handleStream(
res,
(str: string, isFirstMessage: boolean, moreInfo: IOnDataMoreInfo) => {
if (moreInfo.errorMessage) {
onError?.(moreInfo.errorMessage, moreInfo.errorCode)
// TypeError: Cannot assign to read only property ... will happen in page leave, so it should be ignored.
if (moreInfo.errorMessage !== 'AbortError: The user aborted a request.' && !moreInfo.errorMessage.includes('TypeError: Cannot assign to read only property'))
Toast.notify({ type: 'error', message: moreInfo.errorMessage })
return
}
onData?.(str, isFirstMessage, moreInfo)
},
onCompleted,
onThought,
onMessageEnd,
onMessageReplace,
onFile,
onWorkflowStarted,
onWorkflowFinished,
onNodeStarted,
onNodeFinished,
onIterationStart,
onIterationNext,
onIterationFinish,
onLoopStart,
onLoopNext,
onLoopFinish,
onNodeRetry,
onParallelBranchStarted,
onParallelBranchFinished,
onTextChunk,
onTTSChunk,
onTTSEnd,
onTextReplace,
onAgentLog,
onHumanInputRequired,
onWorkflowSuspended,
onDataSourceNodeProcessing,
onDataSourceNodeCompleted,
onDataSourceNodeError,
)
}).catch((e) => {
if (e.toString() !== 'AbortError: The user aborted a request.' && !e.toString().includes('TypeError: Cannot assign to read only property'))

View File

@ -136,8 +136,7 @@ export const fetchFilePreview = ({ fileID }: { fileID: string }): Promise<{ cont
}
export const fetchCurrentWorkspace = ({ url, params }: { url: string; params: Record<string, any> }): Promise<ICurrentWorkspace> => {
// return post<ICurrentWorkspace>(url, { body: params })
return get<ICurrentWorkspace>(url, { params })
return post<ICurrentWorkspace>(url, { body: params })
}
export const updateCurrentWorkspace = ({ url, body }: { url: string; body: Record<string, any> }): Promise<ICurrentWorkspace> => {

View File

@ -105,7 +105,8 @@ export const useLangGeniusVersion = (currentVersion?: string | null, enabled?: b
export const useCurrentWorkspace = () => {
return useQuery<ICurrentWorkspace>({
queryKey: commonQueryKeys.currentWorkspace,
queryFn: () => post<ICurrentWorkspace>('/workspaces/current', { body: {} }),
// queryFn: () => post<ICurrentWorkspace>('/workspaces/current', { body: {} }),
queryFn: () => get<ICurrentWorkspace>('/workspaces/current'), // todo: Need to check later, POST or GET
})
}
@ -220,7 +221,7 @@ export const useIsLogin = () => {
})
}
catch (e: any) {
if(e.status === 401)
if (e.status === 401)
return { logged_in: false }
return { logged_in: true }
}

View File

@ -5,7 +5,7 @@ import type { ErrorHandleTypeEnum } from '@/app/components/workflow/nodes/_base/
import type { RAGPipelineVariables } from '@/models/pipeline'
import type { BeforeRunFormProps } from '@/app/components/workflow/nodes/_base/components/before-run-form'
import type { SpecialResultPanelProps } from '@/app/components/workflow/run/special-result-panel'
import type { GeneratedFormInputItem } from '@/app/components/workflow/nodes/human-input/types'
import type { GeneratedFormInputItem, UserAction } from '@/app/components/workflow/nodes/human-input/types'
import type { RefObject } from 'react'
export type AgentLogItem = {
@ -312,13 +312,14 @@ export type AgentLogResponse = {
}
export type HumanInputFormData = {
id: string
workflow_id: string
form_id: string
node_id: string
node_title: string
form_content: string
inputs: GeneratedFormInputItem[]
web_app_form_token: string
actions: UserAction[]
web_app_form_token: string // For WebApp
resolved_placeholder_values: Record<string, string> // For human input placeholder when its type is variable
}
export type HumanInputRequiredResponse = {