feat: enhance chat component to manage multiple human input forms and their submissions

This commit is contained in:
twwu 2026-01-05 15:30:17 +08:00
parent d0a713e117
commit 5a92bbdab5
16 changed files with 386 additions and 152 deletions

View File

@ -0,0 +1,62 @@
import { RiArrowDownSLine, RiArrowRightSLine } from '@remixicon/react'
import { useCallback, useState } from 'react'
import BlockIcon from '@/app/components/workflow/block-icon'
import { BlockEnum } from '@/app/components/workflow/types'
import { cn } from '@/utils/classnames'
type ContentWrapperProps = {
nodeTitle: string
children: React.ReactNode
showExpandIcon?: boolean
className?: string
}
const ContentWrapper = ({
nodeTitle,
children,
showExpandIcon = false,
className,
}: ContentWrapperProps) => {
const [isExpanded, setIsExpanded] = useState(false)
const handleToggleExpand = useCallback(() => {
setIsExpanded(!isExpanded)
}, [isExpanded])
return (
<div className={cn('rounded-2xl border-[0.5px] border-components-panel-border bg-background-section p-2 shadow-md', className)}>
<div className="flex items-center gap-2 p-2">
{/* node icon */}
<BlockIcon type={BlockEnum.HumanInput} className="shrink-0" />
{/* node name */}
<div
className="system-sm-semibold-uppercase grow truncate text-text-primary"
title={nodeTitle}
>
{nodeTitle}
</div>
{showExpandIcon && (
<div className="shrink-0 cursor-pointer" onClick={handleToggleExpand}>
{
isExpanded
? (
<RiArrowDownSLine className="size-4" />
)
: (
<RiArrowRightSLine className="size-4" />
)
}
</div>
)}
</div>
{(!showExpandIcon || isExpanded) && (
<div className="px-2 py-1">
{/* human input form content */}
{children}
</div>
)}
</div>
)
}
export default ContentWrapper

View File

@ -0,0 +1,30 @@
import type { ExecutedAction as ExecutedActionType } from './type'
import { memo } from 'react'
import { Trans } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import { TriggerAll } from '@/app/components/base/icons/src/vender/workflow'
type ExecutedActionProps = {
executedAction: ExecutedActionType
}
const ExecutedAction = ({
executedAction,
}: ExecutedActionProps) => {
return (
<div className="flex flex-col gap-y-1 py-1">
<Divider className="mb-2 mt-1 w-[30px]" />
<div className="system-xs-regular flex items-center gap-x-1 text-text-tertiary">
<TriggerAll className="size-3.5 shrink-0" />
<Trans
i18nKey="nodes.humanInput.userActions.triggered"
ns="workflow"
components={{ strong: <span className="system-xs-medium text-text-secondary"></span> }}
values={{ actionName: executedAction.title }}
/>
</div>
</div>
)
}
export default memo(ExecutedAction)

View File

