From fedd097f632c35221bf2069b1d2eb114029148b2 Mon Sep 17 00:00:00 2001 From: Wu Tianwei <30284043+WTW0313@users.noreply.github.com> Date: Fri, 30 Jan 2026 10:16:46 +0800 Subject: [PATCH] feat: Human Input node (Frontend Part) (#31631) Co-authored-by: JzoNg Co-authored-by: Joel Co-authored-by: yessenia Co-authored-by: QuantumGhost --- web/__mocks__/provider-context.ts | 1 + .../svg-attribute-error-reproduction.spec.tsx | 1 - .../(humanInputLayout)/form/[token]/form.tsx | 289 ++++ .../(humanInputLayout)/form/[token]/page.tsx | 13 + .../components/authenticated-layout.tsx | 2 +- web/app/(shareLayout)/components/splash.tsx | 2 +- .../components/app/app-publisher/index.tsx | 27 +- web/app/components/app/log/list.tsx | 17 +- .../apikey-info-panel.test-utils.tsx | 1 + .../app/text-generate/item/index.tsx | 44 +- web/app/components/app/workflow-log/list.tsx | 8 + .../components/base/action-button/index.css | 4 + .../components/base/action-button/index.tsx | 1 + .../chat/chat-with-history/chat-wrapper.tsx | 68 +- .../base/chat/chat-with-history/hooks.tsx | 23 + .../human-input-content/content-item.tsx | 54 + .../human-input-content/content-wrapper.tsx | 64 + .../human-input-content/executed-action.tsx | 30 + .../human-input-content/expiration-time.tsx | 46 + .../human-input-content/human-input-form.tsx | 61 + .../human-input-content/submitted-content.tsx | 16 + .../answer/human-input-content/submitted.tsx | 25 + .../chat/answer/human-input-content/tips.tsx | 43 + .../chat/answer/human-input-content/type.ts | 31 + .../human-input-content/unsubmitted.tsx | 36 + .../chat/answer/human-input-content/utils.ts | 64 + .../answer/human-input-filled-form-list.tsx | 32 + .../chat/answer/human-input-form-list.tsx | 70 + .../base/chat/chat/answer/index.tsx | 409 ++++-- .../base/chat/chat/answer/operation.tsx | 25 +- .../chat/chat/answer/workflow-process.tsx | 15 +- web/app/components/base/chat/chat/context.tsx | 5 +- web/app/components/base/chat/chat/hooks.ts | 1190 ++++++++++++----- web/app/components/base/chat/chat/index.tsx | 10 + .../components/base/chat/chat/question.tsx | 22 +- web/app/components/base/chat/chat/type.ts | 23 +- .../chat/embedded-chatbot/chat-wrapper.tsx | 61 +- web/app/components/base/chat/types.ts | 4 +- .../base/icons/assets/public/other/slack.svg | 6 + .../base/icons/assets/public/other/teams.svg | 19 + .../assets/vender/workflow/human-in-loop.svg | 5 + .../base/icons/src/public/other/Slack.json | 61 + .../base/icons/src/public/other/Slack.tsx | 20 + .../base/icons/src/public/other/Teams.json | 146 ++ .../base/icons/src/public/other/Teams.tsx | 20 + .../base/icons/src/public/other/index.ts | 2 + .../src/vender/workflow/HumanInLoop.json | 48 + .../icons/src/vender/workflow/HumanInLoop.tsx | 20 + .../base/icons/src/vender/workflow/index.ts | 1 + web/app/components/base/markdown/index.tsx | 10 +- .../base/markdown/react-markdown-wrapper.tsx | 2 + .../base/prompt-editor/constants.tsx | 7 + .../components/base/prompt-editor/index.tsx | 63 +- .../plugins/component-picker-block/hooks.tsx | 31 +- .../plugins/component-picker-block/index.tsx | 4 + .../plugins/draggable-plugin/index.tsx | 86 ++ .../plugins/hitl-input-block/component-ui.tsx | 170 +++ .../plugins/hitl-input-block/component.tsx | 86 ++ .../hitl-input-block-replacement-block.tsx | 89 ++ .../plugins/hitl-input-block/index.tsx | 106 ++ .../plugins/hitl-input-block/input-field.tsx | 153 +++ .../plugins/hitl-input-block/node.tsx | 272 ++++ .../plugins/hitl-input-block/pre-populate.tsx | 148 ++ .../plugins/hitl-input-block/tag-label.tsx | 32 + .../plugins/hitl-input-block/type-switch.tsx | 27 + .../hitl-input-block/variable-block.tsx | 148 ++ .../plugins/request-url-block/component.tsx | 33 + .../plugins/request-url-block/index.tsx | 64 + .../plugins/request-url-block/node.tsx | 59 + .../request-url-block-replacement-block.tsx | 60 + .../shortcuts-popup-plugin/index.spec.tsx | 134 ++ .../plugins/shortcuts-popup-plugin/index.tsx | 305 +++++ ...kflow-variable-block-replacement-block.tsx | 2 +- .../components/base/prompt-editor/types.ts | 23 + .../components/billing/plan/index.spec.tsx | 8 +- web/app/components/billing/type.ts | 1 + .../components/billing/utils/index.spec.ts | 1 + .../create/common-modal.spec.tsx | 4 + .../rag-pipeline/components/index.spec.tsx | 2 + .../rag-pipeline-header/index.spec.tsx | 2 + .../publisher/index.spec.tsx | 2 + .../rag-pipeline-header/publisher/popup.tsx | 16 +- .../rag-pipeline/hooks/use-DSL.spec.ts | 52 +- .../hooks/use-available-nodes-meta-data.ts | 5 +- .../rag-pipeline/hooks/use-configs-map.ts | 2 +- .../rag-pipeline/hooks/use-pipeline-run.ts | 36 +- .../text-generation/result/content.spec.tsx | 133 -- .../share/text-generation/result/content.tsx | 35 - .../text-generation/result/header.spec.tsx | 176 --- .../share/text-generation/result/header.tsx | 117 -- .../share/text-generation/result/index.tsx | 435 +++--- .../workflow-header/features-trigger.tsx | 7 +- .../workflow-app/hooks/use-workflow-run.ts | 198 ++- web/app/components/workflow/block-icon.tsx | 3 + web/app/components/workflow/constants.ts | 12 + web/app/components/workflow/constants/node.ts | 4 +- .../components/workflow/header/run-mode.tsx | 11 +- .../workflow/hooks/use-available-blocks.ts | 2 +- .../workflow/hooks/use-checklist.ts | 4 +- .../workflow/hooks/use-edges-interactions.ts | 54 + .../hooks/use-fetch-workflow-inspect-vars.ts | 28 +- .../workflow/hooks/use-nodes-interactions.ts | 4 + .../workflow/hooks/use-workflow-history.ts | 1 + .../hooks/use-workflow-run-event/index.ts | 4 + .../use-workflow-node-finished.ts | 2 + ...e-workflow-node-human-input-form-filled.ts | 34 + ...-workflow-node-human-input-form-timeout.ts | 28 + .../use-workflow-node-human-input-required.ts | 60 + .../use-workflow-node-started.ts | 23 +- .../use-workflow-paused.ts | 26 + .../use-workflow-run-event.ts | 12 + .../use-workflow-started.ts | 10 + .../workflow/hooks/use-workflow-variables.ts | 2 +- .../components/workflow/hooks/use-workflow.ts | 14 +- .../components/before-run-form/form-item.tsx | 2 +- .../components/before-run-form/index.tsx | 98 +- .../nodes/_base/components/variable/utils.ts | 35 + .../variable/var-reference-picker.tsx | 375 +++--- .../variable/var-reference-vars.tsx | 7 +- .../variable-label/base/variable-label.tsx | 4 +- .../_base/components/workflow-panel/index.tsx | 3 +- .../workflow-panel/last-run/use-last-run.ts | 11 +- .../nodes/_base/hooks/use-one-step-run.ts | 18 +- .../components/workflow/nodes/_base/node.tsx | 35 +- .../components/workflow/nodes/components.ts | 4 + .../components/add-input-field.tsx | 27 + .../components/button-style-dropdown.tsx | 111 ++ .../delivery-method/email-configure-modal.tsx | 176 +++ .../components/delivery-method/index.tsx | 119 ++ .../delivery-method/mail-body-input.tsx | 65 + .../delivery-method/method-item.tsx | 212 +++ .../delivery-method/method-selector.tsx | 222 +++ .../delivery-method/recipient/email-input.tsx | 183 +++ .../delivery-method/recipient/email-item.tsx | 52 + .../delivery-method/recipient/index.tsx | 102 ++ .../delivery-method/recipient/member-list.tsx | 91 ++ .../recipient/member-selector.tsx | 69 + .../delivery-method/test-email-sender.tsx | 372 ++++++ .../delivery-method/upgrade-modal.tsx | 76 ++ .../components/form-content-preview.tsx | 101 ++ .../human-input/components/form-content.tsx | 175 +++ .../components/single-run-form.tsx | 87 ++ .../nodes/human-input/components/timeout.tsx | 69 + .../human-input/components/user-action.tsx | 111 ++ .../components/variable-in-markdown.tsx | 140 ++ .../workflow/nodes/human-input/default.ts | 75 ++ .../nodes/human-input/hooks/use-config.ts | 85 ++ .../human-input/hooks/use-form-content.ts | 65 + .../hooks/use-single-run-form-params.ts | 128 ++ .../workflow/nodes/human-input/node.tsx | 74 + .../workflow/nodes/human-input/panel.tsx | 251 ++++ .../workflow/nodes/human-input/types.ts | 72 + .../workflow/nodes/human-input/utils.ts | 3 + .../components/workflow/nodes/llm/panel.tsx | 3 +- .../mixed-variable-text-input/placeholder.tsx | 21 +- .../panel/debug-and-preview/chat-wrapper.tsx | 28 +- .../workflow/panel/debug-and-preview/hooks.ts | 510 ++++++- .../panel/human-input-filled-form-list.tsx | 34 + .../workflow/panel/human-input-form-list.tsx | 83 ++ .../workflow/panel/inputs-panel.tsx | 26 +- .../workflow/panel/workflow-preview.tsx | 29 +- web/app/components/workflow/run/index.tsx | 2 +- web/app/components/workflow/run/meta.tsx | 9 +- web/app/components/workflow/run/node.tsx | 15 +- .../components/workflow/run/result-panel.tsx | 3 + .../components/workflow/run/result-text.tsx | 4 +- .../workflow/run/status-container.tsx | 6 +- web/app/components/workflow/run/status.tsx | 93 +- .../run/utils/format-log/human-input/index.ts | 59 + .../workflow/run/utils/format-log/index.ts | 4 +- .../workflow/store/workflow/workflow-slice.ts | 2 + web/app/components/workflow/types.ts | 15 +- .../components/workflow/utils/elk-layout.ts | 88 +- web/app/components/workflow/utils/workflow.ts | 1 + .../components/nodes/base.tsx | 2 +- .../components/nodes/constants.ts | 1 + web/config/index.ts | 6 +- web/context/provider-context.tsx | 6 + web/eslint-suppressions.json | 91 +- web/i18n/en-US/common.json | 2 + web/i18n/en-US/share.json | 10 + web/i18n/en-US/workflow.json | 103 ++ web/i18n/zh-Hans/common.json | 2 + web/i18n/zh-Hans/share.json | 9 + web/i18n/zh-Hans/workflow.json | 103 ++ web/models/log.ts | 21 +- web/service/base.ts | 202 ++- web/service/refresh-token.ts | 2 +- web/service/share.ts | 66 +- web/service/use-common.ts | 2 +- web/service/use-log.ts | 16 + web/service/use-share.ts | 63 +- web/service/use-workflow.ts | 15 + web/service/workflow.ts | 28 + web/tailwind-common-config.ts | 2 + web/themes/manual-dark.css | 7 + web/themes/manual-light.css | 7 + web/types/workflow.ts | 63 + 198 files changed, 10955 insertions(+), 1683 deletions(-) create mode 100644 web/app/(humanInputLayout)/form/[token]/form.tsx create mode 100644 web/app/(humanInputLayout)/form/[token]/page.tsx create mode 100644 web/app/components/base/chat/chat/answer/human-input-content/content-item.tsx create mode 100644 web/app/components/base/chat/chat/answer/human-input-content/content-wrapper.tsx create mode 100644 web/app/components/base/chat/chat/answer/human-input-content/executed-action.tsx create mode 100644 web/app/components/base/chat/chat/answer/human-input-content/expiration-time.tsx create mode 100644 web/app/components/base/chat/chat/answer/human-input-content/human-input-form.tsx create mode 100644 web/app/components/base/chat/chat/answer/human-input-content/submitted-content.tsx create mode 100644 web/app/components/base/chat/chat/answer/human-input-content/submitted.tsx create mode 100644 web/app/components/base/chat/chat/answer/human-input-content/tips.tsx create mode 100644 web/app/components/base/chat/chat/answer/human-input-content/type.ts create mode 100644 web/app/components/base/chat/chat/answer/human-input-content/unsubmitted.tsx create mode 100644 web/app/components/base/chat/chat/answer/human-input-content/utils.ts create mode 100644 web/app/components/base/chat/chat/answer/human-input-filled-form-list.tsx create mode 100644 web/app/components/base/chat/chat/answer/human-input-form-list.tsx create mode 100644 web/app/components/base/icons/assets/public/other/slack.svg create mode 100644 web/app/components/base/icons/assets/public/other/teams.svg create mode 100644 web/app/components/base/icons/assets/vender/workflow/human-in-loop.svg create mode 100644 web/app/components/base/icons/src/public/other/Slack.json create mode 100644 web/app/components/base/icons/src/public/other/Slack.tsx create mode 100644 web/app/components/base/icons/src/public/other/Teams.json create mode 100644 web/app/components/base/icons/src/public/other/Teams.tsx create mode 100644 web/app/components/base/icons/src/vender/workflow/HumanInLoop.json create mode 100644 web/app/components/base/icons/src/vender/workflow/HumanInLoop.tsx create mode 100644 web/app/components/base/prompt-editor/plugins/draggable-plugin/index.tsx create mode 100644 web/app/components/base/prompt-editor/plugins/hitl-input-block/component-ui.tsx create mode 100644 web/app/components/base/prompt-editor/plugins/hitl-input-block/component.tsx create mode 100644 web/app/components/base/prompt-editor/plugins/hitl-input-block/hitl-input-block-replacement-block.tsx create mode 100644 web/app/components/base/prompt-editor/plugins/hitl-input-block/index.tsx create mode 100644 web/app/components/base/prompt-editor/plugins/hitl-input-block/input-field.tsx create mode 100644 web/app/components/base/prompt-editor/plugins/hitl-input-block/node.tsx create mode 100644 web/app/components/base/prompt-editor/plugins/hitl-input-block/pre-populate.tsx create mode 100644 web/app/components/base/prompt-editor/plugins/hitl-input-block/tag-label.tsx create mode 100644 web/app/components/base/prompt-editor/plugins/hitl-input-block/type-switch.tsx create mode 100644 web/app/components/base/prompt-editor/plugins/hitl-input-block/variable-block.tsx create mode 100644 web/app/components/base/prompt-editor/plugins/request-url-block/component.tsx create mode 100644 web/app/components/base/prompt-editor/plugins/request-url-block/index.tsx create mode 100644 web/app/components/base/prompt-editor/plugins/request-url-block/node.tsx create mode 100644 web/app/components/base/prompt-editor/plugins/request-url-block/request-url-block-replacement-block.tsx create mode 100644 web/app/components/base/prompt-editor/plugins/shortcuts-popup-plugin/index.spec.tsx create mode 100644 web/app/components/base/prompt-editor/plugins/shortcuts-popup-plugin/index.tsx delete mode 100644 web/app/components/share/text-generation/result/content.spec.tsx delete mode 100644 web/app/components/share/text-generation/result/content.tsx delete mode 100644 web/app/components/share/text-generation/result/header.spec.tsx delete mode 100644 web/app/components/share/text-generation/result/header.tsx create mode 100644 web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-human-input-form-filled.ts create mode 100644 web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-human-input-form-timeout.ts create mode 100644 web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-human-input-required.ts create mode 100644 web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-paused.ts create mode 100644 web/app/components/workflow/nodes/human-input/components/add-input-field.tsx create mode 100644 web/app/components/workflow/nodes/human-input/components/button-style-dropdown.tsx create mode 100644 web/app/components/workflow/nodes/human-input/components/delivery-method/email-configure-modal.tsx create mode 100644 web/app/components/workflow/nodes/human-input/components/delivery-method/index.tsx create mode 100644 web/app/components/workflow/nodes/human-input/components/delivery-method/mail-body-input.tsx create mode 100644 web/app/components/workflow/nodes/human-input/components/delivery-method/method-item.tsx create mode 100644 web/app/components/workflow/nodes/human-input/components/delivery-method/method-selector.tsx create mode 100644 web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/email-input.tsx create mode 100644 web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/email-item.tsx create mode 100644 web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/index.tsx create mode 100644 web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/member-list.tsx create mode 100644 web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/member-selector.tsx create mode 100644 web/app/components/workflow/nodes/human-input/components/delivery-method/test-email-sender.tsx create mode 100644 web/app/components/workflow/nodes/human-input/components/delivery-method/upgrade-modal.tsx create mode 100644 web/app/components/workflow/nodes/human-input/components/form-content-preview.tsx create mode 100644 web/app/components/workflow/nodes/human-input/components/form-content.tsx create mode 100644 web/app/components/workflow/nodes/human-input/components/single-run-form.tsx create mode 100644 web/app/components/workflow/nodes/human-input/components/timeout.tsx create mode 100644 web/app/components/workflow/nodes/human-input/components/user-action.tsx create mode 100644 web/app/components/workflow/nodes/human-input/components/variable-in-markdown.tsx create mode 100644 web/app/components/workflow/nodes/human-input/default.ts create mode 100644 web/app/components/workflow/nodes/human-input/hooks/use-config.ts create mode 100644 web/app/components/workflow/nodes/human-input/hooks/use-form-content.ts create mode 100644 web/app/components/workflow/nodes/human-input/hooks/use-single-run-form-params.ts create mode 100644 web/app/components/workflow/nodes/human-input/node.tsx create mode 100644 web/app/components/workflow/nodes/human-input/panel.tsx create mode 100644 web/app/components/workflow/nodes/human-input/types.ts create mode 100644 web/app/components/workflow/nodes/human-input/utils.ts create mode 100644 web/app/components/workflow/panel/human-input-filled-form-list.tsx create mode 100644 web/app/components/workflow/panel/human-input-form-list.tsx create mode 100644 web/app/components/workflow/run/utils/format-log/human-input/index.ts diff --git a/web/__mocks__/provider-context.ts b/web/__mocks__/provider-context.ts index 373c2f86d3..d3296bacd0 100644 --- a/web/__mocks__/provider-context.ts +++ b/web/__mocks__/provider-context.ts @@ -35,6 +35,7 @@ export const baseProviderContextValue: ProviderContextState = { refreshLicenseLimit: noop, isAllowTransferWorkspace: false, isAllowPublishAsCustomKnowledgePipelineTemplate: false, + humanInputEmailDeliveryEnabled: false, } export const createMockProviderContextValue = (overrides: Partial = {}): ProviderContextState => { diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/__tests__/svg-attribute-error-reproduction.spec.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/__tests__/svg-attribute-error-reproduction.spec.tsx index fc27f84c60..fffc1ff2a5 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/__tests__/svg-attribute-error-reproduction.spec.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/__tests__/svg-attribute-error-reproduction.spec.tsx @@ -8,7 +8,6 @@ describe('SVG Attribute Error Reproduction', () => { // Capture console errors const originalError = console.error let errorMessages: string[] = [] - beforeEach(() => { errorMessages = [] console.error = vi.fn((message) => { diff --git a/web/app/(humanInputLayout)/form/[token]/form.tsx b/web/app/(humanInputLayout)/form/[token]/form.tsx new file mode 100644 index 0000000000..d027ef8b7d --- /dev/null +++ b/web/app/(humanInputLayout)/form/[token]/form.tsx @@ -0,0 +1,289 @@ +'use client' +import type { ButtonProps } from '@/app/components/base/button' +import type { FormInputItem, UserAction } from '@/app/components/workflow/nodes/human-input/types' +import type { SiteInfo } from '@/models/share' +import type { HumanInputFormError } from '@/service/use-share' +import { + RiCheckboxCircleFill, + RiErrorWarningFill, + RiInformation2Fill, +} from '@remixicon/react' +import { produce } from 'immer' +import { useParams } from 'next/navigation' +import * as React from 'react' +import { useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import AppIcon from '@/app/components/base/app-icon' +import Button from '@/app/components/base/button' +import ContentItem from '@/app/components/base/chat/chat/answer/human-input-content/content-item' +import ExpirationTime from '@/app/components/base/chat/chat/answer/human-input-content/expiration-time' +import { getButtonStyle } from '@/app/components/base/chat/chat/answer/human-input-content/utils' +import Loading from '@/app/components/base/loading' +import DifyLogo from '@/app/components/base/logo/dify-logo' +import useDocumentTitle from '@/hooks/use-document-title' +import { useGetHumanInputForm, useSubmitHumanInputForm } from '@/service/use-share' +import { cn } from '@/utils/classnames' + +export type FormData = { + site: { site: SiteInfo } + form_content: string + inputs: FormInputItem[] + resolved_default_values: Record + user_actions: UserAction[] + expiration_time: number +} + +const FormContent = () => { + const { t } = useTranslation() + + const { token } = useParams<{ token: string }>() + useDocumentTitle('') + + const [inputs, setInputs] = useState>({}) + const [success, setSuccess] = useState(false) + + const { mutate: submitForm, isPending: isSubmitting } = useSubmitHumanInputForm() + + const { data: formData, isLoading, error } = useGetHumanInputForm(token) + + const expired = (error as HumanInputFormError | null)?.code === 'human_input_form_expired' + const submitted = (error as HumanInputFormError | null)?.code === 'human_input_form_submitted' + const rateLimitExceeded = (error as HumanInputFormError | null)?.code === 'web_form_rate_limit_exceeded' + + const splitByOutputVar = (content: string): string[] => { + const outputVarRegex = /(\{\{#\$output\.[^#]+#\}\})/g + const parts = content.split(outputVarRegex) + return parts.filter(part => part.length > 0) + } + + const contentList = useMemo(() => { + if (!formData?.form_content) + return [] + return splitByOutputVar(formData.form_content) + }, [formData?.form_content]) + + useEffect(() => { + if (!formData?.inputs) + return + const initialInputs: Record = {} + formData.inputs.forEach((item) => { + initialInputs[item.output_variable_name] = item.default.type === 'variable' ? formData.resolved_default_values[item.output_variable_name] || '' : item.default.value + }) + setInputs(initialInputs) + }, [formData?.inputs, formData?.resolved_default_values]) + + // use immer + const handleInputsChange = (name: string, value: string) => { + const newInputs = produce(inputs, (draft) => { + draft[name] = value + }) + setInputs(newInputs) + } + + const submit = (actionID: string) => { + submitForm( + { token, data: { inputs, action: actionID } }, + { + onSuccess: () => { + setSuccess(true) + }, + }, + ) + } + + if (isLoading) { + return ( + + ) + } + + if (success) { + return ( +
+
+
+
+ +
+
+
{t('humanInput.thanks', { ns: 'share' })}
+
{t('humanInput.recorded', { ns: 'share' })}
+
+
{t('humanInput.submissionID', { id: token, ns: 'share' })}
+
+
+
+
{t('chat.poweredBy', { ns: 'share' })}
+ +
+
+
+
+ ) + } + + if (expired) { + return ( +
+
+
+
+ +
+
+
{t('humanInput.sorry', { ns: 'share' })}
+
{t('humanInput.expired', { ns: 'share' })}
+
+
{t('humanInput.submissionID', { id: token, ns: 'share' })}
+
+
+
+
{t('chat.poweredBy', { ns: 'share' })}
+ +
+
+
+
+ ) + } + + if (submitted) { + return ( +
+
+
+
+ +
+
+
{t('humanInput.sorry', { ns: 'share' })}
+
{t('humanInput.completed', { ns: 'share' })}
+
+
{t('humanInput.submissionID', { id: token, ns: 'share' })}
+
+
+
+
{t('chat.poweredBy', { ns: 'share' })}
+ +
+
+
+
+ ) + } + + if (rateLimitExceeded) { + return ( +
+
+
+
+ +
+
+
{t('humanInput.rateLimitExceeded', { ns: 'share' })}
+
+
+
+
+
{t('chat.poweredBy', { ns: 'share' })}
+ +
+
+
+
+ ) + } + + if (!formData) { + return ( +
+
+
+
+ +
+
+
{t('humanInput.formNotFound', { ns: 'share' })}
+
+
+
+
+
{t('chat.poweredBy', { ns: 'share' })}
+ +
+
+
+
+ ) + } + + const site = formData.site.site + + return ( +
+
+ +
{site.title}
+
+
+
+ {contentList.map((content, index) => ( + + ))} +
+ {formData.user_actions.map((action: UserAction) => ( + + ))} +
+ +
+
+
+
{t('chat.poweredBy', { ns: 'share' })}
+ +
+
+
+
+ ) +} + +export default React.memo(FormContent) diff --git a/web/app/(humanInputLayout)/form/[token]/page.tsx b/web/app/(humanInputLayout)/form/[token]/page.tsx new file mode 100644 index 0000000000..a7e2305b2b --- /dev/null +++ b/web/app/(humanInputLayout)/form/[token]/page.tsx @@ -0,0 +1,13 @@ +'use client' +import * as React from 'react' +import FormContent from './form' + +const FormPage = () => { + return ( +
+ +
+ ) +} + +export default React.memo(FormPage) diff --git a/web/app/(shareLayout)/components/authenticated-layout.tsx b/web/app/(shareLayout)/components/authenticated-layout.tsx index 113f3b5680..c874990448 100644 --- a/web/app/(shareLayout)/components/authenticated-layout.tsx +++ b/web/app/(shareLayout)/components/authenticated-layout.tsx @@ -47,7 +47,7 @@ const AuthenticatedLayout = ({ children }: { children: React.ReactNode }) => { await webAppLogout(shareCode!) const url = getSigninUrl() router.replace(url) - }, [getSigninUrl, router, webAppLogout, shareCode]) + }, [getSigninUrl, router, shareCode]) if (appInfoError) { return ( diff --git a/web/app/(shareLayout)/components/splash.tsx b/web/app/(shareLayout)/components/splash.tsx index 9f89a03993..a2b847f74f 100644 --- a/web/app/(shareLayout)/components/splash.tsx +++ b/web/app/(shareLayout)/components/splash.tsx @@ -31,7 +31,7 @@ const Splash: FC = ({ children }) => { await webAppLogout(shareCode!) const url = getSigninUrl() router.replace(url) - }, [getSigninUrl, router, webAppLogout, shareCode]) + }, [getSigninUrl, router, shareCode]) const [isLoading, setIsLoading] = useState(true) useEffect(() => { diff --git a/web/app/components/app/app-publisher/index.tsx b/web/app/components/app/app-publisher/index.tsx index 0fc364cb7e..1348e3111f 100644 --- a/web/app/components/app/app-publisher/index.tsx +++ b/web/app/components/app/app-publisher/index.tsx @@ -115,6 +115,7 @@ export type AppPublisherProps = { missingStartNode?: boolean hasTriggerNode?: boolean // Whether workflow currently contains any trigger nodes (used to hide missing-start CTA when triggers exist). startNodeLimitExceeded?: boolean + hasHumanInputNode?: boolean } const PUBLISH_SHORTCUT = ['ctrl', '⇧', 'P'] @@ -138,13 +139,14 @@ const AppPublisher = ({ missingStartNode = false, hasTriggerNode = false, startNodeLimitExceeded = false, + hasHumanInputNode = false, }: AppPublisherProps) => { const { t } = useTranslation() const [published, setPublished] = useState(false) const [open, setOpen] = useState(false) const [showAppAccessControl, setShowAppAccessControl] = useState(false) - const [isAppAccessSet, setIsAppAccessSet] = useState(true) + const [embeddingModalOpen, setEmbeddingModalOpen] = useState(false) const appDetail = useAppStore(state => state.appDetail) @@ -161,6 +163,13 @@ const AppPublisher = ({ const { data: appAccessSubjects, isLoading: isGettingAppWhiteListSubjects } = useAppWhiteListSubjects(appDetail?.id, open && systemFeatures.webapp_auth.enabled && appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS) const openAsyncWindow = useAsyncWindowOpen() + const isAppAccessSet = useMemo(() => { + if (appDetail && appAccessSubjects) { + return !(appDetail.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS && appAccessSubjects.groups?.length === 0 && appAccessSubjects.members?.length === 0) + } + return true + }, [appAccessSubjects, appDetail]) + const noAccessPermission = useMemo(() => systemFeatures.webapp_auth.enabled && appDetail && appDetail.access_mode !== AccessMode.EXTERNAL_MEMBERS && !userCanAccessApp?.result, [systemFeatures, appDetail, userCanAccessApp]) const disabledFunctionButton = useMemo(() => (!publishedAt || missingStartNode || noAccessPermission), [publishedAt, missingStartNode, noAccessPermission]) @@ -171,25 +180,13 @@ const AppPublisher = ({ return t('noUserInputNode', { ns: 'app' }) if (noAccessPermission) return t('noAccessPermission', { ns: 'app' }) - }, [missingStartNode, noAccessPermission, publishedAt]) + }, [missingStartNode, noAccessPermission, publishedAt, t]) useEffect(() => { if (systemFeatures.webapp_auth.enabled && open && appDetail) refetch() }, [open, appDetail, refetch, systemFeatures]) - useEffect(() => { - if (appDetail && appAccessSubjects) { - if (appDetail.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS && appAccessSubjects.groups?.length === 0 && appAccessSubjects.members?.length === 0) - setIsAppAccessSet(false) - else - setIsAppAccessSet(true) - } - else { - setIsAppAccessSet(true) - } - }, [appAccessSubjects, appDetail]) - const handlePublish = useCallback(async (params?: ModelAndParameter | PublishWorkflowParams) => { try { await onPublish?.(params) @@ -461,7 +458,7 @@ const AppPublisher = ({ {t('common.accessAPIReference', { ns: 'workflow' })} - {appDetail?.mode === AppModeEnum.WORKFLOW && ( + {appDetail?.mode === AppModeEnum.WORKFLOW && !hasHumanInputNode && ( { if (!statusCount) return null - if (statusCount.partial_success + statusCount.failed === 0) { + if (statusCount.paused > 0) { + return ( +
+ + Pending +
+ ) + } + else if (statusCount.partial_success + statusCount.failed === 0) { return (
@@ -296,7 +305,7 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) { if (abortControllerRef.current === controller) abortControllerRef.current = null } - }, [detail.id, hasMore, timezone, t, appDetail, detail?.model_config?.configs?.introduction]) + }, [detail.id, hasMore, timezone, t, appDetail]) // Derive chatItemTree, threadChatItems, and oldestAnswerIdRef from allChatItems useEffect(() => { @@ -411,7 +420,7 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) { notify({ type: 'error', message: t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }) }) return false } - }, [allChatItems, appDetail?.id, t]) + }, [allChatItems, appDetail?.id, notify, t]) const fetchInitiated = useRef(false) @@ -504,7 +513,7 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) { finally { setIsLoading(false) } - }, [detail.id, hasMore, isLoading, timezone, t, appDetail, detail?.model_config?.configs?.introduction]) + }, [detail.id, hasMore, isLoading, timezone, t, appDetail]) const handleScroll = useCallback(() => { const scrollableDiv = document.getElementById('scrollableDiv') diff --git a/web/app/components/app/overview/apikey-info-panel/apikey-info-panel.test-utils.tsx b/web/app/components/app/overview/apikey-info-panel/apikey-info-panel.test-utils.tsx index 17857ec702..54763907df 100644 --- a/web/app/components/app/overview/apikey-info-panel/apikey-info-panel.test-utils.tsx +++ b/web/app/components/app/overview/apikey-info-panel/apikey-info-panel.test-utils.tsx @@ -53,6 +53,7 @@ const defaultProviderContext = { refreshLicenseLimit: noop, isAllowTransferWorkspace: false, isAllowPublishAsCustomKnowledgePipelineTemplate: false, + humanInputEmailDeliveryEnabled: false, } const defaultModalContext: ModalContextState = { diff --git a/web/app/components/app/text-generate/item/index.tsx b/web/app/components/app/text-generate/item/index.tsx index c39282a022..22358805a7 100644 --- a/web/app/components/app/text-generate/item/index.tsx +++ b/web/app/components/app/text-generate/item/index.tsx @@ -8,7 +8,7 @@ import { RiClipboardLine, RiFileList3Line, RiPlayList2Line, - RiReplay15Line, + RiResetLeftLine, RiSparklingFill, RiSparklingLine, RiThumbDownLine, @@ -18,10 +18,12 @@ import { useBoolean } from 'ahooks' import copy from 'copy-to-clipboard' import { useParams } from 'next/navigation' import * as React from 'react' -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { useStore as useAppStore } from '@/app/components/app/store' import ActionButton, { ActionButtonState } from '@/app/components/base/action-button' +import HumanInputFilledFormList from '@/app/components/base/chat/chat/answer/human-input-filled-form-list' +import HumanInputFormList from '@/app/components/base/chat/chat/answer/human-input-form-list' import WorkflowProcessItem from '@/app/components/base/chat/chat/answer/workflow-process' import { useChatContext } from '@/app/components/base/chat/chat/context' import Loading from '@/app/components/base/loading' @@ -29,7 +31,8 @@ import { Markdown } from '@/app/components/base/markdown' import NewAudioButton from '@/app/components/base/new-audio-button' import Toast from '@/app/components/base/toast' import { fetchTextGenerationMessage } from '@/service/debug' -import { AppSourceType, fetchMoreLikeThis, updateFeedback } from '@/service/share' +import { AppSourceType, fetchMoreLikeThis, submitHumanInputForm, updateFeedback } from '@/service/share' +import { submitHumanInputForm as submitHumanInputFormService } from '@/service/workflow' import { cn } from '@/utils/classnames' import ResultTab from './result-tab' @@ -121,7 +124,7 @@ const GenerationItem: FC = ({ const [isQuerying, { setTrue: startQuerying, setFalse: stopQuerying }] = useBoolean(false) const childProps = { - isInWebApp: true, + isInWebApp, content: completionRes, messageId: childMessageId, depth: depth + 1, @@ -202,16 +205,22 @@ const GenerationItem: FC = ({ } const [currentTab, setCurrentTab] = useState('DETAIL') - const showResultTabs = !!workflowProcessData?.resultText || !!workflowProcessData?.files?.length + const showResultTabs = !!workflowProcessData?.resultText || !!workflowProcessData?.files?.length || (workflowProcessData?.humanInputFormDataList && workflowProcessData?.humanInputFormDataList.length > 0) || (workflowProcessData?.humanInputFilledFormDataList && workflowProcessData?.humanInputFilledFormDataList.length > 0) const switchTab = async (tab: string) => { setCurrentTab(tab) } useEffect(() => { - if (workflowProcessData?.resultText || !!workflowProcessData?.files?.length) + if (workflowProcessData?.resultText || !!workflowProcessData?.files?.length || (workflowProcessData?.humanInputFormDataList && workflowProcessData?.humanInputFormDataList.length > 0) || (workflowProcessData?.humanInputFilledFormDataList && workflowProcessData?.humanInputFilledFormDataList.length > 0)) switchTab('RESULT') else switchTab('DETAIL') - }, [workflowProcessData?.files?.length, workflowProcessData?.resultText]) + }, [workflowProcessData?.files?.length, workflowProcessData?.resultText, workflowProcessData?.humanInputFormDataList, workflowProcessData?.humanInputFilledFormDataList]) + const handleSubmitHumanInputForm = useCallback(async (formToken: string, formData: { inputs: Record, action: string }) => { + if (appSourceType === AppSourceType.installedApp) + await submitHumanInputFormService(formToken, formData) + else + await submitHumanInputForm(formToken, formData) + }, [appSourceType]) return ( <> @@ -275,7 +284,24 @@ const GenerationItem: FC = ({ )}
{!isError && ( - + <> + {currentTab === 'RESULT' && workflowProcessData.humanInputFormDataList && workflowProcessData.humanInputFormDataList.length > 0 && ( +
+ +
+ )} + {currentTab === 'RESULT' && workflowProcessData.humanInputFilledFormDataList && workflowProcessData.humanInputFilledFormDataList.length > 0 && ( +
+ +
+ )} + + )} )} @@ -348,7 +374,7 @@ const GenerationItem: FC = ({ )} {isInWebApp && isError && ( - + )} {isInWebApp && !isWorkflow && !isTryApp && ( diff --git a/web/app/components/app/workflow-log/list.tsx b/web/app/components/app/workflow-log/list.tsx index b9597c8ea1..262efad781 100644 --- a/web/app/components/app/workflow-log/list.tsx +++ b/web/app/components/app/workflow-log/list.tsx @@ -81,6 +81,14 @@ const WorkflowAppLogList: FC = ({ logs, appDetail, onRefresh }) => { ) } + if (status === 'paused') { + return ( +
+ + Pending +
+ ) + } if (status === 'running') { return (
diff --git a/web/app/components/base/action-button/index.css b/web/app/components/base/action-button/index.css index 3c1a10b86f..4ede34aeb5 100644 --- a/web/app/components/base/action-button/index.css +++ b/web/app/components/base/action-button/index.css @@ -26,6 +26,10 @@ @apply p-0.5 w-6 h-6 rounded-lg } + .action-btn-s { + @apply w-5 h-5 rounded-[6px] + } + .action-btn-xs { @apply p-0 w-4 h-4 rounded } diff --git a/web/app/components/base/action-button/index.tsx b/web/app/components/base/action-button/index.tsx index c91d472087..d182193b00 100644 --- a/web/app/components/base/action-button/index.tsx +++ b/web/app/components/base/action-button/index.tsx @@ -18,6 +18,7 @@ const actionButtonVariants = cva( variants: { size: { xs: 'action-btn-xs', + s: 'action-btn-s', m: 'action-btn-m', l: 'action-btn-l', xl: 'action-btn-xl', diff --git a/web/app/components/base/chat/chat-with-history/chat-wrapper.tsx b/web/app/components/base/chat/chat-with-history/chat-wrapper.tsx index 38a3f6c6b2..304425b9a7 100644 --- a/web/app/components/base/chat/chat-with-history/chat-wrapper.tsx +++ b/web/app/components/base/chat/chat-with-history/chat-wrapper.tsx @@ -2,6 +2,7 @@ import type { FileEntity } from '../../file-uploader/types' import type { ChatConfig, ChatItem, + ChatItemInTree, OnSend, } from '../types' import { useCallback, useEffect, useMemo, useState } from 'react' @@ -16,7 +17,9 @@ import { fetchSuggestedQuestions, getUrl, stopChatMessageResponding, + submitHumanInputForm, } from '@/service/share' +import { submitHumanInputForm as submitHumanInputFormService } from '@/service/workflow' import { TransferMethod } from '@/types/app' import { cn } from '@/utils/classnames' import { formatBooleanInputs } from '@/utils/model-config' @@ -73,9 +76,9 @@ const ChatWrapper = () => { }, [appParams, currentConversationItem?.introduction]) const { chatList, - setTargetMessageId, handleSend, handleStop, + handleSwitchSibling, isResponding: respondingState, suggestedQuestions, } = useChat( @@ -122,8 +125,11 @@ const ChatWrapper = () => { if (fileIsUploading) return true + + if (chatList.some(item => item.isAnswer && item.humanInputFormDataList && item.humanInputFormDataList.length > 0)) + return true return false - }, [inputsFormValue, inputsForms, allInputsHidden]) + }, [allInputsHidden, inputsForms, chatList, inputsFormValue]) useEffect(() => { if (currentChatInstanceRef.current) @@ -134,6 +140,40 @@ const ChatWrapper = () => { setIsResponding(respondingState) }, [respondingState, setIsResponding]) + // Resume paused workflows when chat history is loaded + useEffect(() => { + if (!appPrevChatTree || appPrevChatTree.length === 0) + return + + // Find the last answer item with workflow_run_id that needs resumption (DFS - find deepest first) + let lastPausedNode: ChatItemInTree | undefined + const findLastPausedWorkflow = (nodes: ChatItemInTree[]) => { + nodes.forEach((node) => { + // DFS: recurse to children first + if (node.children && node.children.length > 0) + findLastPausedWorkflow(node.children) + + // Track the last node with humanInputFormDataList + if (node.isAnswer && node.workflow_run_id && node.humanInputFormDataList && node.humanInputFormDataList.length > 0) + lastPausedNode = node + }) + } + + findLastPausedWorkflow(appPrevChatTree) + + // Only resume the last paused workflow + if (lastPausedNode) { + handleSwitchSibling( + lastPausedNode.id, + { + onGetSuggestedQuestions: responseItemId => fetchSuggestedQuestions(responseItemId, appSourceType, appId), + onConversationComplete: currentConversationId ? undefined : handleNewConversationCompleted, + isPublicAPI: appSourceType === AppSourceType.webApp, + }, + ) + } + }, []) + const doSend: OnSend = useCallback((message, files, isRegenerate = false, parentAnswer: ChatItem | null = null) => { const data: any = { query: message, @@ -149,10 +189,10 @@ const ChatWrapper = () => { { onGetSuggestedQuestions: responseItemId => fetchSuggestedQuestions(responseItemId, appSourceType, appId), onConversationComplete: isHistoryConversation ? undefined : handleNewConversationCompleted, - isPublicAPI: !isInstalledApp, + isPublicAPI: appSourceType === AppSourceType.webApp, }, ) - }, [chatList, handleNewConversationCompleted, handleSend, currentConversationId, currentConversationInputs, newConversationInputs, isInstalledApp, appId]) + }, [inputsForms, currentConversationId, currentConversationInputs, newConversationInputs, chatList, handleSend, appSourceType, appId, isHistoryConversation, handleNewConversationCompleted]) const doRegenerate = useCallback((chatItem: ChatItem, editedQuestion?: { message: string, files?: FileEntity[] }) => { const question = editedQuestion ? chatItem : chatList.find(item => item.id === chatItem.parentMessageId)! @@ -160,12 +200,27 @@ const ChatWrapper = () => { doSend(editedQuestion ? editedQuestion.message : question.content, editedQuestion ? editedQuestion.files : question.message_files, true, isValidGeneratedAnswer(parentAnswer) ? parentAnswer : null) }, [chatList, doSend]) + const doSwitchSibling = useCallback((siblingMessageId: string) => { + handleSwitchSibling(siblingMessageId, { + onGetSuggestedQuestions: responseItemId => fetchSuggestedQuestions(responseItemId, appSourceType, appId), + onConversationComplete: currentConversationId ? undefined : handleNewConversationCompleted, + isPublicAPI: appSourceType === AppSourceType.webApp, + }) + }, [handleSwitchSibling, currentConversationId, handleNewConversationCompleted, appSourceType, appId]) + const messageList = useMemo(() => { if (currentConversationId || chatList.length > 1) return chatList // Without messages we are in the welcome screen, so hide the opening statement from chatlist return chatList.filter(item => !item.isOpeningStatement) - }, [chatList]) + }, [chatList, currentConversationId]) + + const handleSubmitHumanInputForm = useCallback(async (formToken: string, formData: any) => { + if (isInstalledApp) + await submitHumanInputFormService(formToken, formData) + else + await submitHumanInputForm(formToken, formData) + }, [isInstalledApp]) const [collapsed, setCollapsed] = useState(!!currentConversationId) @@ -274,6 +329,7 @@ const ChatWrapper = () => { inputsForm={inputsForms} onRegenerate={doRegenerate} onStopResponding={handleStop} + onHumanInputFormSubmit={handleSubmitHumanInputForm} chatNode={( <> {chatNode} @@ -286,7 +342,7 @@ const ChatWrapper = () => { answerIcon={answerIcon} hideProcessDetail themeBuilder={themeBuilder} - switchSibling={siblingMessageId => setTargetMessageId(siblingMessageId)} + switchSibling={doSwitchSibling} inputDisabled={inputDisabled} sidebarCollapseState={sidebarCollapseState} questionIcon={ diff --git a/web/app/components/base/chat/chat-with-history/hooks.tsx b/web/app/components/base/chat/chat-with-history/hooks.tsx index ad1de38d07..da344a9789 100644 --- a/web/app/components/base/chat/chat-with-history/hooks.tsx +++ b/web/app/components/base/chat/chat-with-history/hooks.tsx @@ -1,3 +1,4 @@ +import type { ExtraContent } from '../chat/type' import type { Callback, ChatConfig, @@ -9,6 +10,7 @@ import type { AppData, ConversationItem, } from '@/models/share' +import type { HumanInputFilledFormData, HumanInputFormData } from '@/types/workflow' import { useLocalStorageState } from 'ahooks' import { noop } from 'es-toolkit/function' import { produce } from 'immer' @@ -57,6 +59,24 @@ function getFormattedChatList(messages: any[]) { parentMessageId: item.parent_message_id || undefined, }) const answerFiles = item.message_files?.filter((file: any) => file.belongs_to === 'assistant') || [] + const humanInputFormDataList: HumanInputFormData[] = [] + const humanInputFilledFormDataList: HumanInputFilledFormData[] = [] + let workflowRunId = '' + if (item.status === 'paused') { + item.extra_contents?.forEach((content: ExtraContent) => { + if (content.type === 'human_input' && !content.submitted) { + humanInputFormDataList.push(content.form_definition) + workflowRunId = content.workflow_run_id + } + }) + } + else if (item.status === 'normal') { + item.extra_contents?.forEach((content: ExtraContent) => { + if (content.type === 'human_input' && content.submitted) { + humanInputFilledFormDataList.push(content.form_submission_data) + } + }) + } newChatList.push({ id: item.id, content: item.answer, @@ -66,6 +86,9 @@ function getFormattedChatList(messages: any[]) { citation: item.retriever_resources, message_files: getProcessedFilesFromResponse(answerFiles.map((item: any) => ({ ...item, related_id: item.id, upload_file_id: item.upload_file_id }))), parentMessageId: `question-${item.id}`, + humanInputFormDataList, + humanInputFilledFormDataList, + workflow_run_id: workflowRunId, }) }) return newChatList diff --git a/web/app/components/base/chat/chat/answer/human-input-content/content-item.tsx b/web/app/components/base/chat/chat/answer/human-input-content/content-item.tsx new file mode 100644 index 0000000000..3ed777d41e --- /dev/null +++ b/web/app/components/base/chat/chat/answer/human-input-content/content-item.tsx @@ -0,0 +1,54 @@ +import type { ContentItemProps } from './type' +import * as React from 'react' +import { useMemo } from 'react' +import { Markdown } from '@/app/components/base/markdown' +import Textarea from '@/app/components/base/textarea' + +const ContentItem = ({ + content, + formInputFields, + inputs, + onInputChange, +}: ContentItemProps) => { + const isInputField = (field: string) => { + const outputVarRegex = /\{\{#\$output\.[^#]+#\}\}/ + return outputVarRegex.test(field) + } + + const extractFieldName = (str: string): string => { + const outputVarRegex = /\{\{#\$output\.([^#]+)#\}\}/ + const match = str.match(outputVarRegex) + return match ? match[1] : '' + } + + const fieldName = useMemo(() => { + return extractFieldName(content) + }, [content]) + + const formInputField = useMemo(() => { + return formInputFields.find(field => field.output_variable_name === fieldName) + }, [formInputFields, fieldName]) + + if (!isInputField(content)) { + return ( + + ) + } + + if (!formInputField) + return null + + return ( +
+ {formInputField.type === 'paragraph' && ( +