feat: implement human input form handling and display components

This commit is contained in:
twwu 2026-01-05 17:05:10 +08:00
parent cf9b72d574
commit f654a7f704
15 changed files with 229 additions and 66 deletions

View File

@ -10,14 +10,13 @@ const HumanInputFilledFormList = ({
humanInputFilledFormDataList,
}: HumanInputFilledFormListProps) => {
return (
<div className="mt-2">
<div className="mt-2 flex flex-col gap-y-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}

View File

@ -42,13 +42,12 @@ const HumanInputFormList = ({
}, [getHumanInputNodeData, humanInputFormDataList])
return (
<div className="mt-2">
<div className="mt-2 flex flex-col gap-y-2">
{
humanInputFormDataList.map(formData => (
<ContentWrapper
key={formData.node_id}
nodeTitle={formData.node_title}
className="mb-2 last:mb-0"
>
<UnsubmittedHumanInputContent
key={formData.node_id}

View File

@ -176,11 +176,6 @@ const Answer: FC<AnswerProps> = ({
</div>
)
}
{
!contentIsEmpty && !hasAgentThoughts && (
<BasicContent item={item} />
)
}
{
humanInputFormDataList && humanInputFormDataList.length > 0 && (
<HumanInputFormList
@ -197,6 +192,11 @@ const Answer: FC<AnswerProps> = ({
/>
)
}
{
!contentIsEmpty && !hasAgentThoughts && (
<BasicContent item={item} />
)
}
{
(hasAgentThoughts) && (
<AgentContent

View File

@ -80,6 +80,7 @@ export const useWorkflowRun = () => {
handleWorkflowNodeStarted,
handleWorkflowNodeFinished,
handleWorkflowNodeHumanInputRequired,
handleWorkflowNodeHumanInputFormFilled,
handleWorkflowNodeIterationStarted,
handleWorkflowNodeIterationNext,
handleWorkflowNodeIterationFinished,
@ -795,6 +796,7 @@ export const useWorkflowRun = () => {
onHumanInputRequired(params)
},
onHumanInputFormFilled: (params) => {
handleWorkflowNodeHumanInputFormFilled(params)
if (onHumanInputFormFilled)
onHumanInputFormFilled(params)
},
@ -808,7 +810,7 @@ export const useWorkflowRun = () => {
},
finalCallbacks,
)
}, [store, doSyncWorkflowDraft, workflowStore, pathname, handleWorkflowFailed, flowId, handleWorkflowResume, handleWorkflowStarted, handleWorkflowFinished, fetchInspectVars, invalidAllLastRun, handleWorkflowNodeStarted, handleWorkflowNodeFinished, handleWorkflowNodeIterationStarted, handleWorkflowNodeIterationNext, handleWorkflowNodeIterationFinished, handleWorkflowNodeLoopStarted, handleWorkflowNodeLoopNext, handleWorkflowNodeLoopFinished, handleWorkflowNodeRetry, handleWorkflowAgentLog, handleWorkflowTextChunk, handleWorkflowTextReplace, handleWorkflowPaused, handleWorkflowNodeHumanInputRequired])
}, [store, doSyncWorkflowDraft, workflowStore, pathname, handleWorkflowFailed, flowId, handleWorkflowResume, handleWorkflowStarted, handleWorkflowFinished, fetchInspectVars, invalidAllLastRun, handleWorkflowNodeStarted, handleWorkflowNodeFinished, handleWorkflowNodeIterationStarted, handleWorkflowNodeIterationNext, handleWorkflowNodeIterationFinished, handleWorkflowNodeLoopStarted, handleWorkflowNodeLoopNext, handleWorkflowNodeLoopFinished, handleWorkflowNodeRetry, handleWorkflowAgentLog, handleWorkflowTextChunk, handleWorkflowTextReplace, handleWorkflowPaused, handleWorkflowNodeHumanInputRequired, handleWorkflowNodeHumanInputFormFilled])
const handleStopRun = useCallback((taskId: string) => {
const setStoppedState = () => {

View File

@ -2,6 +2,7 @@ export * from './use-workflow-agent-log'
export * from './use-workflow-failed'
export * from './use-workflow-finished'
export * from './use-workflow-node-finished'
export * from './use-workflow-node-human-input-form-filled'
export * from './use-workflow-node-human-input-required'
export * from './use-workflow-node-iteration-finished'
export * from './use-workflow-node-iteration-next'

View File

@ -0,0 +1,34 @@
import type { HumanInputFormFilledResponse } from '@/types/workflow'
import { produce } from 'immer'
import { useCallback } from 'react'
import { useWorkflowStore } from '@/app/components/workflow/store'
export const useWorkflowNodeHumanInputFormFilled = () => {
const workflowStore = useWorkflowStore()
const handleWorkflowNodeHumanInputFormFilled = useCallback((params: HumanInputFormFilledResponse) => {
const { data } = params
const {
workflowRunningData,
setWorkflowRunningData,
} = workflowStore.getState()
const newWorkflowRunningData = produce(workflowRunningData!, (draft) => {
if (draft.humanInputFormDataList?.length) {
const currentFormIndex = draft.humanInputFormDataList.findIndex(item => item.node_id === data.node_id)
draft.humanInputFormDataList.splice(currentFormIndex, 1)
}
if (!draft.humanInputFilledFormDataList) {
draft.humanInputFilledFormDataList = [data]
}
else {
draft.humanInputFilledFormDataList.push(data)
}
})
setWorkflowRunningData(newWorkflowRunningData)
}, [workflowStore])
return {
handleWorkflowNodeHumanInputFormFilled,
}
}

View File

@ -4,14 +4,36 @@ import { useCallback } from 'react'
import {
useStoreApi,
} from 'reactflow'
import { useWorkflowStore } from '@/app/components/workflow/store'
import { NodeRunningStatus } from '@/app/components/workflow/types'
export const useWorkflowNodeHumanInputRequired = () => {
const store = useStoreApi()
const workflowStore = useWorkflowStore()
// ! Human input required !== Workflow Paused
// Notice: Human input required !== Workflow Paused
const handleWorkflowNodeHumanInputRequired = useCallback((params: HumanInputRequiredResponse) => {
const { data } = params
const {
workflowRunningData,
setWorkflowRunningData,
} = workflowStore.getState()
const newWorkflowRunningData = produce(workflowRunningData!, (draft) => {
if (!draft.humanInputFormDataList) {
draft.humanInputFormDataList = [data]
}
else {
const currentFormIndex = draft.humanInputFormDataList.findIndex(item => item.node_id === data.node_id)
if (currentFormIndex > -1) {
draft.humanInputFormDataList[currentFormIndex] = data
}
else {
draft.humanInputFormDataList.push(data)
}
}
})
setWorkflowRunningData(newWorkflowRunningData)
const {
getNodes,
@ -23,7 +45,7 @@ export const useWorkflowNodeHumanInputRequired = () => {
draft[currentNodeIndex].data._runningStatus = NodeRunningStatus.Paused
})
setNodes(newNodes)
}, [store])
}, [store, workflowStore])
return {
handleWorkflowNodeHumanInputRequired,

View File

@ -3,6 +3,7 @@ import {
useWorkflowFailed,
useWorkflowFinished,
useWorkflowNodeFinished,
useWorkflowNodeHumanInputFormFilled,
useWorkflowNodeHumanInputRequired,
useWorkflowNodeIterationFinished,
useWorkflowNodeIterationNext,
@ -37,6 +38,7 @@ export const useWorkflowRunEvent = () => {
const { handleWorkflowAgentLog } = useWorkflowAgentLog()
const { handleWorkflowPaused } = useWorkflowPaused()
const { handleWorkflowNodeHumanInputRequired } = useWorkflowNodeHumanInputRequired()
const { handleWorkflowNodeHumanInputFormFilled } = useWorkflowNodeHumanInputFormFilled()
const { handleWorkflowResume } = useWorkflowResume()
return {
@ -56,6 +58,7 @@ export const useWorkflowRunEvent = () => {
handleWorkflowTextReplace,
handleWorkflowAgentLog,
handleWorkflowPaused,
handleWorkflowNodeHumanInputFormFilled,
handleWorkflowNodeHumanInputRequired,
handleWorkflowResume,
}

View File

@ -533,7 +533,7 @@ export const useChat = (
responseItem.humanInputFormDataList = [data]
}
else {
const currentFormIndex = responseItem.humanInputFormDataList!.findIndex(item => item.node_id === data.node_id)
const currentFormIndex = responseItem.humanInputFormDataList.findIndex(item => item.node_id === data.node_id)
if (currentFormIndex > -1) {
responseItem.humanInputFormDataList[currentFormIndex] = data
}
@ -554,7 +554,7 @@ export const useChat = (
},
onHumanInputFormFilled: ({ data }) => {
if (responseItem.humanInputFormDataList?.length) {
const currentFormIndex = responseItem.humanInputFormDataList!.findIndex(item => item.node_id === data.node_id)
const currentFormIndex = responseItem.humanInputFormDataList.findIndex(item => item.node_id === data.node_id)
responseItem.humanInputFormDataList.splice(currentFormIndex, 1)
}
if (!responseItem.humanInputFilledFormDataList) {

View File

@ -0,0 +1,33 @@
import type { HumanInputFilledFormData } from '@/types/workflow'
import ContentWrapper from '@/app/components/base/chat/chat/answer/human-input-content/content-wrapper'
import { SubmittedHumanInputContent } from '@/app/components/base/chat/chat/answer/human-input-content/submitted'
type HumanInputFilledFormListProps = {
humanInputFilledFormDataList: HumanInputFilledFormData[]
}
const HumanInputFilledFormList = ({
humanInputFilledFormDataList,
}: HumanInputFilledFormListProps) => {
return (
<div className="mt-3 flex flex-col gap-y-3">
{
humanInputFilledFormDataList.map(formData => (
<ContentWrapper
key={formData.node_id}
nodeTitle="todo: replace with node title"
showExpandIcon
className="bg-components-panel-bg"
>
<SubmittedHumanInputContent
key={formData.node_id}
formData={formData}
/>
</ContentWrapper>
))
}
</div>
)
}
export default HumanInputFilledFormList

View File

@ -0,0 +1,79 @@
import type { DeliveryMethod } from '@/app/components/workflow/nodes/human-input/types'
import type { HumanInputFormData } from '@/types/workflow'
import { useCallback, useMemo } from 'react'
import { useStoreApi } from 'reactflow'
import ContentWrapper from '@/app/components/base/chat/chat/answer/human-input-content/content-wrapper'
import { UnsubmittedHumanInputContent } from '@/app/components/base/chat/chat/answer/human-input-content/unsubmitted'
import { CUSTOM_NODE } from '@/app/components/workflow/constants'
import { DeliveryMethodType } from '@/app/components/workflow/nodes/human-input/types'
type HumanInputFormListProps = {
humanInputFormDataList: HumanInputFormData[]
onHumanInputFormSubmit?: (formID: string, formData: any) => Promise<void>
}
const HumanInputFormList = ({
humanInputFormDataList,
onHumanInputFormSubmit,
}: HumanInputFormListProps) => {
const store = useStoreApi()
const getHumanInputNodeData = useCallback((nodeID: string) => {
const {
getNodes,
} = store.getState()
const nodes = getNodes().filter(node => node.type === CUSTOM_NODE)
const node = nodes.find(n => n.id === nodeID)
return node
}, [store])
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="flex flex-col gap-y-3">
{
humanInputFormDataList.map(formData => (
<ContentWrapper
key={formData.node_id}
nodeTitle={formData.node_title}
className="bg-components-panel-bg"
>
<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

@ -1,33 +0,0 @@
import { memo } from 'react'
// import { useStore } from '../store'
import BlockIcon from '@/app/components/workflow/block-icon'
import { BlockEnum } from '../types'
type props = {
nodeID: string
nodeTitle: string
formData: any
}
const HumanInputInfo = ({ nodeTitle }: props) => {
// const historyWorkflowData = useStore(s => s.historyWorkflowData)
return (
<div className="rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-2 shadow-xs">
<div className="p-2">
{/* node icon */}
<BlockIcon
type={BlockEnum.HumanInput}
// toolIcon={triggerIcon}
/>
{/* node name */}
<div className="system-sm-semibold-uppercase text-text-primary">{nodeTitle}</div>
</div>
<div>
{/* human input form content */}
</div>
</div>
)
}
export default memo(HumanInputInfo)

View File

@ -44,15 +44,18 @@ const InputsPanel = ({ onRun }: Props) => {
const startVariables = startNode?.data.variables
const { checkInputsForm } = useCheckInputsForms()
const initialInputs = { ...inputs }
if (startVariables) {
startVariables.forEach((variable) => {
if (variable.default)
initialInputs[variable.variable] = variable.default
if (inputs[variable.variable] !== undefined)
initialInputs[variable.variable] = inputs[variable.variable]
})
}
const initialInputs = useMemo(() => {
const result = { ...inputs }
if (startVariables) {
startVariables.forEach((variable) => {
if (variable.default)
result[variable.variable] = variable.default
if (inputs[variable.variable] !== undefined)
result[variable.variable] = inputs[variable.variable]
})
}
return result
}, [inputs, startVariables])
const variables = useMemo(() => {
const data = startVariables || []

View File

@ -12,6 +12,7 @@ import {
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import Loading from '@/app/components/base/loading'
import { submitHumanInputForm } from '@/service/workflow'
import { cn } from '@/utils/classnames'
import Toast from '../../base/toast'
import {
@ -25,7 +26,8 @@ import {
WorkflowRunningStatus,
} from '../types'
import { formatWorkflowRunIdentifier } from '../utils'
import HumanInputInfo from './human-input-info'
import HumanInputFilledFormList from './human-input-filled-form-list'
import HumanInputFormList from './human-input-form-list'
import InputsPanel from './inputs-panel'
const WorkflowPreview = () => {
@ -38,6 +40,8 @@ const WorkflowPreview = () => {
const panelWidth = useStore(s => s.previewPanelWidth)
const setPreviewPanelWidth = useStore(s => s.setPreviewPanelWidth)
const showDebugAndPreviewPanel = useStore(s => s.showDebugAndPreviewPanel)
const humanInputFormDataList = useStore(s => s.workflowRunningData?.humanInputFormDataList)
const humanInputFilledFormDataList = useStore(s => s.workflowRunningData?.humanInputFilledFormDataList)
const [currentTab, setCurrentTab] = useState<string>(showInputsPanel ? 'INPUT' : 'TRACING')
const switchTab = async (tab: string) => {
@ -95,6 +99,10 @@ const WorkflowPreview = () => {
}
}, [resize, stopResizing])
const handleSubmitHumanInputForm = useCallback(async (formID: string, formData: any) => {
await submitHumanInputForm(formID, formData)
}, [])
return (
<div
className="relative flex h-full flex-col rounded-l-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl"
@ -175,13 +183,18 @@ const WorkflowPreview = () => {
<InputsPanel onRun={() => switchTab('RESULT')} />
)}
{currentTab === 'RESULT' && (
<>
{/* human input form position TODO */}
<HumanInputInfo
nodeTitle="Human Input Required"
nodeID="human-input-node-id"
formData={{}}
/>
<div className="p-2">
{humanInputFormDataList && humanInputFormDataList.length > 0 && (
<HumanInputFormList
humanInputFormDataList={humanInputFormDataList}
onHumanInputFormSubmit={handleSubmitHumanInputForm}
/>
)}
{humanInputFilledFormDataList && humanInputFilledFormDataList.length > 0 && (
<HumanInputFilledFormList
humanInputFilledFormDataList={humanInputFilledFormDataList}
/>
)}
<ResultText
isRunning={workflowRunningData?.result?.status === WorkflowRunningStatus.Running || !workflowRunningData?.result}
outputs={workflowRunningData?.resultText}
@ -205,7 +218,7 @@ const WorkflowPreview = () => {
<div>{t('operation.copy', { ns: 'common' })}</div>
</Button>
)}
</>
</div>
)}
{currentTab === 'DETAIL' && (
<ResultPanel

View File

@ -17,7 +17,13 @@ import type { VarType as VarKindType } from '@/app/components/workflow/nodes/too
import type { ChatVarType } from '@/app/components/workflow/panel/chat-variable-panel/type'
import type { SchemaTypeDefinition } from '@/service/use-common'
import type { Resolution, TransferMethod } from '@/types/app'
import type { FileResponse, NodeTracing, PanelProps } from '@/types/workflow'
import type {
FileResponse,
HumanInputFilledFormData,
HumanInputFormData,
NodeTracing,
PanelProps,
} from '@/types/workflow'
export enum BlockEnum {
Start = 'start',
@ -429,6 +435,8 @@ export type WorkflowRunningData = {
exceptions_count?: number
}
tracing?: NodeTracing[]
humanInputFormDataList?: HumanInputFormData[]
humanInputFilledFormDataList?: HumanInputFilledFormData[]
}
export type HistoryWorkflowData = {