single run form data

This commit is contained in:
JzoNg 2025-09-05 18:37:28 +08:00
parent 22683fba3f
commit d51db3afb3
5 changed files with 353 additions and 20 deletions

View File

@ -11,10 +11,13 @@ import { InputVarType } from '@/app/components/workflow/types'
import Toast from '@/app/components/base/toast'
import { TransferMethod } from '@/types/app'
import { getProcessedFiles } from '@/app/components/base/file-uploader/utils'
import type { BlockEnum, NodeRunningStatus } from '@/app/components/workflow/types'
import { BlockEnum } from '@/app/components/workflow/types'
import type { NodeRunningStatus } from '@/app/components/workflow/types'
import type { Emoji } from '@/app/components/tools/types'
import type { SpecialResultPanelProps } from '@/app/components/workflow/run/special-result-panel'
import PanelWrap from './panel-wrap'
import SingleRunForm from '@/app/components/workflow/nodes/human-input/components/single-run-form'
const i18nPrefix = 'workflow.singleRun'
export type BeforeRunFormProps = {
@ -29,6 +32,8 @@ export type BeforeRunFormProps = {
showSpecialResultPanel?: boolean
existVarValuesInForms: Record<string, any>[]
filteredExistVarForms: FormProps[]
generatedFormContentData?: Record<string, any>
setSubmittedData?: (data: Record<string, any>) => void
} & Partial<SpecialResultPanelProps>
function formatValue(value: string | any, type: InputVarType) {
@ -58,14 +63,19 @@ function formatValue(value: string | any, type: InputVarType) {
}
const BeforeRunForm: FC<BeforeRunFormProps> = ({
nodeName,
nodeType,
onHide,
onRun,
forms,
filteredExistVarForms,
existVarValuesInForms,
generatedFormContentData,
setSubmittedData,
}) => {
const { t } = useTranslation()
const isHumanInput = nodeType === BlockEnum.HumanInput
const isFileLoaded = (() => {
if (!forms || forms.length === 0)
return true
@ -80,7 +90,8 @@ const BeforeRunForm: FC<BeforeRunFormProps> = ({
return true
})()
const handleRun = () => {
const handleRunOrGenerateForm = () => {
let errMsg = ''
forms.forEach((form, i) => {
const existVarValuesInForm = existVarValuesInForms[i]
@ -131,8 +142,12 @@ const BeforeRunForm: FC<BeforeRunFormProps> = ({
return
}
onRun(submitData)
if (isHumanInput)
setSubmittedData?.(submitData)
else
onRun(submitData)
}
const hasRun = useRef(false)
useEffect(() => {
// React 18 run twice in dev mode
@ -152,22 +167,34 @@ const BeforeRunForm: FC<BeforeRunFormProps> = ({
onHide={onHide}
>
<div className='h-0 grow overflow-y-auto pb-4'>
<div className='mt-3 space-y-4 px-4'>
{filteredExistVarForms.map((form, index) => (
<div key={index}>
<Form
key={index}
className={cn(index < forms.length - 1 && 'mb-4')}
{...form}
/>
{index < forms.length - 1 && <Split />}
</div>
))}
</div>
{!generatedFormContentData && (
<div className='mt-3 space-y-4 px-4'>
{filteredExistVarForms.map((form, index) => (
<div key={index}>
<Form
key={index}
className={cn(index < forms.length - 1 && 'mb-4')}
{...form}
/>
{index < forms.length - 1 && <Split />}
</div>
))}
</div>
)}
{generatedFormContentData && (
<SingleRunForm />
)}
<div className='mt-4 flex justify-between space-x-2 px-4' >
<Button disabled={!isFileLoaded} variant='primary' className='w-0 grow space-x-2' onClick={handleRun}>
<div>{t(`${i18nPrefix}.startRun`)}</div>
</Button>
{!isHumanInput && (
<Button disabled={!isFileLoaded} variant='primary' className='w-0 grow space-x-2' onClick={handleRunOrGenerateForm}>
<div>{t(`${i18nPrefix}.startRun`)}</div>
</Button>
)}
{isHumanInput && (
<Button disabled={!isFileLoaded} variant='primary' className='w-0 grow space-x-2' onClick={handleRunOrGenerateForm}>
<div>{t('workflow.nodes.humanInput.singleRun.button')}</div>
</Button>
)}
</div>
</div>
</PanelWrap>

View File

@ -0,0 +1,265 @@
'use client'
import React, { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import useDocumentTitle from '@/hooks/use-document-title'
import { useParams } from 'next/navigation'
import {
RiCheckboxCircleFill,
RiInformation2Fill,
} from '@remixicon/react'
import Loading from '@/app/components/base/loading'
import AppIcon from '@/app/components/base/app-icon'
import Button from '@/app/components/base/button'
import DifyLogo from '@/app/components/base/logo/dify-logo'
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 type { GeneratedFormInputItem, UserAction } from '@/app/components/workflow/nodes/human-input/types'
import { getHumanInputForm, submitHumanInputForm } from '@/service/share'
import { asyncRunSafe } from '@/utils'
import cn from '@/utils/classnames'
export type FormData = {
site: any
form_content: string
inputs: GeneratedFormInputItem[]
user_actions: UserAction[]
timeout: number
timeout_unit: 'hour' | 'day'
}
const FormContent = () => {
const { t } = useTranslation()
const { token } = useParams<{ token: string }>()
useDocumentTitle('')
const [isLoading, setIsLoading] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [formData, setFormData] = useState<FormData>()
const [contentList, setContentList] = useState<string[]>([])
const [inputs, setInputs] = useState({})
const [success, setSuccess] = useState(false)
const [expired, setExpired] = useState(false)
const [submitted, setSubmitted] = useState(false)
const site = formData?.site.site
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 splitByOutputVar = (content: string): string[] => {
const outputVarRegex = /({{#\$output\.[^#]+#}})/g
const parts = content.split(outputVarRegex)
return parts.filter(part => part.length > 0)
}
const initializeInputs = (formInputs: GeneratedFormInputItem[]) => {
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
})
setInputs(initialInputs)
}
const initializeContentList = (formContent: string) => {
const parts = splitByOutputVar(formContent)
setContentList(parts)
}
// use immer
const handleInputsChange = (name: string, value: any) => {
setInputs(prev => ({
...prev,
[name]: value,
}))
}
const getForm = async (token: string) => {
try {
const data = await getHumanInputForm(token)
setFormData(data)
initializeInputs(data.inputs)
initializeContentList(data.form_content)
setIsLoading(false)
}
catch (error) {
console.error(error)
}
}
const submit = async (actionID: string) => {
setIsSubmitting(true)
try {
await submitHumanInputForm(token, { inputs, action: actionID })
setSuccess(true)
}
catch (e: any) {
if (e.status === 400) {
const [, errRespData] = await asyncRunSafe<{ error_code: string }>(e.json())
const { error_code } = errRespData || {}
if (error_code === 'human_input_form_expired')
setExpired(true)
if (error_code === 'human_input_form_submitted')
setSubmitted(true)
}
}
finally {
setIsSubmitting(false)
}
}
useEffect(() => {
getForm(token)
}, [token])
if (isLoading || !formData) {
return (
<Loading type='app' />
)
}
if (success) {
return (
<div className={cn('flex h-full w-full flex-col items-center justify-center')}>
<div className='min-w-[480px] max-w-[640px]'>
<div className='border-components-divider-subtle flex h-[320px] flex-col gap-4 rounded-[20px] border bg-chat-bubble-bg p-10 pb-9 shadow-lg backdrop-blur-sm'>
<div className='h-[56px] w-[56px] shrink-0 rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge p-3'>
<RiCheckboxCircleFill className='h-8 w-8 text-text-success' />
</div>
<div className='grow'>
<div className='title-4xl-semi-bold text-text-primary'>{t('share.humanInput.thanks')}</div>
<div className='title-4xl-semi-bold text-text-primary'>{t('share.humanInput.recorded')}</div>
</div>
<div className='system-2xs-regular-uppercase shrink-0 text-text-tertiary'>{t('share.humanInput.submissionID', { id: token })}</div>
</div>
<div className='flex flex-row-reverse px-2 py-3'>
<div className={cn(
'flex shrink-0 items-center gap-1.5 px-1',
)}>
<div className='system-2xs-medium-uppercase text-text-tertiary'>{t('share.chat.poweredBy')}</div>
<DifyLogo size='small' />
</div>
</div>
</div>
</div>
)
}
if (expired) {
return (
<div className={cn('flex h-full w-full flex-col items-center justify-center')}>
<div className='min-w-[480px] max-w-[640px]'>
<div className='border-components-divider-subtle flex h-[320px] flex-col gap-4 rounded-[20px] border bg-chat-bubble-bg p-10 pb-9 shadow-lg backdrop-blur-sm'>
<div className='h-[56px] w-[56px] shrink-0 rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge p-3'>
<RiInformation2Fill className='h-8 w-8 text-text-accent' />
</div>
<div className='grow'>
<div className='title-4xl-semi-bold text-text-primary'>{t('share.humanInput.sorry')}</div>
<div className='title-4xl-semi-bold text-text-primary'>{t('share.humanInput.expired')}</div>
</div>
<div className='system-2xs-regular-uppercase shrink-0 text-text-tertiary'>{t('share.humanInput.submissionID', { id: token })}</div>
</div>
<div className='flex flex-row-reverse px-2 py-3'>
<div className={cn(
'flex shrink-0 items-center gap-1.5 px-1',
)}>
<div className='system-2xs-medium-uppercase text-text-tertiary'>{t('share.chat.poweredBy')}</div>
<DifyLogo size='small' />
</div>
</div>
</div>
</div>
)
}
if (submitted) {
return (
<div className={cn('flex h-full w-full flex-col items-center justify-center')}>
<div className='min-w-[480px] max-w-[640px]'>
<div className='border-components-divider-subtle flex h-[320px] flex-col gap-4 rounded-[20px] border bg-chat-bubble-bg p-10 pb-9 shadow-lg backdrop-blur-sm'>
<div className='h-[56px] w-[56px] shrink-0 rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge p-3'>
<RiInformation2Fill className='h-8 w-8 text-text-accent' />
</div>
<div className='grow'>
<div className='title-4xl-semi-bold text-text-primary'>{t('share.humanInput.sorry')}</div>
<div className='title-4xl-semi-bold text-text-primary'>{t('share.humanInput.completed')}</div>
</div>
<div className='system-2xs-regular-uppercase shrink-0 text-text-tertiary'>{t('share.humanInput.submissionID', { id: token })}</div>
</div>
<div className='flex flex-row-reverse px-2 py-3'>
<div className={cn(
'flex shrink-0 items-center gap-1.5 px-1',
)}>
<div className='system-2xs-medium-uppercase text-text-tertiary'>{t('share.chat.poweredBy')}</div>
<DifyLogo size='small' />
</div>
</div>
</div>
</div>
)
}
return (
<div className={cn('mx-auto flex h-full w-full max-w-[720px] flex-col items-center')}>
<div className='mt-4 flex w-full shrink-0 items-center gap-3 py-3'>
<AppIcon
size='large'
iconType={site.icon_type as any}
icon={site.icon}
background={site.icon_background}
imageUrl={site.icon_url}
/>
<div className='system-xl-semibold grow text-text-primary'>{site.title}</div>
</div>
<div className='h-0 w-full grow overflow-y-auto'>
<div className='border-components-divider-subtle rounded-[20px] border bg-chat-bubble-bg p-4 shadow-lg backdrop-blur-sm'>
{contentList.map((content, index) => (
<ContentItem
key={index}
content={content}
formInputFields={formData.inputs}
inputs={inputs}
onInputChange={handleInputsChange}
/>
))}
<div className='flex flex-wrap gap-1 py-1'>
{formData.user_actions.map((action: any) => (
<Button
key={action.id}
disabled={isSubmitting}
variant={getButtonStyle(action.button_style) as any}
onClick={() => submit(action.id)}
>
{action.title}
</Button>
))}
</div>
<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 })}
</div>
</div>
<div className='flex flex-row-reverse px-2 py-3'>
<div className={cn(
'flex shrink-0 items-center gap-1.5 px-1',
)}>
<div className='system-2xs-medium-uppercase text-text-tertiary'>{t('share.chat.poweredBy')}</div>
<DifyLogo size='small' />
</div>
</div>
</div>
</div>
)
}
export default React.memo(FormContent)

View File

@ -1,9 +1,9 @@
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
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 type { HumanInputNodeType } from './types'
import useNodeCrud from '../_base/hooks/use-node-crud'
import { useMemo } from 'react'
import { isOutput } from './utils'
const i18nPrefix = 'workflow.nodes.humanInput'
@ -24,7 +24,7 @@ const useSingleRunFormParams = ({
}: Params) => {
const { t } = useTranslation()
const { inputs } = useNodeCrud<HumanInputNodeType>(id, payload)
const [submittedData, setSubmittedData] = useState<Record<string, any> | null>(null)
const generatedInputs = useMemo(() => {
if (!inputs.form_content)
return []
@ -41,6 +41,21 @@ const useSingleRunFormParams = ({
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 === 'const' ? item.placeholder.value : '',
}
})
return res
}, [inputs.form_content, inputs.inputs])
const getDependentVars = () => {
return generatedInputs.map((item) => {
// Guard against null/undefined variable to prevent app crash
@ -51,9 +66,33 @@ const useSingleRunFormParams = ({
}).filter(arr => arr.length > 0)
}
const generatedFormContentData = useMemo(() => {
if (!inputs.form_content)
return null
if (!generatedInputs.length) {
return {
content: inputs.form_content,
inputFields: formContentOutputFields,
}
}
else {
if (!submittedData)
return null
const newContent = inputs.form_content.replace(/{{#(.*?)#}}/g, (originStr, varName) => {
return submittedData[varName] || isOutput(varName.split('.')) ? originStr : ''
})
return {
content: newContent,
inputFields: formContentOutputFields,
}
}
}, [inputs.form_content, submittedData, formContentOutputFields])
return {
forms,
getDependentVars,
generatedFormContentData,
setSubmittedData,
}
}

View File

@ -1029,6 +1029,7 @@ const translation = {
},
singleRun: {
label: 'Form variables',
button: 'Generate Form',
},
},
},

View File

@ -1029,6 +1029,7 @@ const translation = {
},
singleRun: {
label: '表单变量',
button: '生成表单',
},
},
},