@ -1,73 +0,0 @@
import type { HumanInputContentProps } from './type'
import { Trans, useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import { TriggerAll } from '@/app/components/base/icons/src/vender/workflow'
import { useSelector as useAppSelector } from '@/context/app-context'
import ExpirationTime from './expiration-time'
import HumanInputForm from './human-input-form'
const HumanInputContent = ({
formData,
showEmailTip = false,
isEmailDebugMode = false,
showDebugModeTip = false,
showTimeout = false,
executedAction,
expirationTime,
onSubmit,
}: HumanInputContentProps) => {
const { t } = useTranslation()
const email = useAppSelector(s => s.userProfile.email)
return (
<>
<HumanInputForm
formData={formData}
onSubmit={onSubmit}
/>
{/* Tips */}
{(showEmailTip || showDebugModeTip) && (
<>
<Divider className="!my-2 w-[30px]" />
<div className="space-y-1 pt-1">
{showEmailTip && !isEmailDebugMode && (
<div className="system-xs-regular text-text-secondary">{t('common.humanInputEmailTip', { ns: 'workflow' })}</div>
)}
{showEmailTip && isEmailDebugMode && (
<div className="system-xs-regular text-text-secondary">
<Trans
i18nKey="common.humanInputEmailTipInDebugMode"
ns="workflow"
components={{ email: <span className="system-xs-semibold"></span> }}
values={{ email }}
/>
</div>
)}
{showDebugModeTip && <div className="system-xs-medium text-text-warning">{t('common.humanInputWebappTip', { ns: 'workflow' })}</div>}
</div>
</>
)}
{/* Timeout */}
{showTimeout && typeof expirationTime === 'number' && (
<ExpirationTime expirationTime={expirationTime} />
)}
{/* Executed Action */}
{executedAction && (
<div className="flex flex-col gap-y-1 py-1">
<Divider className="mb-2 mt-1 w-[30px]" />
<div className="system-xs-regular flex items-center gap-x-1 text-text-tertiary">
<TriggerAll className="size-3.5 shrink-0" />
<Trans
i18nKey="nodes.humanInput.userActions.triggered"
ns="workflow"
components={{ strong: <span className="system-xs-medium text-text-secondary"></span> }}
values={{ actionName: executedAction.title }}
/>
</div>
</div>
)}
</>
)
}
export default HumanInputContent

View File

@ -0,0 +1,16 @@
import * as React from 'react'
import { Markdown } from '@/app/components/base/markdown'
type SubmittedContentProps = {
content: string
}
const SubmittedContent = ({
content,
}: SubmittedContentProps) => {
return (
<Markdown content={content} />
)
}
export default React.memo(SubmittedContent)

View File

@ -0,0 +1,25 @@
import type { SubmittedHumanInputContentProps } from './type'
import { useMemo } from 'react'
import ExecutedAction from './executed-action'
import SubmittedContent from './submitted-content'
export const SubmittedHumanInputContent = ({
formData,
}: SubmittedHumanInputContentProps) => {
const { rendered_content, action_id, action_text } = formData
const executedAction = useMemo(() => {
return {
id: action_id,
title: action_text,
}
}, [action_id, action_text])
return (
<>
<SubmittedContent content={rendered_content} />
{/* Executed Action */}
<ExecutedAction executedAction={executedAction} />
</>
)
}

View File

@ -0,0 +1,43 @@
import { memo } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import { useSelector as useAppSelector } from '@/context/app-context'
type TipsProps = {
showEmailTip: boolean
isEmailDebugMode: boolean
showDebugModeTip: boolean
}
const Tips = ({
showEmailTip,
isEmailDebugMode,
showDebugModeTip,
}: TipsProps) => {
const { t } = useTranslation()
const email = useAppSelector(s => s.userProfile.email)
return (
<>
<Divider className="!my-2 w-[30px]" />
<div className="space-y-1 pt-1">
{showEmailTip && !isEmailDebugMode && (
<div className="system-xs-regular text-text-secondary">{t('common.humanInputEmailTip', { ns: 'workflow' })}</div>
)}
{showEmailTip && isEmailDebugMode && (
<div className="system-xs-regular text-text-secondary">
<Trans
i18nKey="common.humanInputEmailTipInDebugMode"
ns="workflow"
components={{ email: <span className="system-xs-semibold"></span> }}
values={{ email }}
/>
</div>
)}
{showDebugModeTip && <div className="system-xs-medium text-text-warning">{t('common.humanInputWebappTip', { ns: 'workflow' })}</div>}
</div>
</>
)
}
export default memo(Tips)

View File

@ -1,14 +1,13 @@
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
import type { HumanInputFormData } from '@/types/workflow'
import type { HumanInputFilledFormData, HumanInputFormData } from '@/types/workflow'
export type ExecutedAction = {
id: string
title: string
}
export type HumanInputContentProps = {
export type UnsubmittedHumanInputContentProps = {
formData: HumanInputFormData
executedAction?: ExecutedAction
showEmailTip?: boolean
isEmailDebugMode?: boolean
showDebugModeTip?: boolean
@ -17,6 +16,10 @@ export type HumanInputContentProps = {
onSubmit?: (formID: string, data: any) => Promise<void>
}
export type SubmittedHumanInputContentProps = {
formData: HumanInputFilledFormData
}
export type HumanInputFormProps = {
formData: HumanInputFormData
onSubmit?: (formID: string, data: any) => Promise<void>

View File

@ -0,0 +1,36 @@
import type { UnsubmittedHumanInputContentProps } from './type'
import ExpirationTime from './expiration-time'
import HumanInputForm from './human-input-form'
import Tips from './tips'
export const UnsubmittedHumanInputContent = ({
formData,
showEmailTip = false,
isEmailDebugMode = false,
showDebugModeTip = false,
showTimeout = false,
expirationTime,
onSubmit,
}: UnsubmittedHumanInputContentProps) => {
return (
<>
{/* Form */}
<HumanInputForm
formData={formData}
onSubmit={onSubmit}
/>
{/* Tips */}
{(showEmailTip || showDebugModeTip) && (
<Tips
showEmailTip={showEmailTip}
isEmailDebugMode={isEmailDebugMode}
showDebugModeTip={showDebugModeTip}
/>
)}
{/* Timeout */}
{showTimeout && typeof expirationTime === 'number' && (
<ExpirationTime expirationTime={expirationTime} />
)}
</>
)
}

View File

@ -0,0 +1,33 @@
import type { HumanInputFilledFormData } from '@/types/workflow'
import ContentWrapper from './human-input-content/content-wrapper'
import { SubmittedHumanInputContent } from './human-input-content/submitted'
type HumanInputFilledFormListProps = {
humanInputFilledFormDataList: HumanInputFilledFormData[]
}
const HumanInputFilledFormList = ({
humanInputFilledFormDataList,
}: HumanInputFilledFormListProps) => {
return (
<div className="mt-2">
{
humanInputFilledFormDataList.map(formData => (
<ContentWrapper
key={formData.node_id}
nodeTitle="todo: replace with node title"
showExpandIcon
className="mb-2 last:mb-0"
>
<SubmittedHumanInputContent
key={formData.node_id}
formData={formData}
/>
</ContentWrapper>
))
}
</div>
)
}
export default HumanInputFilledFormList

View File

@ -0,0 +1,68 @@
import type { DeliveryMethod } from '@/app/components/workflow/nodes/human-input/types'
import type { HumanInputFormData } from '@/types/workflow'
import { useMemo } from 'react'
import { DeliveryMethodType } from '@/app/components/workflow/nodes/human-input/types'
import ContentWrapper from './human-input-content/content-wrapper'
import { UnsubmittedHumanInputContent } from './human-input-content/unsubmitted'
type HumanInputFormListProps = {
humanInputFormDataList: HumanInputFormData[]
onHumanInputFormSubmit?: (formID: string, formData: any) => Promise<void>
getHumanInputNodeData?: (nodeID: string) => any
}
const HumanInputFormList = ({
humanInputFormDataList,
onHumanInputFormSubmit,
getHumanInputNodeData,
}: HumanInputFormListProps) => {
const deliveryMethodsConfig = useMemo((): Record<string, { showEmailTip: boolean, isEmailDebugMode: boolean, showDebugModeTip: boolean }> => {
if (!humanInputFormDataList.length)
return {}
return humanInputFormDataList.reduce((acc, formData) => {
const deliveryMethodsConfig = getHumanInputNodeData?.(formData.node_id)?.data.delivery_methods || []
if (!deliveryMethodsConfig.length) {
acc[formData.node_id] = {
showEmailTip: false,
isEmailDebugMode: false,
showDebugModeTip: false,
}
return acc
}
const isWebappEnabled = deliveryMethodsConfig.some((method: DeliveryMethod) => method.type === DeliveryMethodType.WebApp && method.enabled)
const isEmailEnabled = deliveryMethodsConfig.some((method: DeliveryMethod) => method.type === DeliveryMethodType.Email && method.enabled)
const isEmailDebugMode = deliveryMethodsConfig.some((method: DeliveryMethod) => method.type === DeliveryMethodType.Email && method.config?.debug_mode)
acc[formData.node_id] = {
showEmailTip: isEmailEnabled,
isEmailDebugMode,
showDebugModeTip: !isWebappEnabled,
}
return acc
}, {} as Record<string, { showEmailTip: boolean, isEmailDebugMode: boolean, showDebugModeTip: boolean }>)
}, [getHumanInputNodeData, humanInputFormDataList])
return (
<div className="mt-2">
{
humanInputFormDataList.map(formData => (
<ContentWrapper
key={formData.node_id}
nodeTitle={formData.node_title}
className="mb-2 last:mb-0"
>
<UnsubmittedHumanInputContent
key={formData.node_id}
formData={formData}
showEmailTip={!!deliveryMethodsConfig[formData.node_id]?.showEmailTip}
isEmailDebugMode={!!deliveryMethodsConfig[formData.node_id]?.isEmailDebugMode}
showDebugModeTip={!!deliveryMethodsConfig[formData.node_id]?.showDebugModeTip}
onSubmit={onHumanInputFormSubmit}
/>
</ContentWrapper>
))
}
</div>
)
}
export default HumanInputFormList

View File

@ -6,24 +6,21 @@ import type {
ChatConfig,
ChatItem,
} from '../../types'
import type { ExecutedAction } from './human-input-content/type'
import type { DeliveryMethod } from '@/app/components/workflow/nodes/human-input/types'
import type { AppData } from '@/models/share'
import type { HumanInputFormData } from '@/types/workflow'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { memo, useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { EditTitle } from '@/app/components/app/annotation/edit-annotation-modal/edit-item'
import AnswerIcon from '@/app/components/base/answer-icon'
import Citation from '@/app/components/base/chat/chat/citation'
import LoadingAnim from '@/app/components/base/chat/chat/loading-anim'
import { FileList } from '@/app/components/base/file-uploader'
import { DeliveryMethodType } from '@/app/components/workflow/nodes/human-input/types'
import { cn } from '@/utils/classnames'
import ContentSwitch from '../content-switch'
import { useChatContext } from '../context'
import AgentContent from './agent-content'
import BasicContent from './basic-content'
import HumanInputContent from './human-input-content'
import HumanInputFilledFormList from './human-input-filled-form-list'
import HumanInputFormList from './human-input-form-list'
import More from './more'
import Operation from './operation'
import SuggestedQuestions from './suggested-questions'
@ -71,8 +68,8 @@ const Answer: FC<AnswerProps> = ({
workflowProcess,
allFiles,
message_files,
humanInputFormData,
humanInputFormFilledData,
humanInputFormDataList,
humanInputFilledFormDataList,
} = item
const hasAgentThoughts = !!agent_thoughts?.length
@ -85,49 +82,6 @@ const Answer: FC<AnswerProps> = ({
getHumanInputNodeData,
} = useChatContext()
const deliveryMethodsConfig = useMemo(() => {
const deliveryMethodsConfig = getHumanInputNodeData?.(humanInputFormData?.node_id as any)?.data.delivery_methods || []
if (!deliveryMethodsConfig.length) {
return {
showEmailTip: false,
isEmailDebugMode: 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)
const isEmailDebugMode = deliveryMethodsConfig.some((method: DeliveryMethod) => method.type === DeliveryMethodType.Email && method.config?.debug_mode)
return {
showEmailTip: isEmailEnabled,
isEmailDebugMode,
showDebugModeTip: !isWebappEnabled,
}
}, [getHumanInputNodeData, humanInputFormData?.node_id])
const filledFormData = useMemo((): HumanInputFormData | undefined => {
if (!humanInputFormFilledData)
return
return {
form_id: '',
node_id: humanInputFormFilledData.node_id,
node_title: '',
form_content: humanInputFormFilledData.rendered_content,
inputs: [],
actions: [],
web_app_form_token: '',
resolved_placeholder_values: {},
}
}, [humanInputFormFilledData])
const executedAction = useMemo((): ExecutedAction | undefined => {
if (!humanInputFormFilledData)
return
return {
id: humanInputFormFilledData.action_id,
title: humanInputFormFilledData.action_text,
}
}, [humanInputFormFilledData])
const getContainerWidth = () => {
if (containerRef.current)
setContainerWidth(containerRef.current?.clientWidth + 16)
@ -228,21 +182,18 @@ const Answer: FC<AnswerProps> = ({
)
}
{
humanInputFormData && (
<HumanInputContent
formData={humanInputFormData}
showEmailTip={deliveryMethodsConfig.showEmailTip}
isEmailDebugMode={deliveryMethodsConfig.isEmailDebugMode}
showDebugModeTip={deliveryMethodsConfig.showDebugModeTip}
onSubmit={onHumanInputFormSubmit}
humanInputFormDataList && humanInputFormDataList.length > 0 && (
<HumanInputFormList
humanInputFormDataList={humanInputFormDataList}
onHumanInputFormSubmit={onHumanInputFormSubmit}
getHumanInputNodeData={getHumanInputNodeData}
/>
)
}
{
filledFormData && (
<HumanInputContent
formData={filledFormData}
executedAction={executedAction}
humanInputFilledFormDataList && humanInputFilledFormDataList.length > 0 && (
<HumanInputFilledFormList
humanInputFilledFormDataList={humanInputFilledFormDataList}
/>
)
}

View File

@ -69,7 +69,7 @@ const Operation: FC<OperationProps> = ({
feedback,
adminFeedback,
agent_thoughts,
humanInputFormData,
humanInputFormDataList,
} = item
const [userLocalFeedback, setUserLocalFeedback] = useState(feedback)
const [adminLocalFeedback, setAdminLocalFeedback] = useState(adminFeedback)
@ -304,7 +304,7 @@ const Operation: FC<OperationProps> = ({
<Log logItem={item} />
</div>
)}
{!isOpeningStatement && !humanInputFormData && (
{!isOpeningStatement && !humanInputFormDataList?.length && (
<div className="ml-1 hidden items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-sm group-hover:flex">
{(config?.text_to_speech?.enabled) && (
<NewAudioButton

View File

@ -652,7 +652,18 @@ export const useChat = (
})
},
onHumanInputRequired: ({ data: humanInputRequiredData }) => {
responseItem.humanInputFormData = humanInputRequiredData
if (!responseItem.humanInputFormDataList) {
responseItem.humanInputFormDataList = [humanInputRequiredData]
}
else {
const currentFormIndex = responseItem.humanInputFormDataList!.findIndex(item => item.node_id === humanInputRequiredData.node_id)
if (currentFormIndex > -1) {
responseItem.humanInputFormDataList[currentFormIndex] = humanInputRequiredData
}
else {
responseItem.humanInputFormDataList.push(humanInputRequiredData)
}
}
const currentTracingIndex = responseItem.workflowProcess!.tracing!.findIndex(item => item.node_id === humanInputRequiredData.node_id)
if (currentTracingIndex > -1) {
responseItem.workflowProcess!.tracing[currentTracingIndex].status = NodeRunningStatus.Paused
@ -664,9 +675,17 @@ export const useChat = (
})
}
},
onHumanInputFormFilled: ({ data: humanInputFormFilledData }) => {
delete responseItem.humanInputFormData
responseItem.humanInputFormFilledData = humanInputFormFilledData
onHumanInputFormFilled: ({ data: humanInputFilledFormData }) => {
if (responseItem.humanInputFormDataList?.length) {
const currentFormIndex = responseItem.humanInputFormDataList!.findIndex(item => item.node_id === humanInputFilledFormData.node_id)
responseItem.humanInputFormDataList.splice(currentFormIndex, 1)
}
if (!responseItem.humanInputFilledFormDataList) {
responseItem.humanInputFilledFormDataList = [humanInputFilledFormData]
}
else {
responseItem.humanInputFilledFormDataList.push(humanInputFilledFormData)
}
updateCurrentQAOnTree({
placeholderQuestionId,
questionItem,

View File

@ -4,8 +4,8 @@ import type { InputVarType } from '@/app/components/workflow/types'
import type { Annotation, MessageRating } from '@/models/log'
import type {
FileResponse,
HumanInputFilledFormData,
HumanInputFormData,
HumanInputFormFilledData,
} from '@/types/workflow'
export type MessageMore = {
@ -109,8 +109,8 @@ export type IChatItem = {
prevSibling?: string
nextSibling?: string
// for human input
humanInputFormData?: HumanInputFormData
humanInputFormFilledData?: HumanInputFormFilledData
humanInputFormDataList?: HumanInputFormData[]
humanInputFilledFormDataList?: HumanInputFilledFormData[]
}
export type Metadata = {

View File

@ -249,6 +249,8 @@ export const useChat = (
isAnswer: true,
parentMessageId: questionItem.id,
siblingIndex: parentMessage?.children?.length ?? chatTree.length,
humanInputFormDataList: [],
humanInputFilledFormDataList: [],
}
handleResponding(true)
@ -527,7 +529,18 @@ export const useChat = (
}
},
onHumanInputRequired: ({ data }) => {
responseItem.humanInputFormData = data
if (!responseItem.humanInputFormDataList) {
responseItem.humanInputFormDataList = [data]
}
else {
const currentFormIndex = responseItem.humanInputFormDataList!.findIndex(item => item.node_id === data.node_id)
if (currentFormIndex > -1) {
responseItem.humanInputFormDataList[currentFormIndex] = data
}
else {
responseItem.humanInputFormDataList.push(data)
}
}
const currentTracingIndex = responseItem.workflowProcess!.tracing!.findIndex(item => item.node_id === data.node_id)
if (currentTracingIndex > -1) {
responseItem.workflowProcess!.tracing[currentTracingIndex].status = NodeRunningStatus.Paused
@ -540,8 +553,16 @@ export const useChat = (
}
},
onHumanInputFormFilled: ({ data }) => {
delete responseItem.humanInputFormData
responseItem.humanInputFormFilledData = data
if (responseItem.humanInputFormDataList?.length) {
const currentFormIndex = responseItem.humanInputFormDataList!.findIndex(item => item.node_id === data.node_id)
responseItem.humanInputFormDataList.splice(currentFormIndex, 1)
}
if (!responseItem.humanInputFilledFormDataList) {
responseItem.humanInputFilledFormDataList = [data]
}
else {
responseItem.humanInputFilledFormDataList.push(data)
}
updateCurrentQAOnTree({
placeholderQuestionId,
questionItem,

View File

@ -331,7 +331,7 @@ export type HumanInputRequiredResponse = {
data: HumanInputFormData
}
export type HumanInputFormFilledData = {
export type HumanInputFilledFormData = {
node_id: string
rendered_content: string
action_id: string
@ -342,7 +342,7 @@ export type HumanInputFormFilledResponse = {
task_id: string
workflow_run_id: string
event: string
data: HumanInputFormFilledData
data: HumanInputFilledFormData
}
export type WorkflowRunHistory = {