Merge branch 'feat/hitl-frontend' of https://github.com/langgenius/dify into feat/hitl-frontend

This commit is contained in:
twwu 2026-01-12 13:46:48 +08:00
commit 18e57096d2
7 changed files with 124 additions and 103 deletions

View File

@ -0,0 +1,25 @@
name: Deploy HITL frontend
on:
workflow_run:
workflows: ["Build and Push API & Web"]
branches:
- "feat/hitl-frontend"
types:
- completed
jobs:
deploy:
runs-on: ubuntu-latest
if: |
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.head_branch == 'feat/hitl-frontend'
steps:
- name: Deploy to server
uses: appleboy/ssh-action@v0.1.8
with:
host: ${{ secrets.HITL_SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
${{ vars.SSH_SCRIPT || secrets.SSH_SCRIPT }}

View File

@ -32,10 +32,12 @@ export type BeforeRunFormProps = {
showSpecialResultPanel?: boolean
existVarValuesInForms: Record<string, any>[]
filteredExistVarForms: FormProps[]
generatedFormContentData?: Record<string, any>
showGeneratedForm?: boolean
handleShowGeneratedForm?: (data: Record<string, any>) => void
handleHideGeneratedForm?: () => void
formData?: any
handleSubmitHumanInputForm?: (data: any) => Promise<void>
handleAfterHumanInputStepRun?: () => void
} & Partial<SpecialResultPanelProps>
function formatValue(value: string | any, type: InputVarType) {
@ -73,14 +75,17 @@ const BeforeRunForm: FC<BeforeRunFormProps> = ({
forms,
filteredExistVarForms,
existVarValuesInForms,
generatedFormContentData,
showGeneratedForm = false,
handleShowGeneratedForm,
handleHideGeneratedForm,
formData,
handleSubmitHumanInputForm,
handleAfterHumanInputStepRun,
}) => {
const { t } = useTranslation()
const isHumanInput = nodeType === BlockEnum.HumanInput
const showBackButton = filteredExistVarForms.length > 0
const isFileLoaded = (() => {
if (!forms || forms.length === 0)
@ -154,6 +159,11 @@ const BeforeRunForm: FC<BeforeRunFormProps> = ({
onRun(submitData)
}
const handleHumanInputFormSubmit = async (data: any) => {
await handleSubmitHumanInputForm?.(data)
handleAfterHumanInputStepRun?.()
}
const hasRun = useRef(false)
useEffect(() => {
// React 18 run twice in dev mode
@ -162,7 +172,9 @@ const BeforeRunForm: FC<BeforeRunFormProps> = ({
hasRun.current = true
if (filteredExistVarForms.length === 0 && !isHumanInput)
onRun({})
}, [filteredExistVarForms, onRun])
if (filteredExistVarForms.length === 0 && isHumanInput)
handleShowGeneratedForm?.({})
}, [filteredExistVarForms, handleShowGeneratedForm, isHumanInput, onRun])
if (filteredExistVarForms.length === 0 && !isHumanInput)
return null
@ -187,14 +199,13 @@ const BeforeRunForm: FC<BeforeRunFormProps> = ({
))}
</div>
)}
{showGeneratedForm && generatedFormContentData && (
{showGeneratedForm && formData && (
<SingleRunForm
nodeName={nodeName}
showBackButton={showBackButton}
handleBack={handleHideGeneratedForm}
showBackButton={generatedFormContentData.showBackButton}
formContent={generatedFormContentData.formContent}
inputFields={generatedFormContentData.inputFields}
userActions={generatedFormContentData.userActions}
data={formData}
onSubmit={handleHumanInputFormSubmit}
/>
)}
{!showGeneratedForm && (

View File

@ -445,6 +445,7 @@ const BasePanel: FC<BasePanelProps> = ({
{...passedLogParams}
existVarValuesInForms={getExistVarValuesInForms(singleRunParams?.forms as any)}
filteredExistVarForms={getFilteredExistVarForms(singleRunParams?.forms as any)}
handleAfterHumanInputStepRun={handleAfterCustomSingleRun}
/>
)}

View File

@ -133,6 +133,7 @@ const useLastRun = <T>({
const isLoopNode = blockType === BlockEnum.Loop
const isAggregatorNode = blockType === BlockEnum.VariableAggregator
const isCustomRunNode = isSupportCustomRunForm(blockType)
const isHumanInputNode = blockType === BlockEnum.HumanInput
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
const {
getData: getDataForCheckMore,
@ -342,17 +343,11 @@ const useLastRun = <T>({
return
if (blockType === BlockEnum.TriggerWebhook || blockType === BlockEnum.TriggerPlugin || blockType === BlockEnum.TriggerSchedule)
setShowVariableInspectPanel(true)
if (isCustomRunNode) {
if (isCustomRunNode || isHumanInputNode) {
showSingleRun()
return
}
const vars = singleRunParams?.getDependentVars?.()
// TODO human input
if (singleRunParams?.generatedFormContentData) {
singleRunParams?.handleShowGeneratedForm()
showSingleRun()
return
}
// no need to input params
if (isAggregatorNode ? checkAggregatorVarsSet(vars) : isAllVarsHasValue(vars)) {
callRunApi({}, async () => {

View File

@ -7,58 +7,32 @@ import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import ContentItem from '@/app/components/base/chat/chat/answer/human-input-content/content-item'
import { UserActionButtonType } from '@/app/components/workflow/nodes/human-input/types'
import { getButtonStyle, initializeInputs, splitByOutputVar } from '@/app/components/base/chat/chat/answer/human-input-content/utils'
type Props = {
nodeName: string
formContent: string
inputFields: FormInputItem[]
userActions: UserAction[]
data: {
form_content: string
inputs: FormInputItem[]
actions: UserAction[]
}
showBackButton?: boolean
handleBack?: () => void
onSubmit?: (data: any) => Promise<void>
}
const FormContent = ({
nodeName,
formContent,
inputFields,
userActions,
data,
showBackButton,
handleBack,
onSubmit,
}: Props) => {
const { t } = useTranslation()
const splitByOutputVar = (content: string): string[] => {
const outputVarRegex = /(\{\{#\$output\.[^#]+#\}\})/g
const parts = content.split(outputVarRegex)
return parts.filter(part => part.length > 0)
}
const initializeInputs = (formInputs: FormInputItem[]) => {
const initialInputs: Record<string, any> = {}
formInputs.forEach((item) => {
if (item.type === 'text-input' || item.type === 'paragraph')
initialInputs[item.output_variable_name] = ''
else
initialInputs[item.output_variable_name] = undefined
})
return initialInputs
}
const contentList = splitByOutputVar(formContent)
const defaultInputValues = initializeInputs(inputFields)
const [inputs, setInputs] = useState(defaultInputValues)
const getButtonStyle = (style: UserActionButtonType) => {
if (style === UserActionButtonType.Primary)
return 'primary'
if (style === UserActionButtonType.Default)
return 'secondary'
if (style === UserActionButtonType.Accent)
return 'secondary-accent'
if (style === UserActionButtonType.Ghost)
return 'ghost'
}
const defaultInputs = initializeInputs(data.inputs)
const contentList = splitByOutputVar(data.form_content)
const [inputs, setInputs] = useState(defaultInputs)
const [isSubmitting, setIsSubmitting] = useState(false)
// use immer
const handleInputsChange = (name: string, value: any) => {
@ -69,7 +43,9 @@ const FormContent = ({
}
const submit = async (actionID: string) => {
// TODO
setIsSubmitting(true)
await onSubmit?.({ inputs, action: actionID })
setIsSubmitting(false)
}
return (
@ -89,15 +65,16 @@ const FormContent = ({
<ContentItem
key={index}
content={content}
formInputFields={inputFields}
formInputFields={data.inputs}
inputs={inputs}
onInputChange={handleInputsChange}
/>
))}
<div className="flex flex-wrap gap-1 py-1">
{userActions.map((action: any) => (
{data.actions.map((action: any) => (
<Button
key={action.id}
disabled={isSubmitting}
variant={getButtonStyle(action.button_style) as any}
onClick={() => submit(action.id)}
>

View File

@ -1,8 +1,11 @@
import type { HumanInputNodeType } from '../types'
import type { Props as FormProps } from '@/app/components/workflow/nodes/_base/components/before-run-form/form'
import type { InputVar } from '@/app/components/workflow/types'
import { useMemo, useState } from 'react'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
import { fetchHumanInputNodeStepRunForm, submitHumanInputNodeStepRunForm } from '@/service/workflow'
import { AppModeEnum } from '@/types/app'
import useNodeCrud from '../../_base/hooks/use-node-crud'
import { isOutput } from '../utils'
@ -24,13 +27,13 @@ const useSingleRunFormParams = ({
}: Params) => {
const { t } = useTranslation()
const { inputs } = useNodeCrud<HumanInputNodeType>(id, payload)
const [submittedData, setSubmittedData] = useState<Record<string, any> | null>(null)
const [showGeneratedForm, setShowGeneratedForm] = useState(false)
const [formData, setFormData] = useState<any>(null)
const generatedInputs = useMemo(() => {
if (!inputs.form_content)
return []
return getInputVars([inputs.form_content]).filter(item => !isOutput(item.value_selector || []))
}, [inputs.form_content])
}, [getInputVars, inputs.form_content])
const forms = useMemo(() => {
const forms: FormProps[] = [{
@ -40,22 +43,7 @@ const useSingleRunFormParams = ({
onChange: setRunInputData,
}]
return forms
}, [runInputData, setRunInputData, generatedInputs])
const formContentOutputFields = useMemo(() => {
const res = (inputs.inputs || [])
.filter((item) => {
return inputs.form_content.includes(`{{#$output.${item.output_variable_name}#}}`)
})
.map((item) => {
return {
type: item.type,
output_variable_name: item.output_variable_name,
placeholder: item.placeholder?.type === 'constant' ? item.placeholder.value : '',
}
})
return res
}, [inputs.form_content, inputs.inputs])
}, [t, generatedInputs, runInputData, setRunInputData])
const getDependentVars = () => {
return generatedInputs.map((item) => {
@ -67,36 +55,34 @@ const useSingleRunFormParams = ({
}).filter(arr => arr.length > 0)
}
const generatedFormContentData = useMemo(() => {
if (!inputs.form_content)
return null
if (!generatedInputs.length) {
return {
formContent: inputs.form_content,
inputFields: formContentOutputFields,
userActions: inputs.user_actions,
showBackButton: false,
}
const appDetail = useAppStore(s => s.appDetail)
const appId = appDetail?.id
const isWorkflowMode = appDetail?.mode === AppModeEnum.WORKFLOW
const fetchURL = useMemo(() => {
if (!appId)
return ''
if (isWorkflowMode) {
return `/apps/${appId}/advanced-chat/workflows/draft/human_input/nodes/${id}/form`
}
else {
if (!submittedData)
return null
const newContent = inputs.form_content.replace(/\{\{#(.*?)#\}\}/g, (originStr, varName) => {
if (isOutput(varName.split('.')))
return originStr
return submittedData[`#${varName}#`] ?? ''
})
return {
formContent: newContent,
inputFields: formContentOutputFields,
userActions: inputs.user_actions,
showBackButton: true,
}
return `/apps/${appId}/workflows/draft/human_input/nodes/${id}/form`
}
}, [inputs.form_content, inputs.user_actions, submittedData, formContentOutputFields])
}, [appId, id, isWorkflowMode])
const handleShowGeneratedForm = (formValue: Record<string, any>) => {
setSubmittedData(formValue)
const handleFetchFormContent = useCallback(async (inputs: Record<string, any>) => {
if (!fetchURL)
return null
const data = await fetchHumanInputNodeStepRunForm(fetchURL, { inputs })
setFormData(data)
return data
}, [fetchURL])
const handleSubmitHumanInputForm = useCallback(async (formData: any) => {
await submitHumanInputNodeStepRunForm(fetchURL, formData)
}, [fetchURL])
const handleShowGeneratedForm = async (formValue: Record<string, any>) => {
await handleFetchFormContent(formValue)
setShowGeneratedForm(true)
}
@ -107,10 +93,12 @@ const useSingleRunFormParams = ({
return {
forms,
getDependentVars,
generatedFormContentData,
showGeneratedForm,
handleShowGeneratedForm,
handleHideGeneratedForm,
formData,
handleFetchFormContent,
handleSubmitHumanInputForm,
}
}

View File

@ -1,3 +1,4 @@
import type { FormInputItem, UserAction } from '@/app/components/workflow/nodes/human-input/types'
import type { BlockEnum } from '@/app/components/workflow/types'
import type { CommonResponse } from '@/models/common'
import type { FlowType } from '@/types/common'
@ -101,3 +102,26 @@ export const submitHumanInputForm = (token: string, data: {
}) => {
return post(`/form/human_input/${token}`, { body: data })
}
export const fetchHumanInputNodeStepRunForm = (
url: string,
params: {
inputs: Record<string, any>
},
) => {
return get<{
form_content: string
inputs: FormInputItem[]
user_actions: UserAction[]
}>(url, { params })
}
export const submitHumanInputNodeStepRunForm = (
url: string,
data: {
inputs: Record<string, any>
action: string
},
) => {
return post<CommonResponse>(url, { body: data })
}