diff --git a/web/app/components/workflow/nodes/_base/components/before-run-form/index.tsx b/web/app/components/workflow/nodes/_base/components/before-run-form/index.tsx index 11bd5156ef..b9af38610d 100644 --- a/web/app/components/workflow/nodes/_base/components/before-run-form/index.tsx +++ b/web/app/components/workflow/nodes/_base/components/before-run-form/index.tsx @@ -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[] filteredExistVarForms: FormProps[] + generatedFormContentData?: Record + setSubmittedData?: (data: Record) => void } & Partial function formatValue(value: string | any, type: InputVarType) { @@ -58,14 +63,19 @@ function formatValue(value: string | any, type: InputVarType) { } const BeforeRunForm: FC = ({ 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 = ({ return true })() - const handleRun = () => { + + const handleRunOrGenerateForm = () => { let errMsg = '' forms.forEach((form, i) => { const existVarValuesInForm = existVarValuesInForms[i] @@ -131,8 +142,12 @@ const BeforeRunForm: FC = ({ 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 = ({ onHide={onHide} >
-
- {filteredExistVarForms.map((form, index) => ( -
-
- {index < forms.length - 1 && } -
- ))} -
+ {!generatedFormContentData && ( +
+ {filteredExistVarForms.map((form, index) => ( +
+ + {index < forms.length - 1 && } +
+ ))} +
+ )} + {generatedFormContentData && ( + + )}
- + {!isHumanInput && ( + + )} + {isHumanInput && ( + + )}
diff --git a/web/app/components/workflow/nodes/human-input/components/single-run-form.tsx b/web/app/components/workflow/nodes/human-input/components/single-run-form.tsx new file mode 100644 index 0000000000..5000f38443 --- /dev/null +++ b/web/app/components/workflow/nodes/human-input/components/single-run-form.tsx @@ -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() + const [contentList, setContentList] = useState([]) + 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 = {} + 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 ( + + ) + } + + if (success) { + return ( +
+
+
+
+ +
+
+
{t('share.humanInput.thanks')}
+
{t('share.humanInput.recorded')}
+
+
{t('share.humanInput.submissionID', { id: token })}
+
+
+
+
{t('share.chat.poweredBy')}
+ +
+
+
+
+ ) + } + + if (expired) { + return ( +
+
+
+
+ +
+
+
{t('share.humanInput.sorry')}
+
{t('share.humanInput.expired')}
+
+
{t('share.humanInput.submissionID', { id: token })}
+
+
+
+
{t('share.chat.poweredBy')}
+ +
+
+
+
+ ) + } + + if (submitted) { + return ( +
+
+
+
+ +
+
+
{t('share.humanInput.sorry')}
+
{t('share.humanInput.completed')}
+
+
{t('share.humanInput.submissionID', { id: token })}
+
+
+
+
{t('share.chat.poweredBy')}
+ +
+
+
+
+ ) + } + + return ( +
+
+ +
{site.title}
+
+
+
+ {contentList.map((content, index) => ( + + ))} +
+ {formData.user_actions.map((action: any) => ( + + ))} +
+
+ {formData.timeout_unit === 'day' ? t('share.humanInput.timeoutDay', { count: formData.timeout }) : t('share.humanInput.timeoutHour', { count: formData.timeout })} +
+
+
+
+
{t('share.chat.poweredBy')}
+ +
+
+
+
+ ) +} + +export default React.memo(FormContent) diff --git a/web/app/components/workflow/nodes/human-input/use-single-run-form-params.ts b/web/app/components/workflow/nodes/human-input/use-single-run-form-params.ts index 94cd664dd9..4fe0c1be54 100644 --- a/web/app/components/workflow/nodes/human-input/use-single-run-form-params.ts +++ b/web/app/components/workflow/nodes/human-input/use-single-run-form-params.ts @@ -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(id, payload) - + const [submittedData, setSubmittedData] = useState | 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, } } diff --git a/web/i18n/en-US/workflow.ts b/web/i18n/en-US/workflow.ts index 4c66478241..503dadf906 100644 --- a/web/i18n/en-US/workflow.ts +++ b/web/i18n/en-US/workflow.ts @@ -1029,6 +1029,7 @@ const translation = { }, singleRun: { label: 'Form variables', + button: 'Generate Form', }, }, }, diff --git a/web/i18n/zh-Hans/workflow.ts b/web/i18n/zh-Hans/workflow.ts index fba9eaf063..ec7dc92f7f 100644 --- a/web/i18n/zh-Hans/workflow.ts +++ b/web/i18n/zh-Hans/workflow.ts @@ -1029,6 +1029,7 @@ const translation = { }, singleRun: { label: '表单变量', + button: '生成表单', }, }, },