From 3c98f96ae81d337d00ac0cda90bbce61ca4bc24e Mon Sep 17 00:00:00 2001 From: QuantumGhost Date: Thu, 4 Jun 2026 09:54:28 +0800 Subject: [PATCH] feat(api): introduce select, file and file list form input types to Human Input node (#36322) Co-authored-by: JzoNg Co-authored-by: GPT 5.4 Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: -LAN- --- api/Dockerfile | 2 +- api/controllers/common/human_input.py | 34 +- .../service_api/app/human_input_form.py | 11 +- api/controllers/web/__init__.py | 2 + .../web/human_input_file_upload.py | 212 ++++++++++ api/controllers/web/human_input_form.py | 74 +++- .../common/workflow_response_converter.py | 55 ++- api/core/app/apps/workflow_app_runner.py | 1 + api/core/app/entities/queue_entities.py | 5 + api/core/app/entities/task_entities.py | 2 + api/core/entities/execution_extra_content.py | 19 +- api/core/workflow/human_input_policy.py | 72 +++- api/core/workflow/node_runtime.py | 77 +++- api/factories/file_factory/builders.py | 142 +++++-- ...c2a1b9f03_add_human_input_upload_tables.py | 64 +++ api/models/__init__.py | 4 +- api/models/human_input.py | 52 +++ api/openapi/markdown/console-swagger.md | 1 + api/openapi/markdown/openapi-swagger.md | 2 +- api/openapi/markdown/service-swagger.md | 2 +- api/openapi/markdown/web-swagger.md | 51 +++ ...hemy_execution_extra_content_repository.py | 36 +- .../human_input_file_upload_service.py | 244 +++++++++++ api/services/human_input_service.py | 274 +++++++++++- .../workflow_event_snapshot_service.py | 81 ++-- api/services/workflow_service.py | 27 +- .../controllers/web/test_human_input_form.py | 239 +++++++++++ .../helpers/execution_extra_content.py | 1 + ..._sqlalchemy_api_workflow_run_repository.py | 2 +- ...hemy_execution_extra_content_repository.py | 134 +++++- .../test_human_input_delivery_test.py | 185 +++++++- .../test_human_input_file_upload_service.py | 78 ++++ ...message_service_execution_extra_content.py | 67 ++- .../test_message_service_extra_contents.py | 21 + .../test_workflow_event_snapshot_service.py | 174 ++++++++ .../app/test_workflow_pause_details_api.py | 3 +- .../service_api/app/test_hitl_service_api.py | 8 +- .../service_api/app/test_human_input_form.py | 117 +++++- .../web/test_human_input_file_upload.py | 217 ++++++++++ .../controllers/web/test_human_input_form.py | 160 ++++++- ...workflow_response_converter_human_input.py | 32 ++ .../app/apps/test_workflow_app_runner_core.py | 39 ++ .../app/apps/test_workflow_pause_events.py | 83 +++- .../test_entities_execution_extra_content.py | 5 +- .../test_human_input_repository.py | 67 +++ .../workflow/graph_engine/test_mock_nodes.py | 2 +- .../test_parallel_human_input_join_resume.py | 132 +++++- .../nodes/human_input/test_entities.py | 171 +++++++- .../test_human_input_form_filled_event.py | 114 +++-- .../test_form_input_serialization_compat.py | 338 +++++++++++++++ .../core/workflow/test_human_input_policy.py | 43 ++ .../core/workflow/test_node_factory.py | 45 ++ .../core/workflow/test_node_runtime.py | 69 ++- .../factories/test_build_from_mapping.py | 26 ++ .../unit_tests/libs/_human_input/support.py | 6 +- .../libs/_human_input/test_form_service.py | 7 +- .../libs/_human_input/test_models.py | 3 +- ...hemy_execution_extra_content_repository.py | 101 +++++ .../test_human_input_file_upload_service.py | 304 ++++++++++++++ .../services/test_human_input_service.py | 395 +++++++++++++++++- .../services/test_workflow_service.py | 21 +- .../test_workflow_event_snapshot_service.py | 59 ++- .../test_workflow_human_input_delivery.py | 24 +- .../hitl-form-file-upload-design.md | 184 ++++++++ eslint-suppressions.json | 67 +-- .../generated/api/console/apps/types.gen.ts | 5 + .../generated/api/console/apps/zod.gen.ts | 25 +- .../contracts/generated/api/web/orpc.gen.ts | 92 +++- .../contracts/generated/api/web/types.gen.ts | 41 ++ .../contracts/generated/api/web/zod.gen.ts | 31 ++ web/__mocks__/base-ui-select.tsx | 12 +- .../base/file-uploader-flow.test.tsx | 1 + .../form/[token]/__tests__/form.spec.tsx | 357 ++++++++++++++++ .../form/[token]/__tests__/page.spec.tsx | 19 + .../form/[token]/form-status-card.tsx | 51 +++ .../(humanInputLayout)/form/[token]/form.tsx | 262 ++---------- .../form/[token]/loaded-form-content.tsx | 98 +++++ .../(humanInputLayout)/form/[token]/page.tsx | 2 +- .../form/[token]/use-form-submit.ts | 33 ++ .../__tests__/type-select.spec.tsx | 33 +- .../config-var/config-modal/type-select.tsx | 9 +- .../app/text-generate/item/index.tsx | 3 +- .../app/text-generate/item/workflow-body.tsx | 3 +- .../__tests__/hooks.spec.tsx | 82 ++++ .../base/chat/chat-with-history/hooks.tsx | 40 +- .../base/chat/chat/__tests__/hooks.spec.tsx | 6 +- .../human-input-filled-form-list.spec.tsx | 18 +- .../chat/answer/__tests__/operation.spec.tsx | 7 + .../__tests__/content-item.spec.tsx | 108 ++++- .../__tests__/field-renderer.spec.tsx | 278 ++++++++++++ .../__tests__/human-input-form.spec.tsx | 254 ++++++++++- .../__tests__/submitted-content-item.spec.tsx | 181 ++++++++ .../__tests__/submitted-field-values.spec.tsx | 140 +++++++ .../__tests__/submitted.spec.tsx | 97 +++++ .../__tests__/utils.spec.ts | 334 +++++++++++++-- .../human-input-content/content-item.tsx | 16 +- .../human-input-content/field-renderer.tsx | 113 +++++ .../human-input-content/human-input-form.tsx | 19 +- .../submitted-content-item.tsx | 121 ++++++ .../submitted-field-values.tsx | 88 ++++ .../submitted-form-content.tsx | 34 ++ .../human-input-content/submitted-utils.ts | 15 + .../answer/human-input-content/submitted.tsx | 18 +- .../chat/answer/human-input-content/type.ts | 14 +- .../chat/answer/human-input-content/utils.ts | 147 ++++++- .../chat/answer/human-input-form-list.tsx | 3 +- .../base/chat/chat/answer/index.tsx | 3 +- .../base/chat/chat/answer/operation.tsx | 10 +- web/app/components/base/chat/chat/hooks.ts | 22 +- web/app/components/base/chat/chat/index.tsx | 3 +- web/app/components/base/chat/chat/type.ts | 28 +- .../__tests__/chat-wrapper.spec.tsx | 9 +- .../chat/embedded-chatbot/chat-wrapper.tsx | 3 +- .../file-uploader/__tests__/hooks.spec.ts | 74 +++- .../__tests__/index.spec.tsx | 23 + .../file-uploader-in-attachment/index.tsx | 31 +- .../components/base/file-uploader/hooks.ts | 55 ++- .../field/__tests__/file-uploader.spec.tsx | 1 + .../base/__tests__/field.spec.tsx | 1 + .../prompt-editor/__tests__/index.spec.tsx | 21 +- .../components/base/prompt-editor/index.tsx | 20 +- .../__tests__/component-ui.spec.tsx | 163 +++++++- .../__tests__/component.spec.tsx | 49 ++- .../hitl-input-block/__tests__/index.spec.tsx | 70 +++- .../__tests__/input-field.spec.tsx | 362 +++++++++++++++- .../hitl-input-block/__tests__/node.spec.tsx | 2 + .../plugins/hitl-input-block/component-ui.tsx | 106 +++-- .../plugins/hitl-input-block/component.tsx | 9 +- .../hitl-input-block-replacement-block.tsx | 48 ++- .../plugins/hitl-input-block/index.tsx | 11 +- .../plugins/hitl-input-block/input-field.tsx | 364 ++++++++++++---- .../plugins/hitl-input-block/node.tsx | 5 + .../plugins/hitl-input-block/pre-populate.tsx | 2 - .../__tests__/index.spec.tsx | 31 +- .../plugins/shortcuts-popup-plugin/index.tsx | 18 +- .../prompt-editor/prompt-editor-content.tsx | 9 +- .../workflow-stream-handlers.spec.ts | 2 + .../result/workflow-stream-handlers.ts | 11 +- .../variable/__tests__/utils.spec.ts | 56 ++- .../nodes/_base/components/variable/utils.ts | 18 +- .../__tests__/human-input.spec.tsx | 114 ++++- .../nodes/human-input/__tests__/node.spec.tsx | 2 +- .../human-input/__tests__/panel.spec.tsx | 42 +- .../nodes/human-input/__tests__/types.spec.ts | 73 ++++ .../__tests__/add-input-field.spec.tsx | 75 ++++ .../__tests__/form-content-preview.spec.tsx | 31 +- .../__tests__/form-content.spec.tsx | 76 +++- .../__tests__/single-run-form.spec.tsx | 165 +++++++- .../__tests__/variable-in-markdown.spec.tsx | 110 ++++- .../components/add-input-field.tsx | 3 + .../__tests__/method-item.spec.tsx | 2 +- .../__tests__/test-email-sender.spec.tsx | 219 +++++++++- .../recipient/__tests__/email-input.spec.tsx | 34 ++ .../__tests__/member-selector.spec.tsx | 43 +- .../delivery-method/recipient/email-input.tsx | 3 + .../delivery-method/test-email-sender.tsx | 64 ++- .../components/form-content-preview.tsx | 32 +- .../human-input/components/form-content.tsx | 106 +++-- .../components/single-run-form.tsx | 14 +- .../components/variable-in-markdown.tsx | 118 +++++- .../workflow/nodes/human-input/default.ts | 48 ++- .../hooks/__tests__/use-form-content.spec.ts | 26 +- .../use-single-run-form-params.spec.ts | 143 ++++++- .../human-input/hooks/use-form-content.ts | 7 + .../hooks/use-single-run-form-params.ts | 29 +- .../workflow/nodes/human-input/panel.tsx | 16 +- .../workflow/nodes/human-input/types.ts | 140 ++++++- .../workflow/nodes/human-input/utils.ts | 16 + .../human-input-filled-form-list.spec.tsx | 9 +- .../panel/__tests__/workflow-preview.spec.tsx | 7 +- .../__tests__/chat-wrapper.spec.tsx | 7 +- .../__tests__/components.spec.tsx | 7 +- .../debug-and-preview/__tests__/hooks.spec.ts | 4 +- .../__tests__/hooks/handle-resume.spec.ts | 6 +- .../__tests__/hooks/misc.spec.ts | 4 +- .../__tests__/hooks/sse-callbacks.spec.ts | 14 +- .../panel/debug-and-preview/chat-wrapper.tsx | 4 +- .../workflow/panel/debug-and-preview/hooks.ts | 25 +- .../workflow/panel/workflow-preview.tsx | 3 +- .../__tests__/value-content-sections.spec.tsx | 1 + web/i18n/en-US/workflow.json | 5 + web/i18n/zh-Hans/workflow.json | 5 + .../share-human-input-upload.spec.ts | 116 +++++ web/service/fetch.ts | 14 +- web/service/share.ts | 112 ++++- web/service/use-share.ts | 2 +- web/service/workflow.ts | 8 +- web/types/workflow.ts | 9 +- 188 files changed, 11094 insertions(+), 1158 deletions(-) create mode 100644 api/controllers/web/human_input_file_upload.py create mode 100644 api/migrations/versions/2026_06_03_1416-8d4c2a1b9f03_add_human_input_upload_tables.py create mode 100644 api/services/human_input_file_upload_service.py create mode 100644 api/tests/test_containers_integration_tests/controllers/web/test_human_input_form.py create mode 100644 api/tests/test_containers_integration_tests/services/test_human_input_file_upload_service.py create mode 100644 api/tests/test_containers_integration_tests/services/workflow/test_workflow_event_snapshot_service.py create mode 100644 api/tests/unit_tests/controllers/web/test_human_input_file_upload.py create mode 100644 api/tests/unit_tests/core/workflow/test_form_input_serialization_compat.py create mode 100644 api/tests/unit_tests/repositories/test_sqlalchemy_execution_extra_content_repository.py create mode 100644 api/tests/unit_tests/services/test_human_input_file_upload_service.py create mode 100644 docs/design/human-in-the-loop/hitl-form-file-upload-design.md create mode 100644 web/app/(humanInputLayout)/form/[token]/__tests__/form.spec.tsx create mode 100644 web/app/(humanInputLayout)/form/[token]/__tests__/page.spec.tsx create mode 100644 web/app/(humanInputLayout)/form/[token]/form-status-card.tsx create mode 100644 web/app/(humanInputLayout)/form/[token]/loaded-form-content.tsx create mode 100644 web/app/(humanInputLayout)/form/[token]/use-form-submit.ts create mode 100644 web/app/components/base/chat/chat/answer/human-input-content/__tests__/field-renderer.spec.tsx create mode 100644 web/app/components/base/chat/chat/answer/human-input-content/__tests__/submitted-content-item.spec.tsx create mode 100644 web/app/components/base/chat/chat/answer/human-input-content/__tests__/submitted-field-values.spec.tsx create mode 100644 web/app/components/base/chat/chat/answer/human-input-content/field-renderer.tsx create mode 100644 web/app/components/base/chat/chat/answer/human-input-content/submitted-content-item.tsx create mode 100644 web/app/components/base/chat/chat/answer/human-input-content/submitted-field-values.tsx create mode 100644 web/app/components/base/chat/chat/answer/human-input-content/submitted-form-content.tsx create mode 100644 web/app/components/base/chat/chat/answer/human-input-content/submitted-utils.ts create mode 100644 web/app/components/workflow/nodes/human-input/__tests__/types.spec.ts create mode 100644 web/app/components/workflow/nodes/human-input/components/__tests__/add-input-field.spec.tsx create mode 100644 web/service/__tests__/share-human-input-upload.spec.ts diff --git a/api/Dockerfile b/api/Dockerfile index 493b40af67..1823a8f6a3 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -17,7 +17,7 @@ FROM base AS packages RUN apt-get update \ && apt-get install -y --no-install-recommends \ # basic environment - g++ \ + git g++ \ # for building gmpy2 libmpfr-dev libmpc-dev diff --git a/api/controllers/common/human_input.py b/api/controllers/common/human_input.py index 98fe2ce67b..d9b8f8f9a3 100644 --- a/api/controllers/common/human_input.py +++ b/api/controllers/common/human_input.py @@ -1,10 +1,40 @@ import json -from pydantic import BaseModel, JsonValue +from pydantic import BaseModel, Field, JsonValue + +HUMAN_INPUT_FORM_INPUT_EXAMPLE = { + "decision": "approve", + "attachment": { + "transfer_method": "local_file", + "upload_file_id": "4e0d1b87-52f2-49f6-b8c6-95cd9c954b3e", + "type": "document", + }, + "attachments": [ + { + "transfer_method": "local_file", + "upload_file_id": "1a77f0df-c0e6-461c-987c-e72526f341ee", + "type": "document", + }, + { + "transfer_method": "remote_url", + "url": "https://example.com/report.pdf", + "type": "document", + }, + ], +} class HumanInputFormSubmitPayload(BaseModel): - inputs: dict[str, JsonValue] + inputs: dict[str, JsonValue] = Field( + description=( + "Submitted human input values keyed by output variable name. " + "Use a string for paragraph or select input values, a file mapping for file inputs, " + "and a list of file mappings for file-list inputs. Local file mappings use " + "`transfer_method=local_file` with `upload_file_id`; remote file mappings use " + "`transfer_method=remote_url` with `url` or `remote_url`." + ), + examples=[HUMAN_INPUT_FORM_INPUT_EXAMPLE], + ) action: str diff --git a/api/controllers/service_api/app/human_input_form.py b/api/controllers/service_api/app/human_input_form.py index 2b38a84b0e..87cdca4988 100644 --- a/api/controllers/service_api/app/human_input_form.py +++ b/api/controllers/service_api/app/human_input_form.py @@ -7,6 +7,7 @@ paused human input forms in workflow/chatflow runs. import json import logging +from collections.abc import Sequence from flask import Response from flask_restx import Resource @@ -18,6 +19,7 @@ from controllers.service_api import service_api_ns from controllers.service_api.wraps import FetchUserArg, WhereisUserArg, validate_app_token from core.workflow.human_input_policy import HumanInputSurface, is_recipient_type_allowed_for_surface from extensions.ext_database import db +from graphon.nodes.human_input.entities import FormInputConfig from libs.helper import to_timestamp from models.model import App, EndUser from services.human_input_service import Form, FormNotFoundError, HumanInputService @@ -28,11 +30,11 @@ logger = logging.getLogger(__name__) register_schema_models(service_api_ns, HumanInputFormSubmitPayload) -def _jsonify_form_definition(form: Form) -> Response: - definition_payload = form.get_definition().model_dump() +def _jsonify_form_definition(form: Form, *, inputs: Sequence[FormInputConfig] = ()) -> Response: + definition_payload = form.get_definition().model_dump(mode="json") payload = { "form_content": definition_payload["rendered_content"], - "inputs": definition_payload["inputs"], + "inputs": [form_input.model_dump(mode="json") for form_input in inputs], "resolved_default_values": stringify_form_default_values(definition_payload["default_values"]), "user_actions": definition_payload["user_actions"], "expiration_time": to_timestamp(form.expiration_time), @@ -75,7 +77,8 @@ class WorkflowHumanInputFormApi(Resource): _ensure_form_belongs_to_app(form, app_model) _ensure_form_is_allowed_for_service_api(form) service.ensure_form_active(form) - return _jsonify_form_definition(form) + inputs = service.resolve_form_inputs(form) + return _jsonify_form_definition(form, inputs=inputs) @service_api_ns.expect(service_api_ns.models[HumanInputFormSubmitPayload.__name__]) @service_api_ns.doc("submit_human_input_form") diff --git a/api/controllers/web/__init__.py b/api/controllers/web/__init__.py index cfa39e0dfd..d4b0829dea 100644 --- a/api/controllers/web/__init__.py +++ b/api/controllers/web/__init__.py @@ -23,6 +23,7 @@ from . import ( feature, files, forgot_password, + human_input_file_upload, human_input_form, login, message, @@ -46,6 +47,7 @@ __all__ = [ "feature", "files", "forgot_password", + "human_input_file_upload", "human_input_form", "login", "message", diff --git a/api/controllers/web/human_input_file_upload.py b/api/controllers/web/human_input_file_upload.py new file mode 100644 index 0000000000..cbb4a78529 --- /dev/null +++ b/api/controllers/web/human_input_file_upload.py @@ -0,0 +1,212 @@ +"""HITL human input form file uploads. + +This controller exposes a single public upload endpoint for both local files and +remote URLs. The caller always submits a multipart form: when a non-empty +``url`` field is present, the request follows the remote fetch flow; otherwise it +falls back to the local file upload flow. +""" + +import httpx +from flask import request +from flask_restx import Resource +from pydantic import BaseModel, ConfigDict, Field, HttpUrl +from sqlalchemy.orm import sessionmaker + +import services +from controllers.common import helpers +from controllers.common.errors import ( + BlockedFileExtensionError, + FileTooLargeError, + NoFileUploadedError, + RemoteFileUploadError, + TooManyFilesError, + UnsupportedFileTypeError, +) +from controllers.common.schema import register_schema_models +from controllers.web import web_ns +from core.helper import ssrf_proxy +from extensions.ext_database import db +from fields.file_fields import FileResponse, FileWithSignedUrl +from graphon.file import helpers as file_helpers +from libs.exception import BaseHTTPException +from repositories.factory import DifyAPIRepositoryFactory +from services.file_service import FileService +from services.human_input_file_upload_service import ( + HITL_UPLOAD_TOKEN_PREFIX, + HumanInputFileUploadService, + InvalidUploadTokenError, +) + + +class InvalidUploadTokenBadRequestError(BaseHTTPException): + error_code = "invalid_upload_token" + description = "Invalid upload token." + code = 400 + + +class InvalidUploadTokenUnauthorizedError(BaseHTTPException): + error_code = "invalid_upload_token" + description = "Upload token is required." + code = 401 + + +class InvalidUploadTokenForbiddenError(BaseHTTPException): + error_code = "invalid_upload_token" + description = "Upload token is invalid or expired." + code = 403 + + +class HumanInputFileUploadFormPayload(BaseModel): + """Parsed multipart form fields for HITL uploads.""" + + model_config = ConfigDict(extra="ignore") + + url: HttpUrl | None = Field(default=None, description="Remote file URL") + + +register_schema_models(web_ns, HumanInputFileUploadFormPayload, FileResponse, FileWithSignedUrl) + + +def _create_upload_service() -> HumanInputFileUploadService: + session_factory = sessionmaker(bind=db.engine) + workflow_run_repository = DifyAPIRepositoryFactory.create_api_workflow_run_repository(session_factory) + return HumanInputFileUploadService( + session_factory=session_factory, + workflow_run_repository=workflow_run_repository, + ) + + +def _extract_hitl_upload_token() -> str: + """Read HITL upload token from Authorization without invoking other bearer auth chains.""" + + authorization = request.headers.get("Authorization") + if authorization is None: + raise InvalidUploadTokenUnauthorizedError() + + parts = authorization.split() + if len(parts) != 2: + raise InvalidUploadTokenUnauthorizedError() + + scheme, token = parts + if scheme.lower() != "bearer": + raise InvalidUploadTokenBadRequestError() + if not token: + raise InvalidUploadTokenUnauthorizedError() + if not token.startswith(HITL_UPLOAD_TOKEN_PREFIX): + raise InvalidUploadTokenBadRequestError() + return token + + +def _validate_context(service: HumanInputFileUploadService, token: str): + try: + return service.validate_upload_token(token) + except InvalidUploadTokenError as exc: + raise InvalidUploadTokenForbiddenError() from exc + + +def _parse_local_upload_file(): + if "file" not in request.files: + raise NoFileUploadedError() + if len(request.files) > 1: + raise TooManyFilesError() + + file = request.files["file"] + if not file.filename: + from controllers.common.errors import FilenameNotExistsError + + raise FilenameNotExistsError() + + return file + + +def _parse_upload_form() -> HumanInputFileUploadFormPayload: + return HumanInputFileUploadFormPayload.model_validate(request.form.to_dict(flat=True)) + + +def _upload_local_file(context): + file = _parse_local_upload_file() + + try: + upload_file = FileService(db.engine).upload_file( + filename=file.filename or "", + content=file.read(), + mimetype=file.mimetype, + user=context.owner, + source=None, + ) + except services.errors.file.FileTooLargeError as file_too_large_error: + raise FileTooLargeError(file_too_large_error.description) + except services.errors.file.UnsupportedFileTypeError: + raise UnsupportedFileTypeError() + except services.errors.file.BlockedFileExtensionError as exc: + raise BlockedFileExtensionError() from exc + + response = FileResponse.model_validate(upload_file, from_attributes=True) + return upload_file.id, response + + +def _upload_remote_file(context, url: str): + try: + resp = ssrf_proxy.head(url=url) + if resp.status_code != httpx.codes.OK: + resp = ssrf_proxy.get(url=url, timeout=3, follow_redirects=True) + if resp.status_code != httpx.codes.OK: + raise RemoteFileUploadError(f"Failed to fetch file from {url}: {resp.text}") + except httpx.RequestError as exc: + raise RemoteFileUploadError(f"Failed to fetch file from {url}: {str(exc)}") + + file_info = helpers.guess_file_info_from_response(resp) + if not FileService.is_file_size_within_limit(extension=file_info.extension, file_size=file_info.size): + raise FileTooLargeError() + + content = resp.content if resp.request.method == "GET" else ssrf_proxy.get(url).content + + try: + upload_file = FileService(db.engine).upload_file( + filename=file_info.filename, + content=content, + mimetype=file_info.mimetype, + user=context.owner, + source_url=url, + ) + except services.errors.file.FileTooLargeError as file_too_large_error: + raise FileTooLargeError(file_too_large_error.description) + except services.errors.file.UnsupportedFileTypeError: + raise UnsupportedFileTypeError() + except services.errors.file.BlockedFileExtensionError as exc: + raise BlockedFileExtensionError() from exc + + response = FileWithSignedUrl( + id=upload_file.id, + name=upload_file.name, + size=upload_file.size, + extension=upload_file.extension, + url=file_helpers.get_signed_file_url(upload_file_id=upload_file.id), + mime_type=upload_file.mime_type, + created_by=upload_file.created_by, + created_at=int(upload_file.created_at.timestamp()), + ) + return upload_file.id, response + + +@web_ns.route("/human-input-forms/files") +@web_ns.response(201, "File uploaded successfully", web_ns.models[FileResponse.__name__]) +class HumanInputFileUploadApi(Resource): + def post(self): + """Upload one local file or remote URL file for a HITL human input form.""" + + token = _extract_hitl_upload_token() + upload_service = _create_upload_service() + context = _validate_context(upload_service, token) + form = _parse_upload_form() + + # The browser always submits multipart/form-data. A non-empty `url` + # switches the endpoint into the remote-fetch flow; otherwise the + # request must carry a local `file`. + if form.url is not None: + file_id, response = _upload_remote_file(context=context, url=str(form.url)) + else: + file_id, response = _upload_local_file(context=context) + + upload_service.record_upload_file(context=context, file_id=file_id) + return response.model_dump(mode="json"), 201 diff --git a/api/controllers/web/human_input_form.py b/api/controllers/web/human_input_form.py index 69297450c9..113c39d3dd 100644 --- a/api/controllers/web/human_input_form.py +++ b/api/controllers/web/human_input_form.py @@ -4,27 +4,42 @@ Web App Human Input Form APIs. import json import logging +from collections.abc import Sequence from typing import Any, NotRequired, TypedDict from flask import Response, request from flask_restx import Resource +from pydantic import BaseModel from sqlalchemy import select +from sqlalchemy.orm import sessionmaker from werkzeug.exceptions import Forbidden from configs import dify_config from controllers.common.human_input import HumanInputFormSubmitPayload, stringify_form_default_values +from controllers.common.schema import register_schema_models from controllers.web import web_ns from controllers.web.error import NotFoundError, WebFormRateLimitExceededError from controllers.web.site import serialize_app_site_payload from extensions.ext_database import db +from graphon.nodes.human_input.entities import FormInputConfig from libs.helper import RateLimiter, extract_remote_ip, to_timestamp from models.account import TenantStatus from models.model import App, Site +from repositories.factory import DifyAPIRepositoryFactory +from services.human_input_file_upload_service import HumanInputFileUploadService from services.human_input_service import Form, FormNotFoundError, HumanInputService logger = logging.getLogger(__name__) +class HumanInputUploadTokenResponse(BaseModel): + upload_token: str + expires_at: int + + +register_schema_models(web_ns, HumanInputUploadTokenResponse) + + _FORM_SUBMIT_RATE_LIMITER = RateLimiter( prefix="web_form_submit_rate_limit", max_attempts=dify_config.WEB_FORM_SUBMIT_RATE_LIMIT_MAX_ATTEMPTS, @@ -35,6 +50,20 @@ _FORM_ACCESS_RATE_LIMITER = RateLimiter( max_attempts=dify_config.WEB_FORM_SUBMIT_RATE_LIMIT_MAX_ATTEMPTS, time_window=dify_config.WEB_FORM_SUBMIT_RATE_LIMIT_WINDOW_SECONDS, ) +_FORM_UPLOAD_TOKEN_RATE_LIMITER = RateLimiter( + prefix="web_form_upload_token_rate_limit", + max_attempts=dify_config.WEB_FORM_SUBMIT_RATE_LIMIT_MAX_ATTEMPTS, + time_window=dify_config.WEB_FORM_SUBMIT_RATE_LIMIT_WINDOW_SECONDS, +) + + +def _create_upload_service() -> HumanInputFileUploadService: + session_factory = sessionmaker(bind=db.engine) + workflow_run_repository = DifyAPIRepositoryFactory.create_api_workflow_run_repository(session_factory) + return HumanInputFileUploadService( + session_factory=session_factory, + workflow_run_repository=workflow_run_repository, + ) class FormDefinitionPayload(TypedDict): @@ -46,12 +75,17 @@ class FormDefinitionPayload(TypedDict): site: NotRequired[dict] -def _jsonify_form_definition(form: Form, site_payload: dict | None = None) -> Response: +def _jsonify_form_definition( + form: Form, + *, + inputs: Sequence[FormInputConfig] = (), + site_payload: dict | None = None, +) -> Response: """Return the form payload (optionally with site) as a JSON response.""" - definition_payload = form.get_definition().model_dump() + definition_payload = form.get_definition().model_dump(mode="json") payload: FormDefinitionPayload = { "form_content": definition_payload["rendered_content"], - "inputs": definition_payload["inputs"], + "inputs": [i.model_dump(mode="json") for i in inputs], "resolved_default_values": stringify_form_default_values(definition_payload["default_values"]), "user_actions": definition_payload["user_actions"], "expiration_time": to_timestamp(form.expiration_time), @@ -61,6 +95,33 @@ def _jsonify_form_definition(form: Form, site_payload: dict | None = None) -> Re return Response(json.dumps(payload, ensure_ascii=False), mimetype="application/json") +@web_ns.route("/form/human_input//upload-token") +class HumanInputFormUploadTokenApi(Resource): + """API for issuing HITL upload tokens for active human input forms.""" + + def post(self, form_token: str): + """ + Issue an upload token for a human input form. + + POST /api/form/human_input//upload-token + """ + ip_address = extract_remote_ip(request) + if _FORM_UPLOAD_TOKEN_RATE_LIMITER.is_rate_limited(ip_address): + raise WebFormRateLimitExceededError() + _FORM_UPLOAD_TOKEN_RATE_LIMITER.increment_rate_limit(ip_address) + + try: + token = _create_upload_service().issue_upload_token(form_token) + except FormNotFoundError: + raise NotFoundError("Form not found") + + response = HumanInputUploadTokenResponse( + upload_token=token.upload_token, + expires_at=to_timestamp(token.expires_at), + ) + return response.model_dump(mode="json"), 200 + + @web_ns.route("/form/human_input/") class HumanInputFormApi(Resource): """API for getting and submitting human input forms via the web app.""" @@ -89,8 +150,13 @@ class HumanInputFormApi(Resource): service.ensure_form_active(form) app_model, site = _get_app_site_from_form(form) + inputs = service.resolve_form_inputs(form) - return _jsonify_form_definition(form, site_payload=serialize_app_site_payload(app_model, site, None)) + return _jsonify_form_definition( + form, + inputs=inputs, + site_payload=serialize_app_site_payload(app_model, site, None), + ) # def post(self, _app_model: App, _end_user: EndUser, form_token: str): def post(self, form_token: str): diff --git a/api/core/app/apps/common/workflow_response_converter.py b/api/core/app/apps/common/workflow_response_converter.py index 1d178fd428..502b1907ba 100644 --- a/api/core/app/apps/common/workflow_response_converter.py +++ b/api/core/app/apps/common/workflow_response_converter.py @@ -52,15 +52,11 @@ from core.tools.tool_manager import ToolManager from core.trigger.constants import TRIGGER_PLUGIN_NODE_TYPE from core.trigger.trigger_manager import TriggerManager from core.workflow.human_input_forms import load_form_tokens_by_form_id -from core.workflow.human_input_policy import HumanInputSurface, enrich_human_input_pause_reasons - -# Maps the entry surface a workflow was invoked from to the HITL surface that -# its resume tokens must be filtered for. Surfaces not in this map fall back to -# the general priority ordering (typically CONSOLE > BACKSTAGE). -_INVOKE_FROM_TO_HITL_SURFACE: Mapping[InvokeFrom, HumanInputSurface] = { - InvokeFrom.SERVICE_API: HumanInputSurface.SERVICE_API, - InvokeFrom.OPENAPI: HumanInputSurface.OPENAPI, -} +from core.workflow.human_input_policy import ( + HumanInputSurface, + enrich_human_input_pause_reasons, + resolve_human_input_pause_reason_inputs, +) from core.workflow.system_variables import SystemVariableKey, system_variables_to_mapping from core.workflow.workflow_entry import WorkflowEntry from extensions.ext_database import db @@ -83,6 +79,14 @@ from models.human_input import HumanInputForm from models.workflow import WorkflowRun from services.variable_truncator import BaseTruncator, DummyVariableTruncator, VariableTruncator +# Maps the entry surface a workflow was invoked from to the HITL surface that +# its resume tokens must be filtered for. Surfaces not in this map fall back to +# the general priority ordering (typically CONSOLE > BACKSTAGE). +_INVOKE_FROM_TO_HITL_SURFACE: Mapping[InvokeFrom, HumanInputSurface] = { + InvokeFrom.SERVICE_API: HumanInputSurface.SERVICE_API, + InvokeFrom.OPENAPI: HumanInputSurface.OPENAPI, +} + NodeExecutionId = NewType("NodeExecutionId", str) logger = logging.getLogger(__name__) @@ -327,8 +331,13 @@ class WorkflowResponseConverter: encoded_outputs = self._encode_outputs(event.outputs) or {} if self._application_generate_entity.invoke_from == InvokeFrom.SERVICE_API: encoded_outputs = {} - pause_reasons = [reason.model_dump(mode="json") for reason in event.reasons] - human_input_form_ids = [reason.form_id for reason in event.reasons if isinstance(reason, HumanInputRequired)] + variable_pool = graph_runtime_state.variable_pool + resolved_reasons = resolve_human_input_pause_reason_inputs( + event.reasons, + variable_pool=variable_pool, + ) + pause_reasons = [reason.model_dump(mode="json") for reason in resolved_reasons] + human_input_form_ids = [reason.form_id for reason in resolved_reasons if isinstance(reason, HumanInputRequired)] expiration_times_by_form_id: dict[str, datetime] = {} display_in_ui_by_form_id: dict[str, bool] = {} form_token_by_form_id: dict[str, str] = {} @@ -365,7 +374,7 @@ class WorkflowResponseConverter: responses: list[StreamResponse] = [] - for reason in event.reasons: + for reason in resolved_reasons: if isinstance(reason, HumanInputRequired): expiration_time = expiration_times_by_form_id.get(reason.form_id) if expiration_time is None: @@ -413,17 +422,19 @@ class WorkflowResponseConverter: self, *, event: QueueHumanInputFormFilledEvent, task_id: str ) -> HumanInputFormFilledResponse: run_id = self._ensure_workflow_run_id() - return HumanInputFormFilledResponse( - task_id=task_id, - workflow_run_id=run_id, - data=HumanInputFormFilledResponse.Data( - node_id=event.node_id, - node_title=event.node_title, - rendered_content=event.rendered_content, - action_id=event.action_id, - action_text=event.action_text, - ), + data = HumanInputFormFilledResponse.Data( + node_id=event.node_id, + node_title=event.node_title, + rendered_content=event.rendered_content, + action_id=event.action_id, + action_text=event.action_text, ) + if event.submitted_data is not None: + runtime_type_converter = WorkflowRuntimeTypeConverter() + + data.submitted_data = runtime_type_converter.value_to_json_encodable_recursive(event.submitted_data) + + return HumanInputFormFilledResponse(task_id=task_id, workflow_run_id=run_id, data=data) def human_input_form_timeout_to_stream_response( self, *, event: QueueHumanInputFormTimeoutEvent, task_id: str diff --git a/api/core/app/apps/workflow_app_runner.py b/api/core/app/apps/workflow_app_runner.py index 84e9573416..c7af606419 100644 --- a/api/core/app/apps/workflow_app_runner.py +++ b/api/core/app/apps/workflow_app_runner.py @@ -435,6 +435,7 @@ class WorkflowBasedAppRunner: rendered_content=event.rendered_content, action_id=event.action_id, action_text=event.action_text, + submitted_data=event.submitted_data, ) ) case NodeRunHumanInputFormTimeoutEvent(): diff --git a/api/core/app/entities/queue_entities.py b/api/core/app/entities/queue_entities.py index 221b7fb058..a0e7881ede 100644 --- a/api/core/app/entities/queue_entities.py +++ b/api/core/app/entities/queue_entities.py @@ -11,6 +11,7 @@ from graphon.entities import WorkflowStartReason from graphon.entities.pause_reason import PauseReason from graphon.enums import NodeType, WorkflowNodeExecutionMetadataKey from graphon.model_runtime.entities.llm_entities import LLMResult, LLMResultChunk +from graphon.variables.segments import Segment class QueueEvent(StrEnum): @@ -508,6 +509,10 @@ class QueueHumanInputFormFilledEvent(AppQueueEvent): action_id: str action_text: str + # Keep the field name aligned with Graphon so the app-layer bridge does not + # need to translate between two equivalent payload names. + submitted_data: Mapping[str, Segment] | None = None + class QueueHumanInputFormTimeoutEvent(AppQueueEvent): """ diff --git a/api/core/app/entities/task_entities.py b/api/core/app/entities/task_entities.py index 3a33899bdf..defec9f946 100644 --- a/api/core/app/entities/task_entities.py +++ b/api/core/app/entities/task_entities.py @@ -342,6 +342,8 @@ class HumanInputFormFilledResponse(StreamResponse): action_id: str action_text: str + submitted_data: Mapping[str, Any] | None = None + event: StreamEvent = StreamEvent.HUMAN_INPUT_FORM_FILLED workflow_run_id: str data: Data diff --git a/api/core/entities/execution_extra_content.py b/api/core/entities/execution_extra_content.py index f11c670069..43252ccb2c 100644 --- a/api/core/entities/execution_extra_content.py +++ b/api/core/entities/execution_extra_content.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Mapping, Sequence from typing import Any, TypeAlias -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, JsonValue from graphon.nodes.human_input.entities import FormInputConfig, UserActionConfig from models.execution_extra_content import ExecutionContentType @@ -19,6 +19,8 @@ class HumanInputFormDefinition(BaseModel): inputs: Sequence[FormInputConfig] = Field(default_factory=list) actions: Sequence[UserActionConfig] = Field(default_factory=list) display_in_ui: bool = False + + # `form_token` is `None` if the corresponding form has been submitted. form_token: str | None = None resolved_default_values: Mapping[str, Any] = Field(default_factory=dict) expiration_time: int @@ -29,16 +31,31 @@ class HumanInputFormSubmissionData(BaseModel): node_id: str node_title: str + + # deprecate: the rendered_content is deprecated and only for historical reasons. rendered_content: str + + # The identifier of action user has chosen. action_id: str + # The button text of the action user has chosen. action_text: str + # submitted_data records the submitted form data. + # Keys correspond to `output_variable_name` of HumanInput inputs. + # Values are serialized JSON forms of runtime values, including file dictionaries. + # + # For form submitted before this field is introduced, this field is populated from + # the stored submission data. + submitted_data: Mapping[str, JsonValue] | None = None + class HumanInputContent(BaseModel): model_config = ConfigDict(frozen=True) workflow_run_id: str submitted: bool + # Both the form_defintion and the form_submission_data are present in + # HumanInputContent. For historical records, the form_definition: HumanInputFormDefinition | None = None form_submission_data: HumanInputFormSubmissionData | None = None type: ExecutionContentType = Field(default=ExecutionContentType.HUMAN_INPUT) diff --git a/api/core/workflow/human_input_policy.py b/api/core/workflow/human_input_policy.py index 8d231e2437..e95d753ae9 100644 --- a/api/core/workflow/human_input_policy.py +++ b/api/core/workflow/human_input_policy.py @@ -4,7 +4,11 @@ from collections.abc import Mapping, Sequence from enum import StrEnum from typing import Any -from graphon.entities.pause_reason import PauseReasonType +from graphon.entities.pause_reason import HumanInputRequired, PauseReason, PauseReasonType +from graphon.nodes.human_input.entities import FormInputConfig, SelectInputConfig +from graphon.nodes.human_input.enums import ValueSourceType +from graphon.runtime.graph_runtime_state_protocol import ReadOnlyVariablePool +from graphon.variables import ArrayStringSegment from models.human_input import RecipientType @@ -73,3 +77,69 @@ def enrich_human_input_pause_reasons( updated["expiration_time"] = expiration_time enriched.append(updated) return enriched + + +def resolve_variable_select_input_options( + inputs: Sequence[FormInputConfig], + *, + variable_pool: ReadOnlyVariablePool | None, +) -> list[FormInputConfig]: + """Resolve variable-backed select options to runtime values.""" + + # This function replace the SelectInputConfig.option_source.value + # field with acutial runtime values when option_source.type is VARIABLE. + # + # This is a dirty hacks. However it does reduces the logic leaked to callers of + # the api. + resolved_inputs: list[FormInputConfig] = [] + + if variable_pool is None: + return list(inputs) + + for form_input in inputs: + if not isinstance(form_input, SelectInputConfig): + resolved_inputs.append(form_input) + continue + + option_source = form_input.option_source + if option_source.type != ValueSourceType.VARIABLE: + resolved_inputs.append(form_input) + continue + + option_values = variable_pool.get(option_source.selector) + if option_values is None: + resolved_inputs.append(form_input) + continue + if not isinstance(option_values, ArrayStringSegment): + raise TypeError(f"expected ArrayStringSegment, got {type(option_values)}") + + updated_option_source = option_source.model_copy(update={"value": option_values.value}) + # Ensure frontend receives concrete select options instead of unresolved selectors. + resolved_inputs.append( + form_input.model_copy( + update={"option_source": updated_option_source}, + ) + ) + return resolved_inputs + + +def resolve_human_input_pause_reason_inputs( + reasons: Sequence[PauseReason], + *, + variable_pool: ReadOnlyVariablePool | None, +) -> list[PauseReason]: + if variable_pool is None: + return list(reasons) + + resolved_reasons: list[PauseReason] = [] + for reason in reasons: + if not isinstance(reason, HumanInputRequired): + resolved_reasons.append(reason) + continue + + resolved_inputs = resolve_variable_select_input_options( + reason.inputs, + variable_pool=variable_pool, + ) + resolved_reasons.append(reason.model_copy(update={"inputs": resolved_inputs})) + return resolved_reasons diff --git a/api/core/workflow/node_runtime.py b/api/core/workflow/node_runtime.py index 7ea03a4a33..be14aa92ec 100644 --- a/api/core/workflow/node_runtime.py +++ b/api/core/workflow/node_runtime.py @@ -35,7 +35,7 @@ from core.tools.utils.message_transformer import ToolFileMessageTransformer from core.workflow.file_reference import build_file_reference from extensions.ext_database import db from factories import file_factory -from graphon.file import FileTransferMethod, FileType +from graphon.file import File, FileTransferMethod, FileType from graphon.model_runtime.entities import LLMMode from graphon.model_runtime.entities.llm_entities import ( LLMResult, @@ -47,7 +47,12 @@ from graphon.model_runtime.entities.llm_entities import ( from graphon.model_runtime.entities.message_entities import PromptMessage, PromptMessageTool from graphon.model_runtime.entities.model_entities import AIModelEntity from graphon.model_runtime.model_providers.base.large_language_model import LargeLanguageModel -from graphon.nodes.human_input.entities import HumanInputNodeData +from graphon.nodes.human_input.entities import ( + FileInputConfig, + FileListInputConfig, + FormInputConfig, + HumanInputNodeData, +) from graphon.nodes.llm.runtime_protocols import ( LLMProtocol, PromptMessageSerializerProtocol, @@ -83,7 +88,6 @@ from .system_variables import SystemVariableKey, get_system_text if TYPE_CHECKING: from core.tools.__base.tool import Tool from core.tools.entities.tool_entities import ToolInvokeMessage as CoreToolInvokeMessage - from graphon.file import File from graphon.nodes.llm.file_saver import LLMFileSaver from graphon.nodes.tool.entities import ToolNodeData @@ -682,6 +686,7 @@ class DifyHumanInputNodeRuntime(HumanInputNodeRuntimeProtocol): self._run_context = resolve_dify_run_context(run_context) self._workflow_execution_id_getter = workflow_execution_id_getter self._form_repository = form_repository + self._file_reference_factory = DifyFileReferenceFactory(self._run_context) def _invoke_source(self) -> str: invoke_from = self._run_context.invoke_from @@ -735,6 +740,23 @@ class DifyHumanInputNodeRuntime(HumanInputNodeRuntimeProtocol): repo = self.build_form_repository() return repo.get_form(node_id) + def restore_submitted_data( + self, + *, + node_data: HumanInputNodeData, + submitted_data: Mapping[str, Any], + ) -> Mapping[str, Any]: + restored_data: dict[str, Any] = dict(submitted_data) + for input_config in node_data.inputs: + output_variable_name = input_config.output_variable_name + if output_variable_name not in submitted_data: + continue + restored_data[output_variable_name] = self._restore_submitted_value( + input_config=input_config, + value=submitted_data[output_variable_name], + ) + return restored_data + def create_form( self, *, @@ -755,6 +777,55 @@ class DifyHumanInputNodeRuntime(HumanInputNodeRuntimeProtocol): ) return repo.create_form(params) + def _restore_submitted_value( + self, + *, + input_config: FormInputConfig, + value: Any, + ) -> Any: + if isinstance(input_config, FileInputConfig): + return self._restore_submitted_file_value( + output_variable_name=input_config.output_variable_name, + value=value, + ) + if isinstance(input_config, FileListInputConfig): + return self._restore_submitted_file_list_value( + output_variable_name=input_config.output_variable_name, + value=value, + ) + return value + + def _restore_submitted_file_value( + self, + *, + output_variable_name: str, + value: Any, + ) -> Any: + if not isinstance(value, Mapping): + msg = ( + "HumanInput file submission must be persisted as a mapping, " + f"output_variable_name={output_variable_name}" + ) + raise ValueError(msg) + return self._file_reference_factory.build_from_mapping(mapping=value) + + def _restore_submitted_file_list_value( + self, + *, + output_variable_name: str, + value: Any, + ) -> list[Any]: + if not isinstance(value, list): + msg = ( + "HumanInput file-list submission must be persisted as a list, " + f"output_variable_name={output_variable_name}" + ) + raise ValueError(msg) + if any(not isinstance(item, Mapping) for item in value): + msg = f"HumanInput file-list submission must contain mappings, output_variable_name={output_variable_name}" + raise ValueError(msg) + return [self._file_reference_factory.build_from_mapping(mapping=item) for item in value] + def build_dify_llm_file_saver( *, diff --git a/api/factories/file_factory/builders.py b/api/factories/file_factory/builders.py index 4fb976f0e7..7026af23b8 100644 --- a/api/factories/file_factory/builders.py +++ b/api/factories/file_factory/builders.py @@ -5,7 +5,7 @@ from __future__ import annotations import mimetypes import uuid from collections.abc import Mapping, Sequence -from typing import Any +from typing import Any, Literal, NotRequired, TypedDict, assert_never, cast from sqlalchemy import select @@ -19,10 +19,58 @@ from .common import resolve_mapping_file_id from .remote import get_remote_file_info from .validation import is_file_valid_with_config +type FileTypeValue = FileType | Literal["image", "document", "audio", "video", "custom"] + +type _LocalFileTransferMethod = Literal["local_file", FileTransferMethod.LOCAL_FILE] +type _RemoteUrlTransferMethod = Literal["remote_url", FileTransferMethod.REMOTE_URL] +type _ToolFileTransferMethod = Literal["tool_file", FileTransferMethod.TOOL_FILE] +type _DatasourceFileTransferMethod = Literal["datasource_file", FileTransferMethod.DATASOURCE_FILE] + + +class LocalFileMapping(TypedDict): + transfer_method: _LocalFileTransferMethod + id: NotRequired[str | None] # Read as the graph-layer File.file_id. + type: NotRequired[FileTypeValue | None] # Read for type override and upload config validation. + upload_file_id: NotRequired[str | None] # File id lookup priority 1. + reference: NotRequired[str | None] # File id lookup priority 2; may be an opaque file reference. + related_id: NotRequired[str | None] # File id lookup priority 3; legacy persisted field. + + +class RemoteUrlMapping(TypedDict): + transfer_method: _RemoteUrlTransferMethod + id: NotRequired[str | None] # Read as the graph-layer File.file_id. + type: NotRequired[FileTypeValue | None] # Read for type override and upload config validation. + upload_file_id: NotRequired[str | None] # Persisted UploadFile lookup priority 1. + reference: NotRequired[str | None] # Persisted UploadFile lookup priority 2; may be an opaque file reference. + related_id: NotRequired[str | None] # Persisted UploadFile lookup priority 3; legacy persisted field. + url: NotRequired[str | None] # External URL lookup priority 1 when no UploadFile id is resolved. + remote_url: NotRequired[str | None] # External URL lookup priority 2 when no UploadFile id is resolved. + + +class ToolFileMapping(TypedDict): + transfer_method: _ToolFileTransferMethod + id: NotRequired[str | None] # Read as the graph-layer File.file_id. + type: NotRequired[FileTypeValue | None] # Read for type override and upload config validation. + tool_file_id: NotRequired[str | None] # ToolFile lookup priority 1. + reference: NotRequired[str | None] # ToolFile lookup priority 2; may be an opaque file reference. + related_id: NotRequired[str | None] # ToolFile lookup priority 3; legacy persisted field. + + +class DatasourceFileMapping(TypedDict): + transfer_method: _DatasourceFileTransferMethod + type: NotRequired[FileTypeValue | None] # Read for type override and upload config validation. + datasource_file_id: NotRequired[str | None] # UploadFile lookup priority 1 for datasource-backed files. + reference: NotRequired[str | None] # UploadFile lookup priority 2; may be an opaque file reference. + related_id: NotRequired[str | None] # UploadFile lookup priority 3; legacy persisted field. + + +type FileMapping = LocalFileMapping | RemoteUrlMapping | ToolFileMapping | DatasourceFileMapping +type FileMappingInput = FileMapping | Mapping[str, Any] + def build_from_mapping( *, - mapping: Mapping[str, Any], + mapping: FileMappingInput, tenant_id: str, config: FileUploadConfig | None = None, strict_type_validation: bool = False, @@ -32,18 +80,45 @@ def build_from_mapping( if not transfer_method_value: raise ValueError("transfer_method is required in file mapping") - transfer_method = FileTransferMethod.value_of(transfer_method_value) - build_func = _get_build_function(transfer_method) - file = build_func( - mapping=mapping, - tenant_id=tenant_id, - transfer_method=transfer_method, - strict_type_validation=strict_type_validation, - access_controller=access_controller, - ) + transfer_method = FileTransferMethod.value_of(str(transfer_method_value)) + match transfer_method: + case FileTransferMethod.LOCAL_FILE: + file = _build_from_local_file( + mapping=cast(LocalFileMapping, mapping), + tenant_id=tenant_id, + transfer_method=transfer_method, + strict_type_validation=strict_type_validation, + access_controller=access_controller, + ) + case FileTransferMethod.REMOTE_URL: + file = _build_from_remote_url( + mapping=cast(RemoteUrlMapping, mapping), + tenant_id=tenant_id, + transfer_method=transfer_method, + strict_type_validation=strict_type_validation, + access_controller=access_controller, + ) + case FileTransferMethod.TOOL_FILE: + file = _build_from_tool_file( + mapping=cast(ToolFileMapping, mapping), + tenant_id=tenant_id, + transfer_method=transfer_method, + strict_type_validation=strict_type_validation, + access_controller=access_controller, + ) + case FileTransferMethod.DATASOURCE_FILE: + file = _build_from_datasource_file( + mapping=cast(DatasourceFileMapping, mapping), + tenant_id=tenant_id, + transfer_method=transfer_method, + strict_type_validation=strict_type_validation, + access_controller=access_controller, + ) + case _: + assert_never(transfer_method) if config and not is_file_valid_with_config( - input_file_type=mapping.get("type", FileType.CUSTOM), + input_file_type=mapping.get("type") or FileType.CUSTOM, file_extension=file.extension or "", file_transfer_method=file.transfer_method, config=config, @@ -87,36 +162,33 @@ def build_from_mappings( return files -def _get_build_function(transfer_method: FileTransferMethod): - build_functions = { - FileTransferMethod.LOCAL_FILE: _build_from_local_file, - FileTransferMethod.REMOTE_URL: _build_from_remote_url, - FileTransferMethod.TOOL_FILE: _build_from_tool_file, - FileTransferMethod.DATASOURCE_FILE: _build_from_datasource_file, - } - build_func = build_functions.get(transfer_method) - if build_func is None: - raise ValueError(f"Invalid file transfer method: {transfer_method}") - return build_func - - def _resolve_file_type( *, detected_file_type: FileType, - specified_type: str | None, + specified_type: FileTypeValue | str | None, strict_type_validation: bool, ) -> FileType: - if strict_type_validation and specified_type and detected_file_type.value != specified_type: + """Resolve the graph file type from detected metadata and submitted form type. + + ``custom`` is a configured extension bucket rather than a MIME-derived type, + so strict validation must leave extension checks to the upload config. + """ + if not specified_type: + return detected_file_type + + specified_file_type = FileType(specified_type) + if specified_file_type == FileType.CUSTOM: + return FileType.CUSTOM + + if strict_type_validation and detected_file_type != specified_file_type: raise ValueError("Detected file type does not match the specified type. Please verify the file.") - if specified_type and specified_type != "custom": - return FileType(specified_type) - return detected_file_type + return specified_file_type def _build_from_local_file( *, - mapping: Mapping[str, Any], + mapping: LocalFileMapping, tenant_id: str, transfer_method: FileTransferMethod, strict_type_validation: bool = False, @@ -143,7 +215,7 @@ def _build_from_local_file( detected_file_type = standardize_file_type(extension="." + row.extension, mime_type=row.mime_type) file_type = _resolve_file_type( detected_file_type=detected_file_type, - specified_type=mapping.get("type", "custom"), + specified_type=mapping.get("type"), strict_type_validation=strict_type_validation, ) @@ -163,7 +235,7 @@ def _build_from_local_file( def _build_from_remote_url( *, - mapping: Mapping[str, Any], + mapping: RemoteUrlMapping, tenant_id: str, transfer_method: FileTransferMethod, strict_type_validation: bool = False, @@ -235,7 +307,7 @@ def _build_from_remote_url( def _build_from_tool_file( *, - mapping: Mapping[str, Any], + mapping: ToolFileMapping, tenant_id: str, transfer_method: FileTransferMethod, strict_type_validation: bool = False, @@ -278,7 +350,7 @@ def _build_from_tool_file( def _build_from_datasource_file( *, - mapping: Mapping[str, Any], + mapping: DatasourceFileMapping, tenant_id: str, transfer_method: FileTransferMethod, strict_type_validation: bool = False, diff --git a/api/migrations/versions/2026_06_03_1416-8d4c2a1b9f03_add_human_input_upload_tables.py b/api/migrations/versions/2026_06_03_1416-8d4c2a1b9f03_add_human_input_upload_tables.py new file mode 100644 index 0000000000..416f4a28e2 --- /dev/null +++ b/api/migrations/versions/2026_06_03_1416-8d4c2a1b9f03_add_human_input_upload_tables.py @@ -0,0 +1,64 @@ +"""Add human input upload token and file association tables + +Revision ID: 8d4c2a1b9f03 +Revises: 121e7346074d +Create Date: 2026-06-03 14:16:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op + +import models + +# revision identifiers, used by Alembic. +revision = "8d4c2a1b9f03" +down_revision = "121e7346074d" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "human_input_form_upload_tokens", + sa.Column("id", models.types.StringUUID(), nullable=False), + sa.Column("created_at", sa.DateTime(), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False), + sa.Column("tenant_id", models.types.StringUUID(), nullable=False), + sa.Column("app_id", models.types.StringUUID(), nullable=False), + sa.Column("form_id", models.types.StringUUID(), nullable=False), + sa.Column("recipient_id", models.types.StringUUID(), nullable=False), + sa.Column("token", sa.String(length=255), nullable=False), + sa.PrimaryKeyConstraint("id", name="human_input_form_upload_tokens_pkey"), + sa.UniqueConstraint("token", name="human_input_form_upload_tokens_token_key"), + ) + with op.batch_alter_table("human_input_form_upload_tokens", schema=None) as batch_op: + batch_op.create_index("human_input_form_upload_tokens_form_id_idx", ["form_id"], unique=False) + + op.create_table( + "human_input_form_upload_files", + sa.Column("id", models.types.StringUUID(), nullable=False), + sa.Column("created_at", sa.DateTime(), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False), + sa.Column("tenant_id", models.types.StringUUID(), nullable=False), + sa.Column("app_id", models.types.StringUUID(), nullable=False), + sa.Column("form_id", models.types.StringUUID(), nullable=False), + sa.Column("upload_file_id", models.types.StringUUID(), nullable=False), + sa.Column("upload_token_id", models.types.StringUUID(), nullable=False), + sa.PrimaryKeyConstraint("id", name="human_input_form_upload_files_pkey"), + sa.UniqueConstraint("upload_file_id", name="human_input_form_upload_files_upload_file_id_key"), + ) + with op.batch_alter_table("human_input_form_upload_files", schema=None) as batch_op: + batch_op.create_index("human_input_form_upload_files_form_id_idx", ["form_id"], unique=False) + batch_op.create_index("human_input_form_upload_files_upload_token_id_idx", ["upload_token_id"], unique=False) + + +def downgrade(): + with op.batch_alter_table("human_input_form_upload_files", schema=None) as batch_op: + batch_op.drop_index("human_input_form_upload_files_upload_token_id_idx") + batch_op.drop_index("human_input_form_upload_files_form_id_idx") + op.drop_table("human_input_form_upload_files") + + with op.batch_alter_table("human_input_form_upload_tokens", schema=None) as batch_op: + batch_op.drop_index("human_input_form_upload_tokens_form_id_idx") + op.drop_table("human_input_form_upload_tokens") diff --git a/api/models/__init__.py b/api/models/__init__.py index c1c4a17f41..44f3a6231b 100644 --- a/api/models/__init__.py +++ b/api/models/__init__.py @@ -60,7 +60,7 @@ from .enums import ( WorkflowTriggerStatus, ) from .execution_extra_content import ExecutionExtraContent, HumanInputContent -from .human_input import HumanInputForm +from .human_input import HumanInputForm, HumanInputFormUploadFile, HumanInputFormUploadToken from .model import ( AccountTrialAppRecord, ApiRequest, @@ -202,6 +202,8 @@ __all__ = [ "ExternalKnowledgeBindings", "HumanInputContent", "HumanInputForm", + "HumanInputFormUploadFile", + "HumanInputFormUploadToken", "IconType", "InstalledApp", "InvitationCode", diff --git a/api/models/human_input.py b/api/models/human_input.py index 7447d3efcb..7b02e8d29d 100644 --- a/api/models/human_input.py +++ b/api/models/human_input.py @@ -251,3 +251,55 @@ class HumanInputFormRecipient(DefaultFieldsMixin, Base): access_token=_generate_token(), ) return recipient_model + + +class HumanInputFormUploadToken(DefaultFieldsMixin, Base): + """Upload authorization token bound to one human input form recipient. + + HITL upload tokens are intentionally separate from app/service bearer tokens. + The token is stored as an opaque random value so upload endpoints can perform + a direct lookup without entering the normal Web App authentication chain. + Upload ownership is resolved from the form's workflow run initiator instead + of being persisted on the token row itself. + """ + + __tablename__ = "human_input_form_upload_tokens" + __table_args__ = ( + sa.UniqueConstraint("token", name="human_input_form_upload_tokens_token_key"), + sa.Index("human_input_form_upload_tokens_form_id_idx", "form_id"), + ) + + tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + app_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + form_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + recipient_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + token: Mapped[str] = mapped_column(sa.String(255), nullable=False) + + form: Mapped[HumanInputForm] = relationship( + "HumanInputForm", + uselist=False, + foreign_keys=[form_id], + primaryjoin="foreign(HumanInputFormUploadToken.form_id) == HumanInputForm.id", + lazy="raise", + ) + + +class HumanInputFormUploadFile(DefaultFieldsMixin, Base): + """Association between a human input form and a file uploaded through its token. + + Ownership remains on ``UploadFile`` itself; this table only records the + durable form/token/file linkage needed by Human Input flows. + """ + + __tablename__ = "human_input_form_upload_files" + __table_args__ = ( + sa.UniqueConstraint("upload_file_id", name="human_input_form_upload_files_upload_file_id_key"), + sa.Index("human_input_form_upload_files_form_id_idx", "form_id"), + sa.Index("human_input_form_upload_files_upload_token_id_idx", "upload_token_id"), + ) + + tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + app_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + form_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + upload_file_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + upload_token_id: Mapped[str] = mapped_column(StringUUID, nullable=False) diff --git a/api/openapi/markdown/console-swagger.md b/api/openapi/markdown/console-swagger.md index 5324ab2a14..555b063e59 100644 --- a/api/openapi/markdown/console-swagger.md +++ b/api/openapi/markdown/console-swagger.md @@ -13979,6 +13979,7 @@ Request payload for bulk downloading documents as a zip archive. | node_id | string | | Yes | | node_title | string | | Yes | | rendered_content | string | | Yes | +| submitted_data | object | | No | #### HumanInputFormSubmitPayload diff --git a/api/openapi/markdown/openapi-swagger.md b/api/openapi/markdown/openapi-swagger.md index 899e09ff4a..181d867f4f 100644 --- a/api/openapi/markdown/openapi-swagger.md +++ b/api/openapi/markdown/openapi-swagger.md @@ -597,7 +597,7 @@ mode is a closed enum. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | action | string | | Yes | -| inputs | object | | Yes | +| inputs | object | Submitted human input values keyed by output variable name. Use a string for paragraph or select input values, a file mapping for file inputs, and a list of file mappings for file-list inputs. Local file mappings use `transfer_method=local_file` with `upload_file_id`; remote file mappings use `transfer_method=remote_url` with `url` or `remote_url`. | Yes | #### JsonValue diff --git a/api/openapi/markdown/service-swagger.md b/api/openapi/markdown/service-swagger.md index 5bdb526445..3de701f3f3 100644 --- a/api/openapi/markdown/service-swagger.md +++ b/api/openapi/markdown/service-swagger.md @@ -2923,7 +2923,7 @@ Note: The SQLAlchemy model defines an `is_anonymous` property for Flask-Login se | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | action | string | | Yes | -| inputs | object | | Yes | +| inputs | object | Submitted human input values keyed by output variable name. Use a string for paragraph or select input values, a file mapping for file inputs, and a list of file mappings for file-list inputs. Local file mappings use `transfer_method=local_file` with `upload_file_id`; remote file mappings use `transfer_method=remote_url` with `url` or `remote_url`. | Yes | #### IndexInfoResponse diff --git a/api/openapi/markdown/web-swagger.md b/api/openapi/markdown/web-swagger.md index a7585d5b14..eafccdc04b 100644 --- a/api/openapi/markdown/web-swagger.md +++ b/api/openapi/markdown/web-swagger.md @@ -461,6 +461,42 @@ Request body: | ---- | ----------- | | 200 | Success | +### /form/human_input/{form_token}/upload-token + +#### POST +##### Summary + +Issue an upload token for a human input form + +##### Description + +POST /api/form/human_input//upload-token + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| form_token | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /human-input-forms/files + +#### POST +##### Summary + +Upload one local file or remote URL file for a HITL human input form + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 201 | File uploaded successfully | [FileResponse](#fileresponse) | + ### /login #### POST @@ -1188,6 +1224,21 @@ Returns Server-Sent Events stream. | email | string | | Yes | | language | string | | No | +#### HumanInputFileUploadFormPayload + +Parsed multipart form fields for HITL uploads. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| url | string (uri) | Remote file URL | No | + +#### HumanInputUploadTokenResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| expires_at | integer | | Yes | +| upload_token | string | | Yes | + #### LicenseLimitationModel - enabled: whether this limit is enforced diff --git a/api/repositories/sqlalchemy_execution_extra_content_repository.py b/api/repositories/sqlalchemy_execution_extra_content_repository.py index 67f8795d3f..65a1edbf2d 100644 --- a/api/repositories/sqlalchemy_execution_extra_content_repository.py +++ b/api/repositories/sqlalchemy_execution_extra_content_repository.py @@ -117,7 +117,7 @@ class SQLAlchemyExecutionExtraContentRepository(ExecutionExtraContentRepository) definition_payload["expiration_time"] = form.expiration_time form_definition = FormDefinition.model_validate(definition_payload) except ValueError: - logger.warning("Failed to load form definition for HumanInputContent(id=%s)", model.id) + logger.warning("Failed to load form definition for HumanInputContent(id=%s)", model.id, exc_info=True) return None node_title = form_definition.node_title or form.node_id display_in_ui = bool(form_definition.display_in_ui) @@ -125,21 +125,26 @@ class SQLAlchemyExecutionExtraContentRepository(ExecutionExtraContentRepository) submitted = form.submitted_at is not None or form.status == HumanInputFormStatus.SUBMITTED if not submitted: form_token = self._resolve_form_token(recipients_by_form_id.get(form.id, [])) + else: + form_token = None + form_definition_domain_model = HumanInputFormDefinition( + form_id=form.id, + node_id=form.node_id, + node_title=node_title, + form_content=form.rendered_content, + inputs=form_definition.inputs, + actions=form_definition.user_actions, + display_in_ui=display_in_ui, + form_token=form_token, + resolved_default_values=form_definition.default_values, + expiration_time=int(form.expiration_time.timestamp()), + ) + + if not submitted: return HumanInputContentDomainModel( workflow_run_id=model.workflow_run_id, submitted=False, - form_definition=HumanInputFormDefinition( - form_id=form.id, - node_id=form.node_id, - node_title=node_title, - form_content=form.rendered_content, - inputs=form_definition.inputs, - actions=form_definition.user_actions, - display_in_ui=display_in_ui, - form_token=form_token, - resolved_default_values=form_definition.default_values, - expiration_time=int(form.expiration_time.timestamp()), - ), + form_definition=form_definition_domain_model, ) selected_action_id = form.selected_action_id @@ -164,17 +169,20 @@ class SQLAlchemyExecutionExtraContentRepository(ExecutionExtraContentRepository) form.rendered_content, submitted_data, _extract_output_field_names(form_definition.form_content), + form_definition.inputs, ) return HumanInputContentDomainModel( workflow_run_id=model.workflow_run_id, - submitted=True, + submitted=submitted, + form_definition=form_definition_domain_model, form_submission_data=HumanInputFormSubmissionData( node_id=form.node_id, node_title=node_title, rendered_content=rendered_content, action_id=selected_action_id, action_text=action_text, + submitted_data=submitted_data, ), ) diff --git a/api/services/human_input_file_upload_service.py b/api/services/human_input_file_upload_service.py new file mode 100644 index 0000000000..fe8912a261 --- /dev/null +++ b/api/services/human_input_file_upload_service.py @@ -0,0 +1,244 @@ +from __future__ import annotations + +import secrets +from dataclasses import dataclass +from datetime import datetime, timedelta + +from sqlalchemy import Engine, select +from sqlalchemy.orm import Session, selectinload, sessionmaker + +from configs import dify_config +from graphon.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus +from libs.datetime_utils import ensure_naive_utc, naive_utc_now +from models.account import Account, Tenant +from models.enums import CreatorUserRole +from models.human_input import ( + HumanInputForm, + HumanInputFormRecipient, + HumanInputFormUploadFile, + HumanInputFormUploadToken, +) +from models.model import App, EndUser +from repositories.api_workflow_run_repository import APIWorkflowRunRepository +from services.human_input_service import FormExpiredError, FormNotFoundError, FormSubmittedError + +HITL_UPLOAD_TOKEN_PREFIX = "hitl_upload_" +_TOKEN_RANDOM_BYTES = 32 +_TOKEN_GENERATION_ATTEMPTS = 10 + + +@dataclass(frozen=True) +class HumanInputUploadToken: + upload_token: str + expires_at: datetime + + +@dataclass(frozen=True) +class HumanInputUploadContext: + tenant_id: str + app_id: str + form_id: str + recipient_id: str + upload_token_id: str + owner: Account | EndUser + + +class InvalidUploadTokenError(Exception): + pass + + +class HumanInputFileUploadService: + """Coordinates HITL upload tokens, workflow-run owners, and form-file links. + + Standalone HITL uploads must be owned by the original workflow/chatflow + initiator so that resume-time file restoration continues to flow through the + normal file access checks. Delivery-test forms have no workflow run, so their + uploads are scoped to the app creator account inside the form tenant. + """ + + _session_maker: sessionmaker[Session] + _workflow_run_repository: APIWorkflowRunRepository + + def __init__( + self, + session_factory: sessionmaker[Session] | Engine, + workflow_run_repository: APIWorkflowRunRepository, + ) -> None: + if isinstance(session_factory, Engine): + session_factory = sessionmaker(bind=session_factory) + self._session_maker = session_factory + self._workflow_run_repository = workflow_run_repository + + def issue_upload_token(self, form_token: str) -> HumanInputUploadToken: + """Create an upload token for an active human input recipient token.""" + + with self._session_maker() as session, session.begin(): + recipient_model = session.scalar( + select(HumanInputFormRecipient) + .options(selectinload(HumanInputFormRecipient.form)) + .where(HumanInputFormRecipient.access_token == form_token) + .limit(1) + ) + if recipient_model is None or recipient_model.form is None: + raise FormNotFoundError() + + form = recipient_model.form + self._ensure_form_model_active(form) + upload_token = self._generate_unique_upload_token() + token_model = HumanInputFormUploadToken( + tenant_id=form.tenant_id, + app_id=form.app_id, + form_id=form.id, + recipient_id=recipient_model.id, + token=upload_token, + ) + session.add(token_model) + # Snapshot the expiry before commit so callers do not depend on the + # session factory's expire_on_commit policy. + token = HumanInputUploadToken(upload_token=upload_token, expires_at=form.expiration_time) + + return token + + def validate_upload_token(self, upload_token: str) -> HumanInputUploadContext: + """Resolve an upload token and ensure the bound form is still active.""" + + query = ( + select(HumanInputFormUploadToken) + .options(selectinload(HumanInputFormUploadToken.form)) + .where(HumanInputFormUploadToken.token == upload_token) + .limit(1) + ) + with self._session_maker(expire_on_commit=False) as session: + token_model = session.scalars(query).first() + if token_model is None: + raise InvalidUploadTokenError() + + form_model = token_model.form + if form_model is None: + raise InvalidUploadTokenError() + self._ensure_form_model_active(form_model) + + owner = self._resolve_upload_owner(session=session, form_model=form_model) + + return HumanInputUploadContext( + tenant_id=token_model.tenant_id, + app_id=token_model.app_id, + form_id=token_model.form_id, + recipient_id=token_model.recipient_id, + upload_token_id=token_model.id, + owner=owner, + ) + + def record_upload_file(self, *, context: HumanInputUploadContext, file_id: str) -> None: + """Record that a file was uploaded through a specific form upload token.""" + + with self._session_maker() as session, session.begin(): + session.add( + HumanInputFormUploadFile( + tenant_id=context.tenant_id, + app_id=context.app_id, + form_id=context.form_id, + upload_file_id=file_id, + upload_token_id=context.upload_token_id, + ) + ) + + def _generate_unique_upload_token(self) -> str: + return f"{HITL_UPLOAD_TOKEN_PREFIX}{secrets.token_urlsafe(_TOKEN_RANDOM_BYTES)}" + + def _resolve_upload_owner( + self, + *, + session: Session, + form_model: HumanInputForm, + ) -> Account | EndUser: + if form_model.workflow_run_id is None: + if form_model.form_kind == HumanInputFormKind.DELIVERY_TEST: + return self._resolve_delivery_test_upload_owner(session=session, form_model=form_model) + raise InvalidUploadTokenError() + + workflow_run = self._workflow_run_repository.get_workflow_run_by_id( + tenant_id=form_model.tenant_id, + app_id=form_model.app_id, + run_id=form_model.workflow_run_id, + ) + if workflow_run is None: + raise InvalidUploadTokenError() + + if workflow_run.created_by_role == CreatorUserRole.END_USER: + end_user = session.scalar( + select(EndUser) + .where( + EndUser.id == workflow_run.created_by, + EndUser.tenant_id == workflow_run.tenant_id, + EndUser.app_id == workflow_run.app_id, + ) + .limit(1) + ) + if end_user is None: + raise InvalidUploadTokenError() + return end_user + + if workflow_run.created_by_role != CreatorUserRole.ACCOUNT: + raise InvalidUploadTokenError() + + account = session.scalar(select(Account).where(Account.id == workflow_run.created_by).limit(1)) + if account is None: + raise InvalidUploadTokenError() + + tenant = session.scalar(select(Tenant).where(Tenant.id == workflow_run.tenant_id).limit(1)) + if tenant is None: + raise InvalidUploadTokenError() + + # HITL upload runs outside the normal account auth flow, so hydrate the + # account tenant context explicitly before delegating to FileService. + account.current_tenant = tenant + return account + + def _resolve_delivery_test_upload_owner( + self, + *, + session: Session, + form_model: HumanInputForm, + ) -> Account: + app = session.scalar( + select(App) + .where( + App.id == form_model.app_id, + App.tenant_id == form_model.tenant_id, + ) + .limit(1) + ) + if app is None or app.created_by is None: + raise InvalidUploadTokenError() + + account = session.scalar(select(Account).where(Account.id == app.created_by).limit(1)) + if account is None: + raise InvalidUploadTokenError() + + tenant = session.scalar(select(Tenant).where(Tenant.id == form_model.tenant_id).limit(1)) + if tenant is None: + raise InvalidUploadTokenError() + + account.current_tenant = tenant + if account.current_tenant_id != form_model.tenant_id: + raise InvalidUploadTokenError() + return account + + @staticmethod + def _ensure_form_model_active(form: HumanInputForm) -> None: + if form.submitted_at is not None or form.status == HumanInputFormStatus.SUBMITTED: + raise FormSubmittedError(form.id) + if form.status in {HumanInputFormStatus.TIMEOUT, HumanInputFormStatus.EXPIRED}: + raise FormExpiredError(form.id) + + now = naive_utc_now() + if ensure_naive_utc(form.expiration_time) <= now: + raise FormExpiredError(form.id) + + global_timeout_seconds = dify_config.HUMAN_INPUT_GLOBAL_TIMEOUT_SECONDS + if global_timeout_seconds <= 0 or form.workflow_run_id is None: + return + global_deadline = ensure_naive_utc(form.created_at) + timedelta(seconds=global_timeout_seconds) + if global_deadline <= now: + raise FormExpiredError(form.id) diff --git a/api/services/human_input_service.py b/api/services/human_input_service.py index 76598d31ac..dcddfe0f2c 100644 --- a/api/services/human_input_service.py +++ b/api/services/human_input_service.py @@ -1,22 +1,37 @@ import logging -from collections.abc import Mapping +from collections.abc import Mapping, Sequence from datetime import datetime, timedelta -from typing import Any +from typing import Any, Protocol, cast +from pydantic import JsonValue, TypeAdapter, ValidationError from sqlalchemy import Engine, select from sqlalchemy.orm import Session, sessionmaker from configs import dify_config +from core.app.file_access import DatabaseFileAccessController +from core.app.layers.pause_state_persist_layer import WorkflowResumptionContext from core.repositories.human_input_repository import ( HumanInputFormRecord, HumanInputFormSubmissionRepository, ) +from core.workflow.human_input_policy import resolve_variable_select_input_options +from factories.file_factory import build_from_mapping, build_from_mappings +from graphon.file import FileUploadConfig from graphon.nodes.human_input.entities import ( + FileInputConfig, + FileListInputConfig, FormDefinition, + FormInputConfig, HumanInputSubmissionValidationError, - validate_human_input_submission, + SelectInputConfig, + UserActionConfig, ) -from graphon.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus +from graphon.nodes.human_input.entities import ( + validate_human_input_submission as graphon_validate_human_input_submission, +) +from graphon.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus, ValueSourceType +from graphon.runtime import GraphRuntimeState +from graphon.runtime.graph_runtime_state_protocol import ReadOnlyVariablePool from libs.datetime_utils import ensure_naive_utc, naive_utc_now from libs.exception import BaseHTTPException from models.human_input import RecipientType @@ -24,6 +39,13 @@ from models.model import App, AppMode from repositories.factory import DifyAPIRepositoryFactory from tasks.app_generate.workflow_execute_task import resume_app_execution +_file_access_controller = DatabaseFileAccessController() + + +_JsonObjectAdapter: TypeAdapter[dict[str, JsonValue]] = TypeAdapter(dict[str, JsonValue]) +_JsonValueAdapter: TypeAdapter[JsonValue] = TypeAdapter(JsonValue) +_MappingSequenceAdapter: TypeAdapter[Sequence[Mapping[str, Any]]] = TypeAdapter(Sequence[Mapping[str, Any]]) + class Form: def __init__(self, record: HumanInputFormRecord): @@ -82,7 +104,7 @@ class HumanInputError(Exception): pass -class FormSubmittedError(HumanInputError, BaseHTTPException): +class FormSubmittedError(BaseHTTPException, HumanInputError): error_code = "human_input_form_submitted" description = "This form has already been submitted by another user, form_id={form_id}" code = 412 @@ -90,37 +112,48 @@ class FormSubmittedError(HumanInputError, BaseHTTPException): def __init__(self, form_id: str): template = self.description or "This form has already been submitted by another user, form_id={form_id}" description = template.format(form_id=form_id) - super().__init__(description=description) + BaseHTTPException.__init__(self, description=description) -class FormNotFoundError(HumanInputError, BaseHTTPException): +class FormNotFoundError(BaseHTTPException, HumanInputError): error_code = "human_input_form_not_found" code = 404 -class InvalidFormDataError(HumanInputError, BaseHTTPException): +class InvalidFormDataError(BaseHTTPException, HumanInputError): error_code = "invalid_form_data" code = 400 def __init__(self, description: str): - super().__init__(description=description) + BaseHTTPException.__init__(self, description=description) class WebAppDeliveryNotEnabledError(HumanInputError, BaseException): pass -class FormExpiredError(HumanInputError, BaseHTTPException): +class FormExpiredError(BaseHTTPException, HumanInputError): error_code = "human_input_form_expired" code = 412 def __init__(self, form_id: str): - super().__init__(description=f"This form has expired, form_id={form_id}") + BaseHTTPException.__init__( + self, + description=f"This form has expired, form_id={form_id}", + ) logger = logging.getLogger(__name__) +class FormDefinitionProtocol(Protocol): + @property + def inputs(self) -> Sequence[FormInputConfig]: ... + + @property + def user_actions(self) -> Sequence[UserActionConfig]: ... + + class HumanInputService: def __init__( self, @@ -152,12 +185,19 @@ class HumanInputService: self._ensure_not_submitted(form) return form + def resolve_form_inputs(self, form: Form) -> Sequence[FormInputConfig]: + variable_pool = self._load_variable_pool_for_form(form) + return resolve_variable_select_input_options( + form.get_definition().inputs, + variable_pool=variable_pool, + ) + def submit_form_by_token( self, recipient_type: RecipientType, form_token: str, selected_action_id: str, - form_data: Mapping[str, Any], + form_data: Mapping[str, JsonValue], submission_end_user_id: str | None = None, submission_user_id: str | None = None, ): @@ -166,13 +206,17 @@ class HumanInputService: raise WebAppDeliveryNotEnabledError() self.ensure_form_active(form) - self._validate_submission(form=form, selected_action_id=selected_action_id, form_data=form_data) + normalized_form_data = self._validate_submission( + form=form, + selected_action_id=selected_action_id, + form_data=form_data, + ) result = self._form_repository.mark_submitted( form_id=form.id, recipient_id=form.recipient_id, selected_action_id=selected_action_id, - form_data=form_data, + form_data=normalized_form_data, submission_user_id=submission_user_id, submission_end_user_id=submission_end_user_id, ) @@ -198,12 +242,17 @@ class HumanInputService: if form.submitted: raise FormSubmittedError(form.id) - def _validate_submission(self, form: Form, selected_action_id: str, form_data: Mapping[str, Any]) -> None: + def _validate_submission( + self, + form: Form, + selected_action_id: str, + form_data: Mapping[str, Any], + ) -> dict[str, JsonValue]: definition = form.get_definition() try: - validate_human_input_submission( - inputs=definition.inputs, - user_actions=definition.user_actions, + return self.validate_and_normalize_submission( + tenant_id=form.tenant_id, + form_definition=definition, selected_action_id=selected_action_id, form_data=form_data, ) @@ -237,6 +286,22 @@ class HumanInputService: logger.warning("App mode %s does not support resume for workflow run %s", app.mode, workflow_run_id) + def _load_variable_pool_for_form(self, form: Form) -> ReadOnlyVariablePool | None: + workflow_run_id = form.workflow_run_id + if workflow_run_id is None: + return None + + workflow_run_repo = DifyAPIRepositoryFactory.create_api_workflow_run_repository(self._session_factory) + pause_entity = workflow_run_repo.get_workflow_pause(workflow_run_id) + + if pause_entity is None or pause_entity.resumed_at is not None: + return None + + resumption_context = WorkflowResumptionContext.loads(pause_entity.get_state().decode()) + runtime_state = GraphRuntimeState.from_snapshot(resumption_context.serialized_graph_runtime_state) + + return runtime_state.variable_pool + def _is_globally_expired(self, form: Form, *, now: datetime | None = None) -> bool: global_timeout_seconds = dify_config.HUMAN_INPUT_GLOBAL_TIMEOUT_SECONDS if global_timeout_seconds <= 0: @@ -247,3 +312,176 @@ class HumanInputService: created_at = ensure_naive_utc(form.created_at) global_deadline = created_at + timedelta(seconds=global_timeout_seconds) return global_deadline <= current + + @classmethod + def validate_and_normalize_submission( + cls, + *, + tenant_id: str, + form_definition: FormDefinitionProtocol, + selected_action_id: str, + form_data: Mapping[str, Any], + ) -> dict[str, JsonValue]: + """ + Normalize Dify-owned runtime payloads before delegating shape validation to graphon. + + graphon owns the form schema and validation rules, while Dify owns tenant-aware file + reconstruction and persistence compatibility for submitted payloads. + """ + normalized_form_data = cls.normalize_submission_data( + tenant_id=tenant_id, + form_definition=form_definition, + form_data=form_data, + ) + graphon_validate_human_input_submission( + inputs=form_definition.inputs, + user_actions=form_definition.user_actions, + selected_action_id=selected_action_id, + form_data=normalized_form_data, + ) + return normalized_form_data + + @classmethod + def normalize_submission_data( + cls, + *, + tenant_id: str, + form_definition: FormDefinitionProtocol, + form_data: Mapping[str, Any], + ) -> dict[str, JsonValue]: + normalized_form_data: dict[str, JsonValue] = _JsonObjectAdapter.validate_python(form_data) + inputs_by_name = {form_input.output_variable_name: form_input for form_input in form_definition.inputs} + for name, form_input in inputs_by_name.items(): + if name not in form_data: + continue + normalized_form_data[name] = cls._normalize_input_value( + tenant_id=tenant_id, + form_input=form_input, + value=form_data[name], + ) + + return normalized_form_data + + @classmethod + def _normalize_input_value( + cls, + *, + tenant_id: str, + form_input: FormInputConfig, + value: Any, + ) -> JsonValue: + if isinstance(form_input, SelectInputConfig): + return cls._normalize_select_value(form_input=form_input, value=value) + if isinstance(form_input, FileInputConfig): + return cls._normalize_file_value( + tenant_id=tenant_id, + form_input=form_input, + value=value, + ) + if isinstance(form_input, FileListInputConfig): + return cls._normalize_file_list_value( + tenant_id=tenant_id, + form_input=form_input, + value=value, + ) + return _JsonValueAdapter.validate_python(value) + + @classmethod + def _normalize_select_value( + cls, + *, + form_input: SelectInputConfig, + value: Any, + ) -> JsonValue: + if not isinstance(value, str): + raise HumanInputSubmissionValidationError( + f"Invalid value for select input '{form_input.output_variable_name}': expected string" + ) + option_source = form_input.option_source + if option_source.type == ValueSourceType.CONSTANT and value not in option_source.value: + raise HumanInputSubmissionValidationError( + f"Invalid value for select input '{form_input.output_variable_name}': {value}" + ) + return value + + @classmethod + def _normalize_file_value( + cls, + *, + tenant_id: str, + form_input: FileInputConfig, + value: Any, + ) -> JsonValue: + if not isinstance(value, Mapping): + raise HumanInputSubmissionValidationError( + f"Invalid value for file input '{form_input.output_variable_name}': expected mapping" + ) + upload_config = cls._build_file_upload_config(form_input=form_input, number_limits=1) + try: + # `build_from_mapping` enforces tenant ownership for persisted upload references. + file = build_from_mapping( + mapping=value, + tenant_id=tenant_id, + config=upload_config, + strict_type_validation=True, + access_controller=_file_access_controller, + ) + except ValueError as exc: + raise HumanInputSubmissionValidationError( + f"Invalid value for file input '{form_input.output_variable_name}': {exc}" + ) from exc + return cast(JsonValue, file.to_dict()) + + @classmethod + def _normalize_file_list_value( + cls, + *, + tenant_id: str, + form_input: FileListInputConfig, + value: Any, + ) -> JsonValue: + try: + validated_value = _MappingSequenceAdapter.validate_python(value) + except ValidationError as exc: + raise HumanInputSubmissionValidationError( + f"Invalid value for file list input '{form_input.output_variable_name}': {exc}" + ) from exc + if not isinstance(value, list): + raise HumanInputSubmissionValidationError( + f"Invalid value for file list input '{form_input.output_variable_name}': expected list" + ) + if any(not isinstance(item, Mapping) for item in value): + raise HumanInputSubmissionValidationError( + f"Invalid value for file list input '{form_input.output_variable_name}': expected list of mappings" + ) + upload_config = cls._build_file_upload_config( + form_input=form_input, + number_limits=form_input.number_limits, + ) + try: + # `build_from_mappings` performs the same tenant-aware ownership validation in batch. + files = build_from_mappings( + mappings=validated_value, + tenant_id=tenant_id, + config=upload_config, + strict_type_validation=True, + access_controller=_file_access_controller, + ) + except ValueError as exc: + raise HumanInputSubmissionValidationError( + f"Invalid value for file list input '{form_input.output_variable_name}': {exc}" + ) from exc + return cast(JsonValue, [file.to_dict() for file in files]) + + @staticmethod + def _build_file_upload_config( + *, + form_input: FileInputConfig | FileListInputConfig, + number_limits: int, + ) -> FileUploadConfig: + return FileUploadConfig( + allowed_file_types=list(form_input.allowed_file_types), + allowed_file_extensions=list(form_input.allowed_file_extensions), + allowed_file_upload_methods=list(form_input.allowed_file_upload_methods), + number_limits=number_limits, + ) diff --git a/api/services/workflow_event_snapshot_service.py b/api/services/workflow_event_snapshot_service.py index 94f88f8c49..dad7dff292 100644 --- a/api/services/workflow_event_snapshot_service.py +++ b/api/services/workflow_event_snapshot_service.py @@ -24,11 +24,17 @@ from core.app.entities.task_entities import ( ) from core.app.layers.pause_state_persist_layer import WorkflowResumptionContext from core.workflow.human_input_forms import load_form_tokens_by_form_id -from core.workflow.human_input_policy import HumanInputSurface, enrich_human_input_pause_reasons +from core.workflow.human_input_policy import ( + HumanInputSurface, + enrich_human_input_pause_reasons, + resolve_human_input_pause_reason_inputs, + resolve_variable_select_input_options, +) from graphon.entities import WorkflowStartReason -from graphon.entities.pause_reason import PauseReasonType +from graphon.entities.pause_reason import HumanInputRequired, PauseReasonType from graphon.enums import WorkflowExecutionStatus, WorkflowNodeExecutionStatus from graphon.runtime import GraphRuntimeState +from graphon.runtime.graph_runtime_state_protocol import ReadOnlyVariablePool from graphon.workflow_type_encoder import WorkflowRuntimeTypeConverter from models.human_input import HumanInputForm from models.model import AppMode, Message @@ -220,6 +226,7 @@ def _build_snapshot_events( human_input_surface: HumanInputSurface | None = None, ) -> list[Mapping[str, Any]]: events: list[Mapping[str, Any]] = [] + variable_pool = _load_variable_pool_from_resumption_context(resumption_context) workflow_started = _build_workflow_started_event( workflow_run=workflow_run, @@ -258,6 +265,7 @@ def _build_snapshot_events( pause_entity=pause_entity, session_maker=session_maker, human_input_surface=human_input_surface, + variable_pool=variable_pool, ): _apply_message_context(human_input_event, message_context) events.append(human_input_event) @@ -344,15 +352,10 @@ def _build_human_input_required_events( pause_entity: WorkflowPauseEntity, session_maker: sessionmaker[Session] | None, human_input_surface: HumanInputSurface | None, + variable_pool: ReadOnlyVariablePool | None, ) -> list[dict[str, Any]]: - reasons = [reason.model_dump(mode="json") for reason in pause_entity.get_pause_reasons()] - human_input_form_ids = [ - form_id - for reason in reasons - if reason.get("TYPE") == PauseReasonType.HUMAN_INPUT_REQUIRED - for form_id in [reason.get("form_id")] - if isinstance(form_id, str) - ] + reasons = pause_entity.get_pause_reasons() + human_input_form_ids = [reason.form_id for reason in reasons if isinstance(reason, HumanInputRequired)] expiration_times_by_form_id: dict[str, int] = {} display_in_ui_by_form_id: dict[str, bool] = {} @@ -377,47 +380,33 @@ def _build_human_input_required_events( events: list[dict[str, Any]] = [] for reason in reasons: - if reason.get("TYPE") != PauseReasonType.HUMAN_INPUT_REQUIRED: + if not isinstance(reason, HumanInputRequired): continue - form_id_raw = reason.get("form_id") - node_id_raw = reason.get("node_id") - node_title_raw = reason.get("node_title") - form_content_raw = reason.get("form_content") - if not isinstance(form_id_raw, str): - continue - if not isinstance(node_id_raw, str): - continue - if not isinstance(node_title_raw, str): - continue - if not isinstance(form_content_raw, str): - continue - form_id = form_id_raw - node_id = node_id_raw - node_title = node_title_raw - form_content = form_content_raw - - inputs = reason.get("inputs") - actions = reason.get("actions") - resolved_default_values = reason.get("resolved_default_values") + form_id = reason.form_id expiration_time = expiration_times_by_form_id.get(form_id) if expiration_time is None: continue + resolved_inputs = resolve_variable_select_input_options( + reason.inputs, + variable_pool=variable_pool, + ) + response = HumanInputRequiredResponse( task_id=task_id, workflow_run_id=workflow_run_id, data=HumanInputRequiredResponse.Data( form_id=form_id, - node_id=node_id, - node_title=node_title, - form_content=form_content, - inputs=inputs if isinstance(inputs, list) else [], - actions=actions if isinstance(actions, list) else [], + node_id=reason.node_id, + node_title=reason.node_title, + form_content=reason.form_content, + inputs=resolved_inputs, + actions=reason.actions, display_in_ui=display_in_ui_by_form_id.get(form_id, False), form_token=form_tokens_by_form_id.get(form_id), - resolved_default_values=(resolved_default_values if isinstance(resolved_default_values, dict) else {}), + resolved_default_values=reason.resolved_default_values, expiration_time=expiration_time, ), ) @@ -428,6 +417,16 @@ def _build_human_input_required_events( return events +def _load_variable_pool_from_resumption_context( + resumption_context: WorkflowResumptionContext | None, +) -> ReadOnlyVariablePool | None: + if resumption_context is None: + return None + state = GraphRuntimeState.from_snapshot(resumption_context.serialized_graph_runtime_state) + + return state.variable_pool + + def _build_node_finished_event( *, workflow_run_id: str, @@ -475,12 +474,18 @@ def _build_pause_event( ) -> dict[str, Any] | None: paused_nodes: list[str] = [] outputs: dict[str, Any] = {} + variable_pool: ReadOnlyVariablePool | None = None if resumption_context is not None: state = GraphRuntimeState.from_snapshot(resumption_context.serialized_graph_runtime_state) paused_nodes = state.get_paused_nodes() outputs = dict(WorkflowRuntimeTypeConverter().to_json_encodable(state.outputs or {})) + variable_pool = state.variable_pool - reasons = [reason.model_dump(mode="json") for reason in pause_entity.get_pause_reasons()] + resolved_pause_reasons = resolve_human_input_pause_reason_inputs( + pause_entity.get_pause_reasons(), + variable_pool=variable_pool, + ) + reasons = [reason.model_dump(mode="json") for reason in resolved_pause_reasons] human_input_form_ids = [ form_id for reason in reasons diff --git a/api/services/workflow_service.py b/api/services/workflow_service.py index 36b760d37a..50dd977749 100644 --- a/api/services/workflow_service.py +++ b/api/services/workflow_service.py @@ -59,7 +59,7 @@ from graphon.node_events import NodeRunResult from graphon.nodes import BuiltinNodeTypes from graphon.nodes.base.node import Node from graphon.nodes.http_request import HTTP_REQUEST_CONFIG_FILTER_KEY, build_http_request_config -from graphon.nodes.human_input.entities import HumanInputNodeData, validate_human_input_submission +from graphon.nodes.human_input.entities import HumanInputNodeData from graphon.nodes.human_input.enums import HumanInputFormKind from graphon.nodes.human_input.human_input_node import HumanInputNode from graphon.nodes.start.entities import StartNodeData @@ -82,6 +82,7 @@ from services.errors.app import ( WorkflowHashNotEqualError, WorkflowNotFoundError, ) +from services.human_input_service import HumanInputService from services.workflow.workflow_converter import WorkflowConverter from .errors.workflow_service import DraftWorkflowDeletionError, WorkflowInUseError @@ -1020,7 +1021,7 @@ class WorkflowService: manual_inputs=inputs or {}, user_id=account.id, ) - node = self._build_human_input_node( + node = self._build_human_input_node_for_debugging( workflow=draft_workflow, account=account, node_config=node_config, @@ -1080,7 +1081,7 @@ class WorkflowService: manual_inputs=inputs or {}, user_id=account.id, ) - node = self._build_human_input_node( + node = self._build_human_input_node_for_debugging( workflow=draft_workflow, account=account, node_config=node_config, @@ -1088,9 +1089,10 @@ class WorkflowService: ) node_data = node.node_data - validate_human_input_submission( - inputs=node_data.inputs, - user_actions=node_data.user_actions, + human_input_service = HumanInputService(session_factory=sessionmaker(db.engine)) + normalized_form_inputs = human_input_service.validate_and_normalize_submission( + tenant_id=app_model.tenant_id, + form_definition=node_data, selected_action_id=action, form_data=form_inputs, ) @@ -1100,11 +1102,14 @@ class WorkflowService: (user_action for user_action in node_data.user_actions if user_action.id == action), None, ) - outputs: dict[str, Any] = dict(form_inputs) + outputs: dict[str, Any] = dict(normalized_form_inputs) outputs["__action_id"] = action outputs["__action_value"] = selected_action.title if selected_action else "" outputs["__rendered_content"] = node.render_form_content_with_outputs( - rendered_content, outputs, node_data.outputs_field_names() + rendered_content, + outputs, + node_data.outputs_field_names(), + node_data.inputs, ) enclosing_node_type_and_id = draft_workflow.get_enclosing_node_type_and_id(node_config) @@ -1164,7 +1169,7 @@ class WorkflowService: manual_inputs=inputs or {}, user_id=account.id, ) - node = self._build_human_input_node( + node = self._build_human_input_node_for_debugging( workflow=draft_workflow, account=account, node_config=node_config, @@ -1257,7 +1262,7 @@ class WorkflowService: recipients_data.append(DeliveryTestEmailRecipient(email=email, form_token=recipient.access_token)) return recipients_data - def _build_human_input_node( + def _build_human_input_node_for_debugging( self, *, workflow: Workflow, @@ -1289,8 +1294,8 @@ class WorkflowService: data=node_data, graph_init_params=graph_init_params, graph_runtime_state=graph_runtime_state, - file_reference_factory=DifyFileReferenceFactory(graph_init_params.run_context), runtime=DifyHumanInputNodeRuntime(run_context), + file_reference_factory=DifyFileReferenceFactory(run_context), ) return node diff --git a/api/tests/test_containers_integration_tests/controllers/web/test_human_input_form.py b/api/tests/test_containers_integration_tests/controllers/web/test_human_input_form.py new file mode 100644 index 0000000000..c93b3dcb48 --- /dev/null +++ b/api/tests/test_containers_integration_tests/controllers/web/test_human_input_form.py @@ -0,0 +1,239 @@ +from __future__ import annotations + +import json +from datetime import UTC, datetime, timedelta +from typing import override +from uuid import uuid4 + +import pytest +from flask.testing import FlaskClient +from sqlalchemy import Engine +from sqlalchemy.orm import Session, sessionmaker + +from core.app.app_config.entities import WorkflowUIBasedAppConfig +from core.app.entities.app_invoke_entities import InvokeFrom, WorkflowAppGenerateEntity +from core.app.layers.pause_state_persist_layer import WorkflowResumptionContext, _WorkflowGenerateEntityWrapper +from core.workflow.human_input_adapter import DeliveryMethodType +from graphon.entities import WorkflowExecution +from graphon.entities.pause_reason import HumanInputRequired +from graphon.enums import WorkflowExecutionStatus +from graphon.nodes.human_input.entities import FormDefinition, SelectInputConfig, StringListSource, UserActionConfig +from graphon.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus, ValueSourceType +from graphon.runtime import GraphRuntimeState, VariablePool +from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole +from models.enums import CreatorUserRole, WorkflowRunTriggeredFrom +from models.human_input import ( + HumanInputDelivery, + HumanInputForm, + HumanInputFormRecipient, + RecipientType, + StandaloneWebAppRecipientPayload, +) +from models.model import App, AppMode, CustomizeTokenStrategy, Site +from models.workflow import WorkflowRun, WorkflowType +from repositories.sqlalchemy_api_workflow_run_repository import DifyAPISQLAlchemyWorkflowRunRepository +from services.feature_service import FeatureModel + + +class _TestWorkflowRunRepository(DifyAPISQLAlchemyWorkflowRunRepository): + """Concrete repository for tests where save() is not under test.""" + + @override + def save(self, execution: WorkflowExecution) -> None: + return None + + +def _create_app_with_site(session: Session) -> tuple[App, Account]: + tenant = Tenant(name="Test Tenant") + account = Account(name="Tester", email=f"tester-{uuid4()}@example.com") + session.add_all([tenant, account]) + session.flush() + + session.add( + TenantAccountJoin( + tenant_id=tenant.id, + account_id=account.id, + current=True, + role=TenantAccountRole.OWNER, + ) + ) + + app = App( + tenant_id=tenant.id, + name="Test App", + description="", + mode=AppMode.WORKFLOW.value, + icon_type="emoji", + icon="app", + icon_background="#ffffff", + enable_site=True, + enable_api=True, + created_by=account.id, + updated_by=account.id, + ) + session.add(app) + session.flush() + + site = Site( + app_id=app.id, + title="Test Site", + icon_type="emoji", + icon="robot", + icon_background="#ffffff", + description="desc", + default_language="en", + chat_color_theme="light", + chat_color_theme_inverted=False, + customize_token_strategy=CustomizeTokenStrategy.NOT_ALLOW, + code=f"code-{uuid4().hex[:8]}", + prompt_public=False, + show_workflow_steps=True, + use_icon_as_answer_icon=False, + ) + session.add(site) + session.flush() + return app, account + + +def _build_resumption_context(*, app: App, workflow_run: WorkflowRun, options: list[str]) -> WorkflowResumptionContext: + app_config = WorkflowUIBasedAppConfig( + tenant_id=app.tenant_id, + app_id=app.id, + app_mode=AppMode.WORKFLOW, + workflow_id=workflow_run.workflow_id, + ) + generate_entity = WorkflowAppGenerateEntity( + task_id="task-1", + app_config=app_config, + inputs={}, + files=[], + user_id=str(uuid4()), + stream=True, + invoke_from=InvokeFrom.WEB_APP, + call_depth=0, + workflow_execution_id=workflow_run.id, + ) + runtime_state = GraphRuntimeState(variable_pool=VariablePool(), start_at=0.0) + runtime_state.variable_pool.add(("start", "options"), options) + return WorkflowResumptionContext( + generate_entity=_WorkflowGenerateEntityWrapper(entity=generate_entity), + serialized_graph_runtime_state=runtime_state.dumps(), + ) + + +def test_get_human_input_form_resolves_runtime_select_options( + db_session_with_containers: Session, + test_client_with_containers: FlaskClient, + monkeypatch: pytest.MonkeyPatch, +) -> None: + app, account = _create_app_with_site(db_session_with_containers) + workflow_run = WorkflowRun( + tenant_id=app.tenant_id, + app_id=app.id, + workflow_id=str(uuid4()), + type=WorkflowType.WORKFLOW, + triggered_from=WorkflowRunTriggeredFrom.APP_RUN, + version="v1", + graph=None, + inputs="{}", + status=WorkflowExecutionStatus.RUNNING, + outputs="{}", + error=None, + elapsed_time=0.0, + total_tokens=0, + total_steps=0, + created_by_role=CreatorUserRole.ACCOUNT, + created_by=account.id, + created_at=datetime.now(UTC).replace(tzinfo=None), + ) + db_session_with_containers.add(workflow_run) + db_session_with_containers.flush() + + configured_input = SelectInputConfig( + output_variable_name="decision", + option_source=StringListSource( + type=ValueSourceType.VARIABLE, + selector=["start", "options"], + value=["configured"], + ), + ) + expiration_time = datetime.now(UTC).replace(tzinfo=None) + timedelta(hours=1) + form_definition = FormDefinition( + form_content="Choose", + rendered_content="Choose", + inputs=[configured_input], + user_actions=[UserActionConfig(id="approve", title="Approve")], + expiration_time=expiration_time, + ) + form = HumanInputForm( + tenant_id=app.tenant_id, + app_id=app.id, + workflow_run_id=workflow_run.id, + form_kind=HumanInputFormKind.RUNTIME, + node_id="human-node", + form_definition=form_definition.model_dump_json(), + rendered_content="Choose", + status=HumanInputFormStatus.WAITING, + expiration_time=expiration_time, + ) + db_session_with_containers.add(form) + db_session_with_containers.flush() + + delivery = HumanInputDelivery( + form_id=form.id, + delivery_method_type=DeliveryMethodType.WEBAPP, + channel_payload="{}", + ) + db_session_with_containers.add(delivery) + db_session_with_containers.flush() + + access_token = f"hitl{uuid4().hex[:18]}" + recipient = HumanInputFormRecipient( + form_id=form.id, + delivery_id=delivery.id, + recipient_type=RecipientType.STANDALONE_WEB_APP, + recipient_payload=StandaloneWebAppRecipientPayload().model_dump_json(), + access_token=access_token, + ) + db_session_with_containers.add(recipient) + db_session_with_containers.commit() + + context = _build_resumption_context( + app=app, + workflow_run=workflow_run, + options=["approve", "reject"], + ) + reason = HumanInputRequired( + form_id=form.id, + form_content="Choose", + inputs=[configured_input], + actions=[UserActionConfig(id="approve", title="Approve")], + node_id="human-node", + node_title="Human Input", + ) + engine = db_session_with_containers.get_bind() + assert isinstance(engine, Engine) + workflow_run_repo = _TestWorkflowRunRepository(session_maker=sessionmaker(bind=engine, expire_on_commit=False)) + workflow_run_repo.create_workflow_pause( + workflow_run_id=workflow_run.id, + state_owner_user_id=account.id, + state=context.dumps(), + pause_reasons=[reason], + ) + + def mock_get_features(tenant_id: str, exclude_vector_space: bool = False) -> FeatureModel: + features = FeatureModel(can_replace_logo=True) + return features + + monkeypatch.setattr( + "controllers.web.site.FeatureService.get_features", + mock_get_features, + ) + + response = test_client_with_containers.get(f"/api/form/human_input/{access_token}") + + assert response.status_code == 200, response.get_data(as_text=True) + body = json.loads(response.get_data(as_text=True)) + assert body["inputs"][0]["option_source"]["type"] == "variable" + assert body["inputs"][0]["option_source"]["selector"] == ["start", "options"] + assert body["inputs"][0]["option_source"]["value"] == ["approve", "reject"] diff --git a/api/tests/test_containers_integration_tests/helpers/execution_extra_content.py b/api/tests/test_containers_integration_tests/helpers/execution_extra_content.py index 2a1638d126..dc8b3827c6 100644 --- a/api/tests/test_containers_integration_tests/helpers/execution_extra_content.py +++ b/api/tests/test_containers_integration_tests/helpers/execution_extra_content.py @@ -132,6 +132,7 @@ def create_human_input_message_fixture(db_session) -> HumanInputMessageFixture: status=HumanInputFormStatus.SUBMITTED, expiration_time=naive_utc_now() + timedelta(days=1), selected_action_id=action_id, + submitted_data='{"name": "Alice"}', ) db_session.add(form) db_session.flush() diff --git a/api/tests/test_containers_integration_tests/repositories/test_sqlalchemy_api_workflow_run_repository.py b/api/tests/test_containers_integration_tests/repositories/test_sqlalchemy_api_workflow_run_repository.py index 1ffc84c167..3f3b3eae52 100644 --- a/api/tests/test_containers_integration_tests/repositories/test_sqlalchemy_api_workflow_run_repository.py +++ b/api/tests/test_containers_integration_tests/repositories/test_sqlalchemy_api_workflow_run_repository.py @@ -642,7 +642,7 @@ class TestBuildHumanInputRequiredReason: expiration_time = naive_utc_now() form_definition = FormDefinition( form_content="content", - inputs=[ParagraphInputConfig(type=FormInputType.PARAGRAPH, output_variable_name="name")], + inputs=[ParagraphInputConfig(output_variable_name="name")], user_actions=[UserActionConfig(id="approve", title="Approve")], rendered_content="rendered", expiration_time=expiration_time, diff --git a/api/tests/test_containers_integration_tests/repositories/test_sqlalchemy_execution_extra_content_repository.py b/api/tests/test_containers_integration_tests/repositories/test_sqlalchemy_execution_extra_content_repository.py index 7da6f4a32d..4ce6bb98c3 100644 --- a/api/tests/test_containers_integration_tests/repositories/test_sqlalchemy_execution_extra_content_repository.py +++ b/api/tests/test_containers_integration_tests/repositories/test_sqlalchemy_execution_extra_content_repository.py @@ -5,6 +5,7 @@ Part of #32454 — replaces the mock-based unit tests with real database interac from __future__ import annotations +import json from collections.abc import Generator from dataclasses import dataclass from datetime import timedelta @@ -174,11 +175,15 @@ def _create_submitted_form( action_id: str = "approve", action_title: str = "Approve", node_title: str = "Approval", + form_content: str = "content", + rendered_content: str | None = None, + inputs: list[dict] | None = None, + submitted_data: dict | None = None, ) -> HumanInputForm: expiration_time = naive_utc_now() + timedelta(days=1) form_definition = FormDefinition( - form_content="content", - inputs=[], + form_content=form_content, + inputs=inputs or [], user_actions=[UserActionConfig(id=action_id, title=action_title)], rendered_content="rendered", expiration_time=expiration_time, @@ -191,10 +196,12 @@ def _create_submitted_form( workflow_run_id=workflow_run_id, node_id="node-id", form_definition=form_definition.model_dump_json(), - rendered_content=f"Rendered {action_title}", + rendered_content=rendered_content or f"Rendered {action_title}", status=HumanInputFormStatus.SUBMITTED, expiration_time=expiration_time, selected_action_id=action_id, + submitted_data=None if submitted_data is None else json.dumps(submitted_data), + submitted_at=naive_utc_now(), ) session.add(form) session.flush() @@ -349,6 +356,127 @@ class TestGetByMessageIds: # msg2 has no content assert result[1] == [] + def test_submitted_content_populates_submission_data_from_stored_form_data( + self, + db_session_with_containers: Session, + repository: SQLAlchemyExecutionExtraContentRepository, + test_scope: _TestScope, + ) -> None: + workflow_run_id = str(uuid4()) + conversation = _create_conversation(db_session_with_containers, test_scope) + msg = _create_message(db_session_with_containers, test_scope, conversation.id, workflow_run_id) + stored_submission_data = {"decision": "approve", "comment": "Looks good"} + form = _create_submitted_form( + db_session_with_containers, + test_scope, + workflow_run_id=workflow_run_id, + submitted_data=stored_submission_data, + ) + _create_human_input_content( + db_session_with_containers, + workflow_run_id=workflow_run_id, + message_id=msg.id, + form_id=form.id, + ) + db_session_with_containers.commit() + + result = repository.get_by_message_ids([msg.id]) + + content = result[0][0] + assert content.form_submission_data is not None + assert content.form_submission_data.submitted_data == stored_submission_data + + def test_submitted_content_exposes_select_and_file_form_data( + self, + db_session_with_containers: Session, + repository: SQLAlchemyExecutionExtraContentRepository, + test_scope: _TestScope, + ) -> None: + workflow_run_id = str(uuid4()) + conversation = _create_conversation(db_session_with_containers, test_scope) + msg = _create_message(db_session_with_containers, test_scope, conversation.id, workflow_run_id) + submitted_data = { + "decision": "approve", + "attachment": { + "type": "document", + "transfer_method": "remote_url", + "remote_url": "https://example.com/file.txt", + "filename": "file.txt", + "extension": ".txt", + "mime_type": "text/plain", + }, + "attachments": [ + { + "type": "document", + "transfer_method": "remote_url", + "remote_url": "https://example.com/first.txt", + "filename": "first.txt", + "extension": ".txt", + "mime_type": "text/plain", + }, + { + "type": "document", + "transfer_method": "remote_url", + "remote_url": "https://example.com/second.txt", + "filename": "second.txt", + "extension": ".txt", + "mime_type": "text/plain", + }, + ], + } + form = _create_submitted_form( + db_session_with_containers, + test_scope, + workflow_run_id=workflow_run_id, + form_content=( + "Decision: {{#$output.decision#}}\n" + "Attachment: {{#$output.attachment#}}\n" + "Attachments: {{#$output.attachments#}}" + ), + rendered_content=( + "Decision: {{#$output.decision#}}\n" + "Attachment: {{#$output.attachment#}}\n" + "Attachments: {{#$output.attachments#}}" + ), + inputs=[ + { + "type": "select", + "output_variable_name": "decision", + "option_source": {"type": "constant", "value": ["approve", "reject"]}, + }, + { + "type": "file", + "output_variable_name": "attachment", + "allowed_file_types": ["document"], + "allowed_file_upload_methods": ["remote_url"], + }, + { + "type": "file-list", + "output_variable_name": "attachments", + "allowed_file_types": ["document"], + "allowed_file_upload_methods": ["remote_url"], + "number_limits": 3, + }, + ], + submitted_data=submitted_data, + ) + _create_human_input_content( + db_session_with_containers, + workflow_run_id=workflow_run_id, + message_id=msg.id, + form_id=form.id, + ) + db_session_with_containers.commit() + + result = repository.get_by_message_ids([msg.id]) + + content = result[0][0] + assert content.form_submission_data is not None + assert content.form_submission_data.submitted_data == submitted_data + assert content.form_submission_data.rendered_content == ( + "Decision: approve\nAttachment: [file]\nAttachments: [2 files]" + ) + def test_returns_unsubmitted_form_definition( self, db_session_with_containers: Session, diff --git a/api/tests/test_containers_integration_tests/services/test_human_input_delivery_test.py b/api/tests/test_containers_integration_tests/services/test_human_input_delivery_test.py index 80f9083e81..3e5905efbb 100644 --- a/api/tests/test_containers_integration_tests/services/test_human_input_delivery_test.py +++ b/api/tests/test_containers_integration_tests/services/test_human_input_delivery_test.py @@ -1,9 +1,15 @@ import json import uuid +from io import BytesIO from unittest.mock import MagicMock +import httpx import pytest +from flask.testing import FlaskClient +from sqlalchemy import select +from sqlalchemy.orm import Session +import controllers.web.human_input_file_upload as human_input_file_upload_module from core.workflow.human_input_adapter import ( EmailDeliveryConfig, EmailDeliveryMethod, @@ -11,14 +17,21 @@ from core.workflow.human_input_adapter import ( ExternalRecipient, ) from graphon.enums import BuiltinNodeTypes -from graphon.nodes.human_input.entities import HumanInputNodeData +from graphon.nodes.human_input.entities import FileInputConfig, HumanInputNodeData +from graphon.nodes.human_input.enums import HumanInputFormKind from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole -from models.model import App, AppMode +from models.human_input import HumanInputForm, HumanInputFormRecipient, HumanInputFormUploadFile +from models.model import App, AppMode, UploadFile from models.workflow import Workflow, WorkflowType from services.workflow_service import WorkflowService -def _create_app_with_draft_workflow(session, *, delivery_method_id: uuid.UUID) -> tuple[App, Account]: +def _create_app_with_draft_workflow( + session: Session, + *, + delivery_method_id: uuid.UUID, + include_file_input: bool = False, +) -> tuple[App, Account]: tenant = Tenant(name="Test Tenant") account = Account(name="Tester", email="tester@example.com") session.add_all([tenant, account]) @@ -65,7 +78,7 @@ def _create_app_with_draft_workflow(session, *, delivery_method_id: uuid.UUID) - title="Human Input", delivery_methods=[email_method], form_content="Hello Human Input", - inputs=[], + inputs=[FileInputConfig(output_variable_name="attachment")] if include_file_input else [], user_actions=[], ).model_dump(mode="json") node_data["type"] = BuiltinNodeTypes.HUMAN_INPUT @@ -110,3 +123,167 @@ def test_human_input_delivery_test_sends_email( assert send_mock.call_count == 1 assert send_mock.call_args.kwargs["to"] == "recipient@example.com" + + +def test_human_input_delivery_test_form_accepts_file_upload( + db_session_with_containers: Session, + test_client_with_containers: FlaskClient, + monkeypatch: pytest.MonkeyPatch, +) -> None: + delivery_method_id = uuid.uuid4() + app, account = _create_app_with_draft_workflow( + db_session_with_containers, + delivery_method_id=delivery_method_id, + include_file_input=True, + ) + + monkeypatch.setattr("services.human_input_delivery_test_service.mail.is_inited", lambda: True) + monkeypatch.setattr("services.human_input_delivery_test_service.mail.send", MagicMock()) + + WorkflowService().test_human_input_delivery( + app_model=app, + account=account, + node_id="human-node", + delivery_method_id=str(delivery_method_id), + ) + + form = db_session_with_containers.scalar( + select(HumanInputForm) + .where( + HumanInputForm.app_id == app.id, + HumanInputForm.form_kind == HumanInputFormKind.DELIVERY_TEST, + HumanInputForm.workflow_run_id.is_(None), + ) + .limit(1) + ) + assert form is not None + recipient = db_session_with_containers.scalar( + select(HumanInputFormRecipient).where(HumanInputFormRecipient.form_id == form.id).limit(1) + ) + assert recipient is not None + assert recipient.access_token is not None + + token_response = test_client_with_containers.post(f"/api/form/human_input/{recipient.access_token}/upload-token") + assert token_response.status_code == 200 + upload_token = token_response.get_json()["upload_token"] + + upload_response = test_client_with_containers.post( + "/api/human-input-forms/files", + data={"file": (BytesIO(b"delivery test content"), "evidence.txt")}, + content_type="multipart/form-data", + headers={"Authorization": f"Bearer {upload_token}"}, + ) + + assert upload_response.status_code == 201, upload_response.get_data(as_text=True) + upload_file_id = upload_response.get_json()["id"] + + db_session_with_containers.expire_all() + upload_file = db_session_with_containers.get(UploadFile, upload_file_id) + assert upload_file is not None + assert upload_file.tenant_id == app.tenant_id + assert upload_file.created_by == account.id + link = db_session_with_containers.scalar( + select(HumanInputFormUploadFile) + .where( + HumanInputFormUploadFile.form_id == form.id, + HumanInputFormUploadFile.upload_file_id == upload_file_id, + ) + .limit(1) + ) + assert link is not None + + +def test_human_input_delivery_test_form_accepts_remote_file_upload( + db_session_with_containers: Session, + test_client_with_containers: FlaskClient, + monkeypatch: pytest.MonkeyPatch, +) -> None: + delivery_method_id = uuid.uuid4() + app, account = _create_app_with_draft_workflow( + db_session_with_containers, + delivery_method_id=delivery_method_id, + include_file_input=True, + ) + + monkeypatch.setattr("services.human_input_delivery_test_service.mail.is_inited", lambda: True) + monkeypatch.setattr("services.human_input_delivery_test_service.mail.send", MagicMock()) + + WorkflowService().test_human_input_delivery( + app_model=app, + account=account, + node_id="human-node", + delivery_method_id=str(delivery_method_id), + ) + + form = db_session_with_containers.scalar( + select(HumanInputForm) + .where( + HumanInputForm.app_id == app.id, + HumanInputForm.form_kind == HumanInputFormKind.DELIVERY_TEST, + HumanInputForm.workflow_run_id.is_(None), + ) + .limit(1) + ) + assert form is not None + recipient = db_session_with_containers.scalar( + select(HumanInputFormRecipient).where(HumanInputFormRecipient.form_id == form.id).limit(1) + ) + assert recipient is not None + assert recipient.access_token is not None + + token_response = test_client_with_containers.post(f"/api/form/human_input/{recipient.access_token}/upload-token") + assert token_response.status_code == 200 + upload_token = token_response.get_json()["upload_token"] + + remote_url = "https://example.com/evidence.txt" + remote_content = b"delivery test remote content" + head_response = httpx.Response( + 200, + request=httpx.Request("HEAD", remote_url), + headers={ + "Content-Length": str(len(remote_content)), + "Content-Type": "text/plain", + }, + ) + get_response = httpx.Response( + 200, + request=httpx.Request("GET", remote_url), + headers={ + "Content-Length": str(len(remote_content)), + "Content-Type": "text/plain", + }, + content=remote_content, + ) + head_mock = MagicMock(return_value=head_response) + get_mock = MagicMock(return_value=get_response) + monkeypatch.setattr(human_input_file_upload_module.ssrf_proxy, "head", head_mock) + monkeypatch.setattr(human_input_file_upload_module.ssrf_proxy, "get", get_mock) + + upload_response = test_client_with_containers.post( + "/api/human-input-forms/files", + data={"url": remote_url}, + content_type="multipart/form-data", + headers={"Authorization": f"Bearer {upload_token}"}, + ) + + assert upload_response.status_code == 201, upload_response.get_data(as_text=True) + upload_file_id = upload_response.get_json()["id"] + assert upload_response.get_json()["url"] + head_mock.assert_called_once_with(url=remote_url) + get_mock.assert_called_once_with(remote_url) + + db_session_with_containers.expire_all() + upload_file = db_session_with_containers.get(UploadFile, upload_file_id) + assert upload_file is not None + assert upload_file.tenant_id == app.tenant_id + assert upload_file.created_by == account.id + assert upload_file.source_url == remote_url + link = db_session_with_containers.scalar( + select(HumanInputFormUploadFile) + .where( + HumanInputFormUploadFile.form_id == form.id, + HumanInputFormUploadFile.upload_file_id == upload_file_id, + ) + .limit(1) + ) + assert link is not None diff --git a/api/tests/test_containers_integration_tests/services/test_human_input_file_upload_service.py b/api/tests/test_containers_integration_tests/services/test_human_input_file_upload_service.py new file mode 100644 index 0000000000..ac9a19c4c4 --- /dev/null +++ b/api/tests/test_containers_integration_tests/services/test_human_input_file_upload_service.py @@ -0,0 +1,78 @@ +import uuid +from datetime import datetime, timedelta +from unittest.mock import MagicMock + +import pytest +from sqlalchemy import select +from sqlalchemy.orm import Session, sessionmaker + +import services.human_input_file_upload_service as service_module +from extensions.ext_database import db +from graphon.nodes.human_input.enums import HumanInputFormKind +from libs.datetime_utils import naive_utc_now +from models.human_input import ( + HumanInputForm, + HumanInputFormRecipient, + HumanInputFormUploadToken, + StandaloneWebAppRecipientPayload, +) +from services.human_input_file_upload_service import HITL_UPLOAD_TOKEN_PREFIX, HumanInputFileUploadService + + +def _create_waiting_form_recipient( + db_session_with_containers: Session, +) -> tuple[str, str, datetime]: + form_id = "00000000-0000-0000-0000-000000000101" + recipient_id = "00000000-0000-0000-0000-000000000102" + expiration_time = naive_utc_now() + timedelta(hours=1) + + db_session_with_containers.add( + HumanInputForm( + id=form_id, + tenant_id=str(uuid.uuid4()), + app_id=str(uuid.uuid4()), + workflow_run_id=None, + form_kind=HumanInputFormKind.DELIVERY_TEST, + node_id="human-node", + form_definition="{}", + rendered_content="content", + expiration_time=expiration_time, + ) + ) + db_session_with_containers.add( + HumanInputFormRecipient( + id=recipient_id, + form_id=form_id, + delivery_id=str(uuid.uuid4()), + recipient_type=StandaloneWebAppRecipientPayload().TYPE, + recipient_payload=StandaloneWebAppRecipientPayload().model_dump_json(), + access_token="form-token-1", + ) + ) + db_session_with_containers.commit() + return form_id, recipient_id, expiration_time + + +def test_issue_upload_token_returns_expiration_with_default_session_expiry( + db_session_with_containers: Session, + monkeypatch: pytest.MonkeyPatch, +) -> None: + form_id, recipient_id, expiration_time = _create_waiting_form_recipient(db_session_with_containers) + monkeypatch.setattr(service_module.secrets, "token_urlsafe", lambda _bytes: "random-value") + + service = HumanInputFileUploadService( + session_factory=sessionmaker(bind=db.engine), + workflow_run_repository=MagicMock(), + ) + + token = service.issue_upload_token("form-token-1") + + assert token.upload_token == f"{HITL_UPLOAD_TOKEN_PREFIX}random-value" + assert token.expires_at == expiration_time + + db_session_with_containers.expire_all() + token_model = db_session_with_containers.scalar(select(HumanInputFormUploadToken)) + assert token_model is not None + assert token_model.form_id == form_id + assert token_model.recipient_id == recipient_id + assert token_model.token == token.upload_token diff --git a/api/tests/test_containers_integration_tests/services/test_message_service_execution_extra_content.py b/api/tests/test_containers_integration_tests/services/test_message_service_execution_extra_content.py index 52ebc0131f..6a9046acd4 100644 --- a/api/tests/test_containers_integration_tests/services/test_message_service_execution_extra_content.py +++ b/api/tests/test_containers_integration_tests/services/test_message_service_execution_extra_content.py @@ -3,6 +3,7 @@ from __future__ import annotations import pytest from sqlalchemy.orm import Session +from models.human_input import HumanInputFormStatus from services.message_service import MessageService from tests.test_containers_integration_tests.helpers.execution_extra_content import ( create_human_input_message_fixture, @@ -23,17 +24,55 @@ def test_pagination_returns_extra_contents(db_session_with_containers: Session): assert pagination.data message = pagination.data[0] - assert message.extra_contents == [ - { - "type": "human_input", - "workflow_run_id": fixture.message.workflow_run_id, - "submitted": True, - "form_submission_data": { - "node_id": fixture.form.node_id, - "node_title": fixture.node_title, - "rendered_content": fixture.form.rendered_content, - "action_id": fixture.action_id, - "action_text": fixture.action_text, - }, - } - ] + assert len(message.extra_contents) == 1 + content = message.extra_contents[0] + assert content["type"] == "human_input" + assert content["workflow_run_id"] == fixture.message.workflow_run_id + assert content["submitted"] is True + + form_submission_data = content["form_submission_data"] + assert form_submission_data["node_id"] == fixture.form.node_id + assert form_submission_data["node_title"] == fixture.node_title + assert form_submission_data["rendered_content"] == fixture.form.rendered_content + assert form_submission_data["action_id"] == fixture.action_id + assert form_submission_data["action_text"] == fixture.action_text + + form_definition = content["form_definition"] + assert form_definition["form_id"] == fixture.form.id + assert form_definition["node_id"] == fixture.form.node_id + assert form_definition["node_title"] == fixture.node_title + assert form_definition["form_content"] == fixture.form.rendered_content + + +@pytest.mark.usefixtures("flask_req_ctx_with_containers") +def test_pagination_returns_waiting_human_input_extra_contents(db_session_with_containers: Session): + fixture = create_human_input_message_fixture(db_session_with_containers) + fixture.form.status = HumanInputFormStatus.WAITING + fixture.form.selected_action_id = None + fixture.form.submitted_at = None + fixture.form.submitted_data = None + db_session_with_containers.commit() + + pagination = MessageService.pagination_by_first_id( + app_model=fixture.app, + user=fixture.account, + conversation_id=fixture.conversation.id, + first_id=None, + limit=10, + ) + + assert pagination.data + message = pagination.data[0] + assert len(message.extra_contents) == 1 + content = message.extra_contents[0] + assert content["type"] == "human_input" + assert content["workflow_run_id"] == fixture.message.workflow_run_id + assert content["submitted"] is False + assert "form_submission_data" not in content + + form_definition = content["form_definition"] + assert form_definition["form_id"] == fixture.form.id + assert form_definition["node_id"] == fixture.form.node_id + assert form_definition["node_title"] == fixture.node_title + assert form_definition["form_content"] == fixture.form.rendered_content + assert form_definition["display_in_ui"] is True diff --git a/api/tests/test_containers_integration_tests/services/test_message_service_extra_contents.py b/api/tests/test_containers_integration_tests/services/test_message_service_extra_contents.py index f2cb667204..d76a925d0e 100644 --- a/api/tests/test_containers_integration_tests/services/test_message_service_extra_contents.py +++ b/api/tests/test_containers_integration_tests/services/test_message_service_extra_contents.py @@ -4,6 +4,7 @@ from decimal import Decimal import pytest +from libs.helper import to_timestamp from models.enums import ConversationFromSource from models.model import Message from services import message_service @@ -47,17 +48,37 @@ def test_attach_message_extra_contents_assigns_serialized_payload(db_session_wit message_service.attach_message_extra_contents(messages) + form = fixture.form + assert messages[0].extra_contents == [ { "type": "human_input", "workflow_run_id": fixture.message.workflow_run_id, "submitted": True, + "form_definition": { + "form_id": form.id, + "node_id": form.node_id, + "node_title": "Approval", + "form_content": "Rendered block", + "inputs": [], + "actions": [ + { + "id": "approve", + "title": "Approve request", + "button_style": "default", + } + ], + "display_in_ui": True, + "resolved_default_values": {}, + "expiration_time": to_timestamp(form.expiration_time), + }, "form_submission_data": { "node_id": fixture.form.node_id, "node_title": fixture.node_title, "rendered_content": fixture.form.rendered_content, "action_id": fixture.action_id, "action_text": fixture.action_text, + "submitted_data": {"name": "Alice"}, }, } ] diff --git a/api/tests/test_containers_integration_tests/services/workflow/test_workflow_event_snapshot_service.py b/api/tests/test_containers_integration_tests/services/workflow/test_workflow_event_snapshot_service.py new file mode 100644 index 0000000000..ce8b29c840 --- /dev/null +++ b/api/tests/test_containers_integration_tests/services/workflow/test_workflow_event_snapshot_service.py @@ -0,0 +1,174 @@ +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from typing import override +from uuid import uuid4 + +from sqlalchemy import Engine, delete +from sqlalchemy.orm import Session, sessionmaker + +from core.app.app_config.entities import WorkflowUIBasedAppConfig +from core.app.entities.app_invoke_entities import InvokeFrom, WorkflowAppGenerateEntity +from core.app.layers.pause_state_persist_layer import WorkflowResumptionContext, _WorkflowGenerateEntityWrapper +from graphon.entities.pause_reason import HumanInputRequired +from graphon.enums import WorkflowExecutionStatus +from graphon.nodes.human_input.entities import SelectInputConfig, StringListSource, UserActionConfig +from graphon.nodes.human_input.enums import HumanInputFormStatus, ValueSourceType +from graphon.runtime import GraphRuntimeState, VariablePool +from models.enums import CreatorUserRole +from models.human_input import HumanInputForm +from models.model import AppMode +from models.workflow import WorkflowRun +from repositories.entities.workflow_pause import WorkflowPauseEntity +from services.workflow_event_snapshot_service import _build_snapshot_events + + +@dataclass(frozen=True) +class _FakePauseEntity(WorkflowPauseEntity): + pause_id: str + workflow_run_id: str + paused_at_value: datetime + pause_reasons: Sequence[HumanInputRequired] + + @property + @override + def id(self) -> str: + return self.pause_id + + @property + @override + def workflow_execution_id(self) -> str: + return self.workflow_run_id + + @override + def get_state(self) -> bytes: + raise AssertionError("state is not required for snapshot tests") + + @property + @override + def resumed_at(self) -> datetime | None: + return None + + @property + @override + def paused_at(self) -> datetime: + return self.paused_at_value + + @override + def get_pause_reasons(self) -> Sequence[HumanInputRequired]: + return self.pause_reasons + + +def _build_resumption_context(workflow_run_id: str) -> WorkflowResumptionContext: + app_config = WorkflowUIBasedAppConfig( + tenant_id=str(uuid4()), + app_id=str(uuid4()), + app_mode=AppMode.WORKFLOW, + workflow_id=str(uuid4()), + ) + generate_entity = WorkflowAppGenerateEntity( + task_id="task-1", + app_config=app_config, + inputs={}, + files=[], + user_id=str(uuid4()), + stream=True, + invoke_from=InvokeFrom.EXPLORE, + call_depth=0, + workflow_execution_id=workflow_run_id, + ) + runtime_state = GraphRuntimeState(variable_pool=VariablePool(), start_at=0.0) + runtime_state.variable_pool.add(("start", "options"), ["approve", "reject"]) + wrapper = _WorkflowGenerateEntityWrapper(entity=generate_entity) + return WorkflowResumptionContext( + generate_entity=wrapper, + serialized_graph_runtime_state=runtime_state.dumps(), + ) + + +def _build_workflow_run(workflow_run_id: str) -> WorkflowRun: + return WorkflowRun( + id=workflow_run_id, + tenant_id=str(uuid4()), + app_id=str(uuid4()), + workflow_id=str(uuid4()), + type="workflow", + triggered_from="app-run", + version="v1", + graph=None, + inputs="{}", + status=WorkflowExecutionStatus.PAUSED, + outputs="{}", + error=None, + elapsed_time=0.0, + total_tokens=0, + total_steps=0, + created_by_role=CreatorUserRole.END_USER, + created_by=str(uuid4()), + created_at=datetime(2024, 1, 1, tzinfo=UTC), + ) + + +def test_build_snapshot_events_resolves_variable_select_options(db_session_with_containers: Session) -> None: + engine = db_session_with_containers.get_bind() + assert isinstance(engine, Engine) + test_tenant_id = str(uuid4()) + test_app_id = str(uuid4()) + workflow_run_id = str(uuid4()) + form = HumanInputForm( + tenant_id=test_tenant_id, + app_id=test_app_id, + workflow_run_id=workflow_run_id, + node_id="node-id", + form_definition='{"display_in_ui": true}', + rendered_content="Rendered", + status=HumanInputFormStatus.WAITING, + expiration_time=(datetime.now(UTC) + timedelta(hours=1)).replace(tzinfo=None), + ) + db_session_with_containers.add(form) + db_session_with_containers.commit() + db_session_with_containers.refresh(form) + + reason = HumanInputRequired( + form_id=form.id, + form_content="Rendered", + inputs=[ + SelectInputConfig( + output_variable_name="decision", + option_source=StringListSource( + type=ValueSourceType.VARIABLE, + selector=["start", "options"], + value=[], + ), + ) + ], + actions=[UserActionConfig(id="approve", title="Approve")], + node_id="node-id", + node_title="Human Input", + ) + pause_entity = _FakePauseEntity( + pause_id=str(uuid4()), + workflow_run_id=workflow_run_id, + paused_at_value=datetime.now(UTC), + pause_reasons=[reason], + ) + + session_maker = sessionmaker(bind=engine, expire_on_commit=False) + events = _build_snapshot_events( + workflow_run=_build_workflow_run(workflow_run_id), + node_snapshots=[], + task_id="task-1", + message_context=None, + pause_entity=pause_entity, + resumption_context=_build_resumption_context(workflow_run_id), + session_maker=session_maker, + ) + + human_input_events = [event for event in events if event.get("event") == "human_input_required"] + assert len(human_input_events) == 1 + assert human_input_events[0]["data"]["inputs"][0]["option_source"]["value"] == ["approve", "reject"] + + db_session_with_containers.execute(delete(HumanInputForm).where(HumanInputForm.id == form.id)) + db_session_with_containers.commit() diff --git a/api/tests/unit_tests/controllers/console/app/test_workflow_pause_details_api.py b/api/tests/unit_tests/controllers/console/app/test_workflow_pause_details_api.py index 58274f1688..78e1b0c46f 100644 --- a/api/tests/unit_tests/controllers/console/app/test_workflow_pause_details_api.py +++ b/api/tests/unit_tests/controllers/console/app/test_workflow_pause_details_api.py @@ -13,7 +13,6 @@ from controllers.web.error import NotFoundError from graphon.entities.pause_reason import HumanInputRequired from graphon.enums import WorkflowExecutionStatus from graphon.nodes.human_input.entities import ParagraphInputConfig, UserActionConfig -from graphon.nodes.human_input.enums import FormInputType from libs import login as login_lib from models.account import Account, AccountStatus, TenantAccountRole from models.workflow import WorkflowRun @@ -66,7 +65,7 @@ def test_pause_details_returns_backstage_input_url(app: Flask, monkeypatch: pyte reason = HumanInputRequired( form_id="form-1", form_content="content", - inputs=[ParagraphInputConfig(type=FormInputType.PARAGRAPH, output_variable_name="name")], + inputs=[ParagraphInputConfig(output_variable_name="name")], actions=[UserActionConfig(id="approve", title="Approve")], node_id="node-1", node_title="Ask Name", diff --git a/api/tests/unit_tests/controllers/service_api/app/test_hitl_service_api.py b/api/tests/unit_tests/controllers/service_api/app/test_hitl_service_api.py index ff668ac60a..b2920f93d3 100644 --- a/api/tests/unit_tests/controllers/service_api/app/test_hitl_service_api.py +++ b/api/tests/unit_tests/controllers/service_api/app/test_hitl_service_api.py @@ -593,7 +593,11 @@ class TestHitlServiceApi: form_id="form-1", form_content="Rendered", inputs=[ - ParagraphInputConfig(type=FormInputType.PARAGRAPH, output_variable_name="field", default=None), + ParagraphInputConfig( + type=FormInputType.PARAGRAPH, + output_variable_name="field", + default=None, + ), ], actions=[UserActionConfig(id="approve", title="Approve")], display_in_ui=True, @@ -607,7 +611,7 @@ class TestHitlServiceApi: paused_nodes=["node-id"], ) - runtime_state = SimpleNamespace(total_tokens=0, node_run_steps=0) + runtime_state = SimpleNamespace(total_tokens=0, node_run_steps=0, variable_pool=VariablePool()) responses = converter.workflow_pause_to_stream_response( event=queue_event, task_id="task", diff --git a/api/tests/unit_tests/controllers/service_api/app/test_human_input_form.py b/api/tests/unit_tests/controllers/service_api/app/test_human_input_form.py index 5d1c4b4e26..ce000ab5a2 100644 --- a/api/tests/unit_tests/controllers/service_api/app/test_human_input_form.py +++ b/api/tests/unit_tests/controllers/service_api/app/test_human_input_form.py @@ -12,6 +12,7 @@ import pytest from flask import Flask from werkzeug.exceptions import NotFound +from controllers.common.human_input import HumanInputFormSubmitPayload from controllers.service_api.app.human_input_form import WorkflowHumanInputFormApi from models.human_input import RecipientType from tests.unit_tests.controllers.service_api.conftest import _unwrap @@ -20,7 +21,7 @@ from tests.unit_tests.controllers.service_api.conftest import _unwrap class TestWorkflowHumanInputFormApi: def test_get_success(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: definition = SimpleNamespace( - model_dump=lambda: { + model_dump=lambda **_kwargs: { "rendered_content": "Rendered form content", "inputs": [{"output_variable_name": "name"}], "default_values": {"name": "Alice", "age": 30, "meta": {"k": "v"}}, @@ -36,6 +37,9 @@ class TestWorkflowHumanInputFormApi: ) service_mock = Mock() service_mock.get_form_by_token.return_value = form + service_mock.resolve_form_inputs.return_value = [ + SimpleNamespace(model_dump=lambda **_kwargs: {"output_variable_name": "name"}) + ] workflow_module = sys.modules["controllers.service_api.app.human_input_form"] monkeypatch.setattr(workflow_module, "HumanInputService", lambda _engine: service_mock) monkeypatch.setattr(workflow_module, "db", SimpleNamespace(engine=object())) @@ -56,8 +60,54 @@ class TestWorkflowHumanInputFormApi: "expiration_time": int(form.expiration_time.timestamp()), } service_mock.get_form_by_token.assert_called_once_with("token-1") + service_mock.resolve_form_inputs.assert_called_once_with(form) service_mock.ensure_form_active.assert_called_once_with(form) + def test_get_resolves_runtime_select_values(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + definition = SimpleNamespace( + model_dump=lambda **_kwargs: { + "rendered_content": "Rendered form content", + "inputs": [ + { + "output_variable_name": "decision", + "option_source": {"type": "variable", "selector": ["start", "options"], "value": []}, + } + ], + "default_values": {}, + "user_actions": [{"id": "approve", "title": "Approve"}], + } + ) + form = SimpleNamespace( + app_id="app-1", + tenant_id="tenant-1", + recipient_type=RecipientType.STANDALONE_WEB_APP, + expiration_time=datetime(2099, 1, 1, tzinfo=UTC), + get_definition=lambda: definition, + ) + resolved_input = SimpleNamespace( + model_dump=lambda **_kwargs: { + "output_variable_name": "decision", + "option_source": {"type": "variable", "selector": ["start", "options"], "value": ["approve", "reject"]}, + } + ) + service_mock = Mock() + service_mock.get_form_by_token.return_value = form + service_mock.resolve_form_inputs.return_value = [resolved_input] + workflow_module = sys.modules["controllers.service_api.app.human_input_form"] + monkeypatch.setattr(workflow_module, "HumanInputService", lambda _engine: service_mock) + monkeypatch.setattr(workflow_module, "db", SimpleNamespace(engine=object())) + + api = WorkflowHumanInputFormApi() + handler = _unwrap(api.get) + app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1") + + with app.test_request_context("/form/human_input/token-1", method="GET"): + response = handler(api, app_model=app_model, form_token="token-1") + + payload = json.loads(response.get_data(as_text=True)) + assert payload["inputs"][0]["option_source"]["value"] == ["approve", "reject"] + service_mock.resolve_form_inputs.assert_called_once_with(form) + def test_get_form_not_in_app(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: form = SimpleNamespace( app_id="another-app", @@ -146,6 +196,71 @@ class TestWorkflowHumanInputFormApi: submission_end_user_id="end-user-1", ) + def test_post_accepts_select_file_and_file_list_inputs(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + form = SimpleNamespace( + app_id="app-1", + tenant_id="tenant-1", + recipient_type=RecipientType.STANDALONE_WEB_APP, + ) + service_mock = Mock() + service_mock.get_form_by_token.return_value = form + workflow_module = sys.modules["controllers.service_api.app.human_input_form"] + monkeypatch.setattr(workflow_module, "HumanInputService", lambda _engine: service_mock) + monkeypatch.setattr(workflow_module, "db", SimpleNamespace(engine=object())) + + api = WorkflowHumanInputFormApi() + handler = _unwrap(api.post) + app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1") + end_user = SimpleNamespace(id="end-user-1") + inputs = { + "decision": "approve", + "attachment": { + "transfer_method": "local_file", + "upload_file_id": "4e0d1b87-52f2-49f6-b8c6-95cd9c954b3e", + "type": "document", + }, + "attachments": [ + { + "transfer_method": "local_file", + "upload_file_id": "1a77f0df-c0e6-461c-987c-e72526f341ee", + "type": "document", + }, + { + "transfer_method": "remote_url", + "url": "https://example.com/report.pdf", + "type": "document", + }, + ], + } + + with app.test_request_context( + "/form/human_input/token-1", + method="POST", + json={"inputs": inputs, "action": "approve", "user": "external-1"}, + ): + response, status = handler(api, app_model=app_model, end_user=end_user, form_token="token-1") + + assert response == {} + assert status == 200 + service_mock.submit_form_by_token.assert_called_once_with( + recipient_type=RecipientType.STANDALONE_WEB_APP, + form_token="token-1", + selected_action_id="approve", + form_data=inputs, + submission_end_user_id="end-user-1", + ) + + def test_submit_payload_schema_documents_select_file_and_file_list_inputs(self) -> None: + schema = HumanInputFormSubmitPayload.model_json_schema() + + inputs_schema = schema["properties"]["inputs"] + assert "select input" in inputs_schema["description"] + examples = inputs_schema["examples"] + assert examples[0]["decision"] == "approve" + assert examples[0]["attachment"]["transfer_method"] == "local_file" + assert examples[0]["attachment"]["upload_file_id"] == "4e0d1b87-52f2-49f6-b8c6-95cd9c954b3e" + assert examples[0]["attachments"][1]["transfer_method"] == "remote_url" + @pytest.mark.parametrize( "recipient_type", [ diff --git a/api/tests/unit_tests/controllers/web/test_human_input_file_upload.py b/api/tests/unit_tests/controllers/web/test_human_input_file_upload.py new file mode 100644 index 0000000000..a01a74b535 --- /dev/null +++ b/api/tests/unit_tests/controllers/web/test_human_input_file_upload.py @@ -0,0 +1,217 @@ +"""Unit tests for HITL human input file upload endpoints.""" + +from __future__ import annotations + +from datetime import datetime +from io import BytesIO +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest +from flask import Flask + +import controllers.web.human_input_file_upload as upload_module +from controllers.common.errors import NoFileUploadedError +from controllers.web.human_input_file_upload import ( + HumanInputFileUploadApi, + InvalidUploadTokenForbiddenError, + InvalidUploadTokenUnauthorizedError, +) + + +@pytest.fixture +def app() -> Flask: + app = Flask(__name__) + app.config["TESTING"] = True + return app + + +def _upload_context() -> SimpleNamespace: + return SimpleNamespace( + form_id="form-1", + upload_token_id="token-row-1", + owner=SimpleNamespace(id="owner-1", current_tenant_id="tenant-1"), + ) + + +def _upload_file() -> SimpleNamespace: + return SimpleNamespace( + id="file-1", + name="sample.txt", + size=7, + extension="txt", + mime_type="text/plain", + created_by="end-user-1", + created_at=datetime(2024, 1, 1), + tenant_id="tenant-1", + source_url="signed-source-url", + ) + + +def _patch_upload_service(monkeypatch: pytest.MonkeyPatch, service: MagicMock) -> tuple[MagicMock, dict[str, object]]: + workflow_run_repository = MagicMock() + repo_factory = MagicMock(return_value=workflow_run_repository) + captured: dict[str, object] = {} + + def _service_factory(session_factory, workflow_run_repository): + captured["session_factory"] = session_factory + captured["workflow_run_repository"] = workflow_run_repository + return service + + monkeypatch.setattr( + upload_module.DifyAPIRepositoryFactory, + "create_api_workflow_run_repository", + repo_factory, + ) + monkeypatch.setattr(upload_module, "HumanInputFileUploadService", _service_factory) + return repo_factory, captured + + +def test_human_input_file_upload_route_uses_unified_path() -> None: + urls = { + url for _resource, resource_urls, _route_doc, _kwargs in upload_module.web_ns.resources for url in resource_urls + } + + assert "/human-input-forms/files" in urls + assert "/form/human_input/files/upload" not in urls + assert "/form/human_input/files/remote-upload" not in urls + + +def test_local_upload_requires_authorization_before_reading_files(app: Flask) -> None: + data = {"file": (BytesIO(b"content"), "sample.txt")} + + with app.test_request_context( + "/api/human-input-forms/files", + method="POST", + data=data, + content_type="multipart/form-data", + ): + with pytest.raises(InvalidUploadTokenUnauthorizedError): + HumanInputFileUploadApi().post() + + +def test_local_upload_ignores_source_and_records_form_file_link(monkeypatch: pytest.MonkeyPatch, app: Flask) -> None: + service = MagicMock() + service.validate_upload_token.return_value = _upload_context() + repo_factory, captured = _patch_upload_service(monkeypatch, service) + + file_service = MagicMock() + file_service.upload_file.return_value = _upload_file() + file_service_cls = MagicMock(return_value=file_service) + monkeypatch.setattr(upload_module, "FileService", file_service_cls) + monkeypatch.setattr(upload_module, "db", SimpleNamespace(engine=object())) + + data = { + "file": (BytesIO(b"content"), "sample.txt"), + "source": "datasets", + } + with app.test_request_context( + "/api/human-input-forms/files", + method="POST", + headers={"Authorization": "bearer hitl_upload_token-1"}, + data=data, + content_type="multipart/form-data", + ): + result, status = HumanInputFileUploadApi().post() + + assert status == 201 + assert result["id"] == "file-1" + file_service.upload_file.assert_called_once() + assert file_service.upload_file.call_args.kwargs["source"] is None + assert file_service.upload_file.call_args.kwargs["user"].id == "owner-1" + repo_factory.assert_called_once() + assert captured["workflow_run_repository"] is repo_factory.return_value + service.record_upload_file.assert_called_once_with( + context=service.validate_upload_token.return_value, + file_id="file-1", + ) + + +def test_local_upload_missing_file_raises_after_valid_token(monkeypatch: pytest.MonkeyPatch, app: Flask) -> None: + service = MagicMock() + service.validate_upload_token.return_value = _upload_context() + _patch_upload_service(monkeypatch, service) + monkeypatch.setattr(upload_module, "db", SimpleNamespace(engine=object())) + + with app.test_request_context( + "/api/human-input-forms/files", + method="POST", + headers={"Authorization": "bearer hitl_upload_token-1"}, + content_type="multipart/form-data", + ): + with pytest.raises(NoFileUploadedError): + HumanInputFileUploadApi().post() + + service.validate_upload_token.assert_called_once_with("hitl_upload_token-1") + + +def test_remote_upload_validates_token_before_fetching_remote_url(monkeypatch: pytest.MonkeyPatch, app: Flask) -> None: + service = MagicMock() + service.validate_upload_token.side_effect = InvalidUploadTokenForbiddenError() + _patch_upload_service(monkeypatch, service) + monkeypatch.setattr(upload_module, "db", SimpleNamespace(engine=object())) + ssrf_proxy = MagicMock() + monkeypatch.setattr(upload_module, "ssrf_proxy", ssrf_proxy) + + with app.test_request_context( + "/api/human-input-forms/files", + method="POST", + headers={"Authorization": "Bearer hitl_upload_token-1"}, + data={"url": "https://example.com/file.txt"}, + content_type="multipart/form-data", + ): + with pytest.raises(InvalidUploadTokenForbiddenError): + HumanInputFileUploadApi().post() + + ssrf_proxy.head.assert_not_called() + ssrf_proxy.get.assert_not_called() + + +def test_remote_upload_records_form_file_link(monkeypatch: pytest.MonkeyPatch, app: Flask) -> None: + service = MagicMock() + service.validate_upload_token.return_value = _upload_context() + _patch_upload_service(monkeypatch, service) + monkeypatch.setattr(upload_module, "db", SimpleNamespace(engine=object())) + + response = MagicMock() + response.status_code = 200 + response.content = b"remote" + response.request.method = "GET" + ssrf_proxy = MagicMock() + ssrf_proxy.head.return_value = response + monkeypatch.setattr(upload_module, "ssrf_proxy", ssrf_proxy) + monkeypatch.setattr( + upload_module.helpers, + "guess_file_info_from_response", + lambda _response: SimpleNamespace(filename="sample.txt", extension="txt", mimetype="text/plain", size=6), + ) + + file_service = MagicMock() + file_service.upload_file.return_value = _upload_file() + file_service_cls = MagicMock(return_value=file_service) + file_service_cls.is_file_size_within_limit.return_value = True + monkeypatch.setattr(upload_module, "FileService", file_service_cls) + monkeypatch.setattr( + upload_module.file_helpers, + "get_signed_file_url", + lambda upload_file_id: f"signed:{upload_file_id}", + ) + + with app.test_request_context( + "/api/human-input-forms/files", + method="POST", + headers={"Authorization": "Bearer hitl_upload_token-1"}, + data={"url": "https://example.com/file.txt"}, + content_type="multipart/form-data", + ): + result, status = HumanInputFileUploadApi().post() + + assert status == 201 + assert result["url"] == "signed:file-1" + file_service.upload_file.assert_called_once() + assert file_service.upload_file.call_args.kwargs["source_url"] == "https://example.com/file.txt" + assert file_service.upload_file.call_args.kwargs["user"].id == "owner-1" + service.record_upload_file.assert_called_once_with( + context=service.validate_upload_token.return_value, + file_id="file-1", + ) diff --git a/api/tests/unit_tests/controllers/web/test_human_input_form.py b/api/tests/unit_tests/controllers/web/test_human_input_form.py index f9d49237b7..0caeae2cee 100644 --- a/api/tests/unit_tests/controllers/web/test_human_input_form.py +++ b/api/tests/unit_tests/controllers/web/test_human_input_form.py @@ -15,10 +15,14 @@ from werkzeug.exceptions import Forbidden import controllers.web.human_input_form as human_input_module import controllers.web.site as site_module from controllers.web.error import WebFormRateLimitExceededError +from graphon.nodes.human_input.entities import ParagraphInputConfig, SelectInputConfig, StringListSource +from graphon.nodes.human_input.enums import ValueSourceType from models.human_input import RecipientType +from services.feature_service import FeatureModel from services.human_input_service import FormExpiredError HumanInputFormApi = human_input_module.HumanInputFormApi +HumanInputFormUploadTokenApi = human_input_module.HumanInputFormUploadTokenApi TenantStatus = human_input_module.TenantStatus @@ -63,7 +67,7 @@ def test_get_form_includes_site(monkeypatch: pytest.MonkeyPatch, app: Flask): expiration_time = datetime(2099, 1, 1, tzinfo=UTC) class _FakeDefinition: - def model_dump(self): + def model_dump(self, mode: str | None = None): return { "form_content": "Raw content", "rendered_content": "Rendered {{#$output.name#}}", @@ -117,6 +121,8 @@ def test_get_form_includes_site(monkeypatch: pytest.MonkeyPatch, app: Flask): # Patch service to return fake form. service_mock = MagicMock() service_mock.get_form_by_token.return_value = form + resolved_input = ParagraphInputConfig(output_variable_name="name") + service_mock.resolve_form_inputs.return_value = [resolved_input] monkeypatch.setattr(human_input_module, "HumanInputService", lambda engine: service_mock) # Patch db session. @@ -142,7 +148,7 @@ def test_get_form_includes_site(monkeypatch: pytest.MonkeyPatch, app: Flask): "expiration_time", } assert body["form_content"] == "Rendered {{#$output.name#}}" - assert body["inputs"] == [{"type": "text", "output_variable_name": "name", "default": None}] + assert body["inputs"] == [resolved_input.model_dump(mode="json")] assert body["resolved_default_values"] == {"name": "Alice", "age": "30", "meta": '{"k": "v"}'} assert body["user_actions"] == [{"id": "approve", "title": "Approve", "button_style": "default"}] assert body["expiration_time"] == int(expiration_time.timestamp()) @@ -180,13 +186,158 @@ def test_get_form_includes_site(monkeypatch: pytest.MonkeyPatch, app: Flask): limiter_mock.increment_rate_limit.assert_called_once_with("203.0.113.10") +def test_get_form_uses_runtime_select_options(monkeypatch: pytest.MonkeyPatch, app: Flask): + """GET returns variable-backed select options resolved from runtime state.""" + + expiration_time = datetime(2099, 1, 1, tzinfo=UTC) + configured_inputs = [ + { + "type": "select", + "output_variable_name": "decision", + "option_source": { + "type": "variable", + "selector": ["start", "options"], + "value": ["configured"], + }, + } + ] + runtime_inputs = [ + SelectInputConfig( + output_variable_name="decision", + option_source=StringListSource( + type=ValueSourceType.VARIABLE, + selector=["start", "options"], + value=["approve", "reject"], + ), + ) + ] + + class _FakeDefinition: + def model_dump(self, mode: str | None = None): + return { + "form_content": "Raw content", + "rendered_content": "Rendered", + "inputs": configured_inputs, + "default_values": {}, + "user_actions": [], + } + + class _FakeForm: + def __init__(self, expiration: datetime): + self.workflow_run_id = "workflow-1" + self.app_id = "app-1" + self.tenant_id = "tenant-1" + self.recipient_type = RecipientType.STANDALONE_WEB_APP + self.expiration_time = expiration + + def get_definition(self): + return _FakeDefinition() + + limiter_mock = MagicMock() + limiter_mock.is_rate_limited.return_value = False + monkeypatch.setattr(human_input_module, "_FORM_ACCESS_RATE_LIMITER", limiter_mock) + monkeypatch.setattr(human_input_module, "extract_remote_ip", lambda req: "203.0.113.10") + + tenant = SimpleNamespace( + id="tenant-1", + status=TenantStatus.NORMAL, + plan="basic", + custom_config_dict={}, + ) + app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1", tenant=tenant, enable_site=True) + site_model = SimpleNamespace( + title="My Site", + icon_type="emoji", + icon="robot", + icon_background="#fff", + description="desc", + default_language="en", + chat_color_theme="light", + chat_color_theme_inverted=False, + copyright=None, + privacy_policy=None, + custom_disclaimer=None, + prompt_public=False, + show_workflow_steps=True, + use_icon_as_answer_icon=False, + ) + + form = _FakeForm(expiration_time) + service_mock = MagicMock() + service_mock.get_form_by_token.return_value = form + service_mock.resolve_form_inputs.return_value = runtime_inputs + monkeypatch.setattr(human_input_module, "HumanInputService", lambda engine: service_mock) + monkeypatch.setattr(human_input_module, "db", _FakeDB(_FakeSession({"App": app_model, "Site": site_model}))) + + def mock_get_features(tenant_id: str, exclude_vector_space: bool = False): + return FeatureModel(can_replace_logo=True) + + monkeypatch.setattr(site_module.FeatureService, "get_features", mock_get_features) + + with app.test_request_context("/api/form/human_input/token-1", method="GET"): + response = HumanInputFormApi().get("token-1") + + body = json.loads(response.get_data(as_text=True)) + assert body["inputs"] == [input_config.model_dump(mode="json") for input_config in runtime_inputs] + service_mock.resolve_form_inputs.assert_called_once_with(form) + + +def test_create_upload_token_returns_token_and_form_expiration(monkeypatch: pytest.MonkeyPatch, app: Flask): + """POST returns a HITL upload token for an active form token.""" + + expiration_time = datetime(2099, 1, 1, tzinfo=UTC) + service_mock = MagicMock() + service_mock.issue_upload_token.return_value = SimpleNamespace( + upload_token="hitl_upload_token-1", + expires_at=expiration_time, + ) + workflow_run_repository = MagicMock() + repo_factory = MagicMock(return_value=workflow_run_repository) + captured: dict[str, object] = {} + + def _service_factory(session_factory, workflow_run_repository): + captured["session_factory"] = session_factory + captured["workflow_run_repository"] = workflow_run_repository + return service_mock + + monkeypatch.setattr( + human_input_module.DifyAPIRepositoryFactory, + "create_api_workflow_run_repository", + repo_factory, + ) + monkeypatch.setattr( + human_input_module, + "HumanInputFileUploadService", + _service_factory, + ) + monkeypatch.setattr(human_input_module, "db", SimpleNamespace(engine=object())) + + limiter_mock = MagicMock() + limiter_mock.is_rate_limited.return_value = False + monkeypatch.setattr(human_input_module, "_FORM_UPLOAD_TOKEN_RATE_LIMITER", limiter_mock) + monkeypatch.setattr(human_input_module, "extract_remote_ip", lambda req: "203.0.113.10") + + with app.test_request_context("/api/form/human_input/token-1/upload-token", method="POST"): + result, status = HumanInputFormUploadTokenApi().post("token-1") + + assert status == 200 + assert result == { + "upload_token": "hitl_upload_token-1", + "expires_at": int(expiration_time.timestamp()), + } + repo_factory.assert_called_once() + assert captured["workflow_run_repository"] is workflow_run_repository + service_mock.issue_upload_token.assert_called_once_with("token-1") + limiter_mock.increment_rate_limit.assert_called_once_with("203.0.113.10") + + def test_get_form_allows_backstage_token(monkeypatch: pytest.MonkeyPatch, app: Flask): """GET returns form payload for backstage token.""" expiration_time = datetime(2099, 1, 2, tzinfo=UTC) class _FakeDefinition: - def model_dump(self): + def model_dump(self, mode: str | None = None): return { "form_content": "Raw content", "rendered_content": "Rendered", @@ -237,6 +388,7 @@ def test_get_form_allows_backstage_token(monkeypatch: pytest.MonkeyPatch, app: F service_mock = MagicMock() service_mock.get_form_by_token.return_value = form + service_mock.resolve_form_inputs.return_value = [] monkeypatch.setattr(human_input_module, "HumanInputService", lambda engine: service_mock) db_stub = _FakeDB(_FakeSession({"WorkflowRun": workflow_run, "App": app_model, "Site": site_model})) @@ -305,7 +457,7 @@ def test_get_form_raises_forbidden_when_site_missing(monkeypatch: pytest.MonkeyP expiration_time = datetime(2099, 1, 3, tzinfo=UTC) class _FakeDefinition: - def model_dump(self): + def model_dump(self, mode: str | None = None): return { "form_content": "Raw content", "rendered_content": "Rendered", diff --git a/api/tests/unit_tests/core/app/apps/common/test_workflow_response_converter_human_input.py b/api/tests/unit_tests/core/app/apps/common/test_workflow_response_converter_human_input.py index 1bef6f69cd..9df351fb7a 100644 --- a/api/tests/unit_tests/core/app/apps/common/test_workflow_response_converter_human_input.py +++ b/api/tests/unit_tests/core/app/apps/common/test_workflow_response_converter_human_input.py @@ -7,6 +7,7 @@ from core.app.entities.queue_entities import QueueHumanInputFormFilledEvent, Que from core.workflow.system_variables import build_system_variables from graphon.entities import WorkflowStartReason from graphon.runtime import GraphRuntimeState, VariablePool +from graphon.variables.segments import StringSegment def _build_converter(): @@ -63,6 +64,37 @@ def test_human_input_form_filled_stream_response_contains_rendered_content(): assert resp.data.action_id == "Approve" +def test_human_input_form_filled_stream_response_serializes_submitted_data(): + converter = _build_converter() + converter.workflow_start_to_stream_response( + task_id="task-1", + workflow_run_id="run-1", + workflow_id="wf-1", + reason=WorkflowStartReason.INITIAL, + ) + + queue_event = QueueHumanInputFormFilledEvent( + node_execution_id="exec-1", + node_id="node-1", + node_type="human-input", + node_title="Human Input", + rendered_content="# Title\nvalue", + action_id="Approve", + action_text="Approve", + submitted_data={ + "decision": StringSegment(value="approve"), + "comment": StringSegment(value="looks good"), + }, + ) + + resp = converter.human_input_form_filled_to_stream_response(event=queue_event, task_id="task-1") + + assert resp.data.submitted_data == { + "decision": "approve", + "comment": "looks good", + } + + def test_human_input_form_timeout_stream_response_contains_timeout_metadata(): converter = _build_converter() converter.workflow_start_to_stream_response( diff --git a/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_core.py b/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_core.py index 3949c41eae..77cd81db58 100644 --- a/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_core.py +++ b/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_core.py @@ -9,6 +9,7 @@ from core.app.apps.workflow_app_runner import WorkflowBasedAppRunner from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom from core.app.entities.queue_entities import ( QueueAgentLogEvent, + QueueHumanInputFormFilledEvent, QueueIterationCompletedEvent, QueueLoopCompletedEvent, QueueNodeExceptionEvent, @@ -30,6 +31,7 @@ from graphon.graph_events import ( NodeRunAgentLogEvent, NodeRunExceptionEvent, NodeRunFailedEvent, + NodeRunHumanInputFormFilledEvent, NodeRunIterationSucceededEvent, NodeRunLoopFailedEvent, NodeRunRetryEvent, @@ -39,6 +41,7 @@ from graphon.graph_events import ( ) from graphon.node_events import NodeRunResult from graphon.runtime import GraphRuntimeState, VariablePool +from graphon.variables.segments import StringSegment from graphon.variables.variables import StringVariable @@ -363,6 +366,42 @@ class TestWorkflowBasedAppRunner: assert any(isinstance(event, QueueIterationCompletedEvent) for event in published) assert any(isinstance(event, QueueLoopCompletedEvent) for event in published) + def test_handle_human_input_form_filled_event_preserves_submitted_data(self): + published: list[object] = [] + + class _QueueManager: + def publish(self, event, publish_from): + published.append(event) + + runner = WorkflowBasedAppRunner(queue_manager=_QueueManager(), app_id="app") + graph_runtime_state = GraphRuntimeState( + variable_pool=VariablePool.from_bootstrap( + system_variables=default_system_variables(), + user_inputs={}, + environment_variables=[], + ), + start_at=0.0, + ) + workflow_entry = SimpleNamespace(graph_engine=SimpleNamespace(graph_runtime_state=graph_runtime_state)) + + runner._handle_event( + workflow_entry, + NodeRunHumanInputFormFilledEvent( + id="exec", + node_id="node", + node_type=BuiltinNodeTypes.HUMAN_INPUT, + node_title="Human Input", + rendered_content="content", + action_id="approve", + action_text="Approve", + submitted_data={"decision": StringSegment(value="approve")}, + ), + ) + + queue_event = published[-1] + assert isinstance(queue_event, QueueHumanInputFormFilledEvent) + assert queue_event.submitted_data == {"decision": StringSegment(value="approve")} + @pytest.mark.parametrize( ("event_factory", "queue_event_cls"), [ diff --git a/api/tests/unit_tests/core/app/apps/test_workflow_pause_events.py b/api/tests/unit_tests/core/app/apps/test_workflow_pause_events.py index 72a46a74c9..319e603b35 100644 --- a/api/tests/unit_tests/core/app/apps/test_workflow_pause_events.py +++ b/api/tests/unit_tests/core/app/apps/test_workflow_pause_events.py @@ -14,8 +14,14 @@ from core.workflow.system_variables import build_system_variables from graphon.entities import WorkflowStartReason from graphon.entities.pause_reason import HumanInputRequired from graphon.graph_events import GraphRunPausedEvent -from graphon.nodes.human_input.entities import ParagraphInputConfig, UserActionConfig -from graphon.nodes.human_input.enums import FormInputType +from graphon.nodes.human_input.entities import ( + ParagraphInputConfig, + SelectInputConfig, + StringListSource, + UserActionConfig, +) +from graphon.nodes.human_input.enums import ValueSourceType +from graphon.runtime import GraphRuntimeState, VariablePool from models.account import Account from models.human_input import RecipientType @@ -156,9 +162,7 @@ def test_queue_workflow_paused_event_to_stream_responses(monkeypatch: pytest.Mon reason = HumanInputRequired( form_id="form-1", form_content="Rendered", - inputs=[ - ParagraphInputConfig(type=FormInputType.PARAGRAPH, output_variable_name="field", default=None), - ], + inputs=[ParagraphInputConfig(output_variable_name="field")], actions=[UserActionConfig(id="approve", title="Approve")], node_id="node-id", node_title="Human Step", @@ -169,7 +173,7 @@ def test_queue_workflow_paused_event_to_stream_responses(monkeypatch: pytest.Mon paused_nodes=["node-id"], ) - runtime_state = SimpleNamespace(total_tokens=0, node_run_steps=0) + runtime_state = GraphRuntimeState(variable_pool=VariablePool(), start_at=0.0) responses = converter.workflow_pause_to_stream_response( event=queue_event, task_id="task", @@ -193,3 +197,70 @@ def test_queue_workflow_paused_event_to_stream_responses(monkeypatch: pytest.Mon assert hi_resp.data.display_in_ui is True assert hi_resp.data.form_token == "backstage-token" assert hi_resp.data.expiration_time == int(expiration_time.timestamp()) + + +def test_queue_workflow_paused_event_resolves_variable_select_options(monkeypatch: pytest.MonkeyPatch): + converter = _build_converter() + converter.workflow_start_to_stream_response( + task_id="task", + workflow_run_id="run-id", + workflow_id="workflow-id", + reason=WorkflowStartReason.INITIAL, + ) + + expiration_time = datetime(2024, 1, 1, tzinfo=UTC) + + class _FakeSession: + def execute(self, _stmt): + return [("form-1", expiration_time, '{"display_in_ui": true}')] + + def scalars(self, _stmt): + return [] + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + monkeypatch.setattr(workflow_response_converter, "Session", lambda **_: _FakeSession()) + monkeypatch.setattr(workflow_response_converter, "db", SimpleNamespace(engine=object())) + + reason = HumanInputRequired( + form_id="form-1", + form_content="Rendered", + inputs=[ + SelectInputConfig( + output_variable_name="decision", + option_source=StringListSource( + type=ValueSourceType.VARIABLE, + selector=["start", "options"], + value=[], + ), + ) + ], + actions=[UserActionConfig(id="approve", title="Approve")], + node_id="node-id", + node_title="Human Step", + ) + queue_event = QueueWorkflowPausedEvent( + reasons=[reason], + outputs={}, + paused_nodes=["node-id"], + ) + + runtime_state = GraphRuntimeState(variable_pool=VariablePool(), start_at=0.0) + runtime_state.variable_pool.add(("start", "options"), ["approve", "reject"]) + responses = converter.workflow_pause_to_stream_response( + event=queue_event, + task_id="task", + graph_runtime_state=runtime_state, + ) + + assert isinstance(responses[0], HumanInputRequiredResponse) + hi_resp = responses[0] + assert hi_resp.data.inputs[0].option_source.value == ["approve", "reject"] + + assert isinstance(responses[-1], WorkflowPauseStreamResponse) + pause_resp = responses[-1] + assert pause_resp.data.reasons[0]["inputs"][0]["option_source"]["value"] == ["approve", "reject"] diff --git a/api/tests/unit_tests/core/entities/test_entities_execution_extra_content.py b/api/tests/unit_tests/core/entities/test_entities_execution_extra_content.py index 2642179992..d0849e7b88 100644 --- a/api/tests/unit_tests/core/entities/test_entities_execution_extra_content.py +++ b/api/tests/unit_tests/core/entities/test_entities_execution_extra_content.py @@ -5,7 +5,6 @@ from core.entities.execution_extra_content import ( HumanInputFormSubmissionData, ) from graphon.nodes.human_input.entities import ParagraphInputConfig, UserActionConfig -from graphon.nodes.human_input.enums import FormInputType from models.execution_extra_content import ExecutionContentType @@ -16,7 +15,7 @@ def test_human_input_content_defaults_and_domain_alias() -> None: node_id="node-1", node_title="Human Input", form_content="Please confirm", - inputs=[ParagraphInputConfig(type=FormInputType.PARAGRAPH, output_variable_name="answer")], + inputs=[ParagraphInputConfig(output_variable_name="answer")], actions=[UserActionConfig(id="confirm", title="Confirm")], resolved_default_values={"answer": "yes"}, expiration_time=1_700_000_000, @@ -27,6 +26,7 @@ def test_human_input_content_defaults_and_domain_alias() -> None: rendered_content="Please confirm", action_id="confirm", action_text="Confirm", + submitted_data={"answer": "yes"}, ) # Act @@ -42,4 +42,5 @@ def test_human_input_content_defaults_and_domain_alias() -> None: assert content.type == ExecutionContentType.HUMAN_INPUT assert content.form_definition is form_definition assert content.form_submission_data is submission_data + assert content.form_submission_data.submitted_data == {"answer": "yes"} assert ExecutionExtraContentDomainModel is HumanInputContent diff --git a/api/tests/unit_tests/core/repositories/test_human_input_repository.py b/api/tests/unit_tests/core/repositories/test_human_input_repository.py index 418537675d..edd8be8618 100644 --- a/api/tests/unit_tests/core/repositories/test_human_input_repository.py +++ b/api/tests/unit_tests/core/repositories/test_human_input_repository.py @@ -586,6 +586,73 @@ def test_mark_submitted_updates_and_raises_when_missing(monkeypatch: pytest.Monk assert record.submitted_data == {"k": "v"} +def test_mark_submitted_serializes_select_and_file_payloads(monkeypatch: pytest.MonkeyPatch) -> None: + fixed_now = datetime(2024, 1, 1, 0, 0, 0) + monkeypatch.setattr("core.repositories.human_input_repository.naive_utc_now", lambda: fixed_now) + + form = _DummyForm( + id="f-complex", + workflow_run_id=None, + node_id="node", + tenant_id="tenant", + app_id="app", + form_definition=_make_form_definition_json(include_expiration_time=True), + rendered_content="

x

", + expiration_time=fixed_now, + ) + recipient = _DummyRecipient( + id="r-complex", + form_id=form.id, + recipient_type=RecipientType.CONSOLE, + access_token="tok", + ) + session = _FakeSession(forms={form.id: form}, recipients={recipient.id: recipient}) + _patch_session_factory(monkeypatch, session) + + payload = { + "decision": "approve", + "attachment": { + "type": "document", + "transfer_method": "remote_url", + "remote_url": "https://example.com/file.txt", + "filename": "file.txt", + "extension": ".txt", + "mime_type": "text/plain", + }, + "attachments": [ + { + "type": "document", + "transfer_method": "remote_url", + "remote_url": "https://example.com/first.txt", + "filename": "first.txt", + "extension": ".txt", + "mime_type": "text/plain", + }, + { + "type": "document", + "transfer_method": "remote_url", + "remote_url": "https://example.com/second.txt", + "filename": "second.txt", + "extension": ".txt", + "mime_type": "text/plain", + }, + ], + } + + repo = HumanInputFormSubmissionRepository() + record = repo.mark_submitted( + form_id=form.id, + recipient_id=recipient.id, + selected_action_id="approve", + form_data=payload, + submission_user_id="user-1", + submission_end_user_id="end-user-1", + ) + + assert json.loads(form.submitted_data or "") == payload + assert record.submitted_data == payload + + def test_mark_timeout_invalid_status_raises(monkeypatch: pytest.MonkeyPatch) -> None: form = _DummyForm( id="f", diff --git a/api/tests/unit_tests/core/workflow/graph_engine/test_mock_nodes.py b/api/tests/unit_tests/core/workflow/graph_engine/test_mock_nodes.py index c3e6f5d76c..55dcbdb7a1 100644 --- a/api/tests/unit_tests/core/workflow/graph_engine/test_mock_nodes.py +++ b/api/tests/unit_tests/core/workflow/graph_engine/test_mock_nodes.py @@ -81,7 +81,7 @@ class MockNodeMixin: if isinstance(self, TemplateTransformNode): kwargs.setdefault("jinja2_template_renderer", _TestJinja2Renderer()) - # Provide default ToolNode dependencies for ToolNode subclasses. + # Provide default tool_file_manager for ToolNode subclasses from graphon.nodes.tool import ToolNode as _ToolNode # local import to avoid cycles if isinstance(self, _ToolNode): diff --git a/api/tests/unit_tests/core/workflow/graph_engine/test_parallel_human_input_join_resume.py b/api/tests/unit_tests/core/workflow/graph_engine/test_parallel_human_input_join_resume.py index fd6263be19..a16a8b481a 100644 --- a/api/tests/unit_tests/core/workflow/graph_engine/test_parallel_human_input_join_resume.py +++ b/api/tests/unit_tests/core/workflow/graph_engine/test_parallel_human_input_join_resume.py @@ -12,6 +12,7 @@ from core.repositories.human_input_repository import ( from core.workflow.node_runtime import DifyFileReferenceFactory, DifyHumanInputNodeRuntime from core.workflow.system_variables import build_system_variables from graphon.entities import WorkflowStartReason +from graphon.file import File, FileTransferMethod, FileType from graphon.graph import Graph from graphon.graph_engine import GraphEngine, GraphEngineConfig from graphon.graph_engine.command_channels import InMemoryChannel @@ -24,8 +25,15 @@ from graphon.graph_events import ( from graphon.nodes.base.entities import OutputVariableEntity from graphon.nodes.end.end_node import EndNode from graphon.nodes.end.entities import EndNodeData -from graphon.nodes.human_input.entities import HumanInputNodeData, UserActionConfig -from graphon.nodes.human_input.enums import HumanInputFormStatus +from graphon.nodes.human_input.entities import ( + FileInputConfig, + FileListInputConfig, + HumanInputNodeData, + SelectInputConfig, + StringListSource, + UserActionConfig, +) +from graphon.nodes.human_input.enums import HumanInputFormStatus, ValueSourceType from graphon.nodes.human_input.human_input_node import HumanInputNode from graphon.nodes.start.entities import StartNodeData from graphon.nodes.start.start_node import StartNode @@ -52,6 +60,21 @@ class InMemoryPauseStore: return GraphRuntimeState.from_snapshot(self._snapshot) +class _TestFileReferenceFactory: + def build_from_mapping(self, *, mapping: Mapping[str, Any]) -> File: + return File( + file_id=mapping.get("id"), + file_type=FileType(mapping["type"]), + transfer_method=FileTransferMethod(mapping["transfer_method"]), + remote_url=mapping.get("remote_url") or mapping.get("url"), + related_id=mapping.get("related_id") or mapping.get("upload_file_id"), + filename=mapping.get("filename"), + extension=mapping.get("extension"), + mime_type=mapping.get("mime_type"), + size=mapping.get("size", -1), + ) + + @dataclass class StaticForm(HumanInputFormEntity): form_id: str @@ -106,6 +129,9 @@ class StaticRepo(HumanInputFormRepository): def get_form(self, node_id: str) -> HumanInputFormEntity | None: return self._forms_by_node_id.get(node_id) + def set_forms(self, forms_by_node_id: Mapping[str, HumanInputFormEntity]) -> None: + self._forms_by_node_id = dict(forms_by_node_id) + def create_form(self, params: FormCreateParams) -> HumanInputFormEntity: raise AssertionError("create_form should not be called in resume scenario") @@ -148,7 +174,14 @@ def _build_graph(runtime_state: GraphRuntimeState, repo: HumanInputFormRepositor human_data = HumanInputNodeData( title="Human Input", form_content="Human input required", - inputs=[], + inputs=[ + SelectInputConfig( + output_variable_name="decision", + option_source=StringListSource(type=ValueSourceType.CONSTANT, value=["approve", "reject"]), + ), + FileInputConfig(output_variable_name="attachment"), + FileListInputConfig(output_variable_name="attachments", number_limits=2), + ], user_actions=[UserActionConfig(id="approve", title="Approve")], ) @@ -177,8 +210,12 @@ def _build_graph(runtime_state: GraphRuntimeState, repo: HumanInputFormRepositor end_data = EndNodeData( title="End", outputs=[ - OutputVariableEntity(variable="res_a", value_selector=["human_a", "__action_id"]), - OutputVariableEntity(variable="res_b", value_selector=["human_b", "__action_id"]), + OutputVariableEntity(variable="res_a_action", value_selector=["human_a", "__action_id"]), + OutputVariableEntity(variable="res_a_decision", value_selector=["human_a", "decision"]), + OutputVariableEntity(variable="res_a_attachment", value_selector=["human_a", "attachment"]), + OutputVariableEntity(variable="res_b_action", value_selector=["human_b", "__action_id"]), + OutputVariableEntity(variable="res_b_decision", value_selector=["human_b", "decision"]), + OutputVariableEntity(variable="res_b_attachments", value_selector=["human_b", "attachments"]), ], desc=None, ) @@ -216,13 +253,13 @@ def _run_graph(graph: Graph, runtime_state: GraphRuntimeState) -> list[object]: return list(engine.run()) -def _form(submitted: bool, action_id: str | None) -> StaticForm: +def _form(submitted: bool, action_id: str | None, data: Mapping[str, Any] | None = None) -> StaticForm: return StaticForm( form_id="form", rendered="rendered", is_submitted=submitted, action_id=action_id, - data={}, + data=data, status_value=HumanInputFormStatus.SUBMITTED if submitted else HumanInputFormStatus.WAITING, ) @@ -246,7 +283,21 @@ def test_parallel_human_input_join_completes_after_second_resume() -> None: first_resume_state = pause_store.load() first_resume_repo = StaticRepo( { - "human_a": _form(submitted=True, action_id="approve"), + "human_a": _form( + submitted=True, + action_id="approve", + data={ + "decision": "approve", + "attachment": { + "type": "document", + "transfer_method": "remote_url", + "remote_url": "https://example.com/resume.pdf", + "filename": "resume.pdf", + "extension": ".pdf", + "mime_type": "application/pdf", + }, + }, + ), "human_b": _form(submitted=False, action_id=None), } ) @@ -256,19 +307,68 @@ def test_parallel_human_input_join_completes_after_second_resume() -> None: assert isinstance(first_resume_events[0], GraphRunStartedEvent) assert first_resume_events[0].reason is WorkflowStartReason.RESUMPTION assert isinstance(first_resume_events[-1], GraphRunPausedEvent) - pause_store.save(first_resume_state) - - second_resume_state = pause_store.load() - second_resume_repo = StaticRepo( + second_resume_state = first_resume_state + first_resume_repo.set_forms( { - "human_a": _form(submitted=True, action_id="approve"), - "human_b": _form(submitted=True, action_id="approve"), + "human_a": _form( + submitted=True, + action_id="approve", + data={ + "decision": "approve", + "attachment": { + "type": "document", + "transfer_method": "remote_url", + "remote_url": "https://example.com/resume.pdf", + "filename": "resume.pdf", + "extension": ".pdf", + "mime_type": "application/pdf", + }, + }, + ), + "human_b": _form( + submitted=True, + action_id="approve", + data={ + "decision": "reject", + "attachments": [ + { + "type": "image", + "transfer_method": "remote_url", + "remote_url": "https://example.com/a.png", + "filename": "a.png", + "extension": ".png", + "mime_type": "image/png", + }, + { + "type": "image", + "transfer_method": "remote_url", + "remote_url": "https://example.com/b.png", + "filename": "b.png", + "extension": ".png", + "mime_type": "image/png", + }, + ], + }, + ), } ) - second_resume_graph = _build_graph(second_resume_state, second_resume_repo) - second_resume_events = _run_graph(second_resume_graph, second_resume_state) + second_resume_events = _run_graph(first_resume_graph, second_resume_state) assert isinstance(second_resume_events[0], GraphRunStartedEvent) assert second_resume_events[0].reason is WorkflowStartReason.RESUMPTION assert isinstance(second_resume_events[-1], GraphRunSucceededEvent) assert any(isinstance(event, NodeRunSucceededEvent) and event.node_id == "end" for event in second_resume_events) + second_resume_outputs = second_resume_state.outputs + assert second_resume_outputs["res_a_action"] == "approve" + assert second_resume_outputs["res_a_decision"] == "approve" + assert isinstance(second_resume_outputs["res_a_attachment"], File) + res_a_attachment_in_second_outputs = second_resume_outputs["res_a_attachment"] + assert isinstance(res_a_attachment_in_second_outputs, File) + assert res_a_attachment_in_second_outputs.filename == "resume.pdf" + assert res_a_attachment_in_second_outputs.type == FileType.DOCUMENT + assert res_a_attachment_in_second_outputs.transfer_method == FileTransferMethod.REMOTE_URL + assert second_resume_outputs["res_b_action"] == "approve" + assert second_resume_outputs["res_b_decision"] == "reject" + assert isinstance(second_resume_outputs["res_b_attachments"], list) + assert [file.filename for file in second_resume_outputs["res_b_attachments"]] == ["a.png", "b.png"] + assert all(file.type == FileType.IMAGE for file in second_resume_outputs["res_b_attachments"]) diff --git a/api/tests/unit_tests/core/workflow/nodes/human_input/test_entities.py b/api/tests/unit_tests/core/workflow/nodes/human_input/test_entities.py index a5a8e877f2..1d68610e7f 100644 --- a/api/tests/unit_tests/core/workflow/nodes/human_input/test_entities.py +++ b/api/tests/unit_tests/core/workflow/nodes/human_input/test_entities.py @@ -33,11 +33,16 @@ from core.workflow.human_input_adapter import ( from core.workflow.node_runtime import DifyFileReferenceFactory, DifyHumanInputNodeRuntime from core.workflow.system_variables import build_system_variables from graphon.entities import GraphInitParams +from graphon.file import File, FileTransferMethod, FileType from graphon.node_events import PauseRequestedEvent from graphon.node_events.node import StreamCompletedEvent from graphon.nodes.human_input.entities import ( + FileInputConfig, + FileListInputConfig, HumanInputNodeData, ParagraphInputConfig, + SelectInputConfig, + StringListSource, StringSource, UserActionConfig, ) @@ -49,7 +54,9 @@ from graphon.nodes.human_input.enums import ( ValueSourceType, ) from graphon.nodes.human_input.human_input_node import HumanInputNode +from graphon.nodes.protocols import FileReferenceFactoryProtocol from graphon.runtime import GraphRuntimeState, VariablePool +from graphon.variables.segments import ArrayFileSegment, FileSegment, StringSegment from libs.datetime_utils import naive_utc_now @@ -136,6 +143,23 @@ class InMemoryHumanInputFormRepository(HumanInputFormRepository): entity.status_value = HumanInputFormStatus.SUBMITTED +class _TestFileReferenceFactory(FileReferenceFactoryProtocol): + """Build graph-layer file objects without touching Dify persistence in unit tests.""" + + def build_from_mapping(self, *, mapping: Mapping[str, Any]) -> File: + return File( + file_id=mapping.get("id"), + file_type=FileType(mapping["type"]), + transfer_method=FileTransferMethod(mapping["transfer_method"]), + remote_url=mapping.get("remote_url") or mapping.get("url"), + related_id=mapping.get("related_id") or mapping.get("upload_file_id"), + filename=mapping.get("filename"), + extension=mapping.get("extension"), + mime_type=mapping.get("mime_type"), + size=mapping.get("size", -1), + ) + + def _build_human_input_node( *, node_id: str, @@ -198,12 +222,11 @@ class TestParagraphInputConfig: """Test paragraph input with constant default value.""" default = StringSource(type=ValueSourceType.CONSTANT, value="Enter your response here...") - form_input = ParagraphInputConfig( - type=FormInputType.PARAGRAPH, output_variable_name="user_input", default=default - ) + form_input = ParagraphInputConfig(output_variable_name="user_input", default=default) assert form_input.type == FormInputType.PARAGRAPH assert form_input.output_variable_name == "user_input" + assert form_input.default is not None assert form_input.default.type == ValueSourceType.CONSTANT assert form_input.default.value == "Enter your response here..." @@ -211,16 +234,15 @@ class TestParagraphInputConfig: """Test paragraph input with variable default value.""" default = StringSource(type=ValueSourceType.VARIABLE, selector=["node_123", "output_var"]) - form_input = ParagraphInputConfig( - type=FormInputType.PARAGRAPH, output_variable_name="user_input", default=default - ) + form_input = ParagraphInputConfig(output_variable_name="user_input", default=default) + assert form_input.default is not None assert form_input.default.type == ValueSourceType.VARIABLE assert form_input.default.selector == ["node_123", "output_var"] def test_form_input_without_default(self): """Test form input without default value.""" - form_input = ParagraphInputConfig(type=FormInputType.PARAGRAPH, output_variable_name="description") + form_input = ParagraphInputConfig(output_variable_name="description") assert form_input.type == FormInputType.PARAGRAPH assert form_input.output_variable_name == "description" @@ -279,7 +301,6 @@ class TestHumanInputNodeData: inputs = [ ParagraphInputConfig( - type=FormInputType.PARAGRAPH, output_variable_name="content", default=StringSource(type=ValueSourceType.CONSTANT, value="Enter content..."), ) @@ -343,8 +364,8 @@ class TestHumanInputNodeData: def test_duplicate_input_output_variable_name_raises_validation_error(self): """Duplicate form input output_variable_name should raise validation error.""" duplicate_inputs = [ - ParagraphInputConfig(type=FormInputType.PARAGRAPH, output_variable_name="content"), - ParagraphInputConfig(type=FormInputType.PARAGRAPH, output_variable_name="content"), + ParagraphInputConfig(output_variable_name="content"), + ParagraphInputConfig(output_variable_name="content"), ] with pytest.raises(ValidationError, match="duplicated output_variable_name 'content'"): @@ -464,12 +485,10 @@ class TestHumanInputNodeVariableResolution: form_content="Provide your name", inputs=[ ParagraphInputConfig( - type=FormInputType.PARAGRAPH, output_variable_name="user_name", default=StringSource(type=ValueSourceType.VARIABLE, selector=["start", "name"]), ), ParagraphInputConfig( - type=FormInputType.PARAGRAPH, output_variable_name="user_email", default=StringSource(type=ValueSourceType.CONSTANT, value="foo@example.com"), ), @@ -726,9 +745,11 @@ class TestValidation: def test_invalid_form_input_type(self): """Test validation with invalid form input type.""" with pytest.raises(ValidationError): - ParagraphInputConfig( - type="invalid-type", # Invalid type - output_variable_name="test", + ParagraphInputConfig.model_validate( + { + "type": "invalid-type", + "output_variable_name": "test", + } ) def test_invalid_button_style(self): @@ -782,12 +803,7 @@ class TestHumanInputNodeRenderedContent: node_data = HumanInputNodeData( title="Human Input", form_content="Name: {{#$output.name#}}", - inputs=[ - ParagraphInputConfig( - type=FormInputType.PARAGRAPH, - output_variable_name="name", - ) - ], + inputs=[ParagraphInputConfig(output_variable_name="name")], user_actions=[UserActionConfig(id="approve", title="Approve")], ) config = {"id": "human", "data": node_data.model_dump()} @@ -815,4 +831,115 @@ class TestHumanInputNodeRenderedContent: last_event = events[-1] assert isinstance(last_event, StreamCompletedEvent) node_run_result = last_event.node_run_result - assert node_run_result.outputs["__rendered_content"].to_object() == "Name: Alice" + assert node_run_result.outputs["name"] == StringSegment(value="Alice") + assert node_run_result.outputs["__action_id"] == StringSegment(value="approve") + assert node_run_result.outputs["__rendered_content"] == StringSegment(value="Name: Alice") + + def test_resume_restores_file_outputs_as_runtime_segments(self): + variable_pool = VariablePool.from_bootstrap( + system_variables=build_system_variables( + user_id="user", + app_id="app", + workflow_id="workflow", + workflow_execution_id="run", + ), + user_inputs={}, + conversation_variables=[], + ) + runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=0.0) + graph_init_params = GraphInitParams( + workflow_id="workflow", + graph_config={"nodes": [], "edges": []}, + run_context={ + DIFY_RUN_CONTEXT_KEY: { + "tenant_id": "tenant", + "app_id": "app", + "user_id": "user", + "user_from": "account", + "invoke_from": "debugger", + } + }, + call_depth=0, + ) + + node_data = HumanInputNodeData( + title="Human Input", + form_content=( + "Decision: {{#$output.decision#}}\n" + "Attachment: {{#$output.attachment#}}\n" + "Attachments: {{#$output.attachments#}}" + ), + inputs=[ + SelectInputConfig( + output_variable_name="decision", + option_source=StringListSource(type="constant", value=["approve", "reject"]), + ), + FileInputConfig(output_variable_name="attachment"), + FileListInputConfig(output_variable_name="attachments", number_limits=2), + ], + user_actions=[UserActionConfig(id="approve", title="Approve")], + ) + config = {"id": "human", "data": node_data.model_dump()} + + form_repository = InMemoryHumanInputFormRepository() + runtime = DifyHumanInputNodeRuntime(graph_init_params.run_context) + runtime._build_form_repository = MagicMock(return_value=form_repository) # type: ignore[attr-defined] + node = _build_human_input_node( + node_id=config["id"], + node_data=config["data"], + graph_init_params=graph_init_params, + graph_runtime_state=runtime_state, + runtime=runtime, + ) + + pause_gen = node._run() + pause_event = next(pause_gen) + assert isinstance(pause_event, PauseRequestedEvent) + with pytest.raises(StopIteration): + next(pause_gen) + + form_repository.set_submission( + action_id="approve", + form_data={ + "decision": "approve", + "attachment": { + "type": "document", + "transfer_method": "remote_url", + "remote_url": "https://example.com/resume.pdf", + "filename": "resume.pdf", + "extension": ".pdf", + "mime_type": "application/pdf", + }, + "attachments": [ + { + "type": "image", + "transfer_method": "remote_url", + "remote_url": "https://example.com/a.png", + "filename": "a.png", + "extension": ".png", + "mime_type": "image/png", + }, + { + "type": "image", + "transfer_method": "remote_url", + "remote_url": "https://example.com/b.png", + "filename": "b.png", + "extension": ".png", + "mime_type": "image/png", + }, + ], + }, + ) + + events = list(node._run()) + last_event = events[-1] + assert isinstance(last_event, StreamCompletedEvent) + node_run_result = last_event.node_run_result + assert node_run_result.outputs["decision"] == StringSegment(value="approve") + assert node_run_result.outputs["__rendered_content"] == StringSegment( + value="Decision: approve\nAttachment: [file]\nAttachments: [2 files]" + ) + assert isinstance(node_run_result.outputs["attachment"], FileSegment) + assert node_run_result.outputs["attachment"].value.filename == "resume.pdf" + assert isinstance(node_run_result.outputs["attachments"], ArrayFileSegment) + assert [file.filename for file in node_run_result.outputs["attachments"].value] == ["a.png", "b.png"] diff --git a/api/tests/unit_tests/core/workflow/nodes/human_input/test_human_input_form_filled_event.py b/api/tests/unit_tests/core/workflow/nodes/human_input/test_human_input_form_filled_event.py index 40522a0d4f..763e1eecfd 100644 --- a/api/tests/unit_tests/core/workflow/nodes/human_input/test_human_input_form_filled_event.py +++ b/api/tests/unit_tests/core/workflow/nodes/human_input/test_human_input_form_filled_event.py @@ -1,20 +1,34 @@ import datetime +from collections.abc import Mapping from types import SimpleNamespace +from typing import Any from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, InvokeFrom, UserFrom from core.workflow.node_runtime import DifyFileReferenceFactory, DifyHumanInputNodeRuntime from core.workflow.system_variables import default_system_variables from graphon.entities import GraphInitParams from graphon.enums import BuiltinNodeTypes +from graphon.file import File, FileTransferMethod, FileType from graphon.graph_events import ( NodeRunHumanInputFormFilledEvent, NodeRunHumanInputFormTimeoutEvent, NodeRunStartedEvent, ) -from graphon.nodes.human_input.entities import HumanInputNodeData +from graphon.nodes.human_input.entities import ( + FileInputConfig, + FileListInputConfig, + HumanInputNodeData, + ParagraphInputConfig, + SelectInputConfig, + StringListSource, + UserActionConfig, +) from graphon.nodes.human_input.enums import HumanInputFormStatus from graphon.nodes.human_input.human_input_node import HumanInputNode +from graphon.nodes.protocols import FileReferenceFactoryProtocol from graphon.runtime import GraphRuntimeState, VariablePool +from graphon.variables.segments import ArrayFileSegment, FileSegment, StringSegment +from graphon.variables.types import SegmentType from libs.datetime_utils import naive_utc_now @@ -26,6 +40,21 @@ class _FakeFormRepository: return self._form +class _TestFileReferenceFactory(FileReferenceFactoryProtocol): + def build_from_mapping(self, *, mapping: Mapping[str, Any]): + return File( + file_id=mapping.get("id"), + file_type=FileType(mapping["type"]), + transfer_method=FileTransferMethod(mapping["transfer_method"]), + remote_url=mapping.get("remote_url") or mapping.get("url"), + related_id=mapping.get("related_id") or mapping.get("upload_file_id"), + filename=mapping.get("filename"), + extension=mapping.get("extension"), + mime_type=mapping.get("mime_type"), + size=mapping.get("size", -1), + ) + + def _create_human_input_node( *, config: dict, @@ -49,7 +78,14 @@ def _create_human_input_node( ) -def _build_node(form_content: str = "Please enter your name:\n\n{{#$output.name#}}") -> HumanInputNode: +def _build_node( + form_content: str = ( + "Please enter your name:\n\n{{#$output.name#}}\n" + "Decision: {{#$output.decision#}}\n" + "Attachment: {{#$output.attachment#}}\n" + "Attachments: {{#$output.attachments#}}" + ), +) -> HumanInputNode: system_variables = default_system_variables() graph_runtime_state = GraphRuntimeState( variable_pool=VariablePool.from_bootstrap( @@ -81,19 +117,15 @@ def _build_node(form_content: str = "Please enter your name:\n\n{{#$output.name# "title": "Human Input", "form_content": form_content, "inputs": [ - { - "type": "paragraph", - "output_variable_name": "name", - "default": {"type": "constant", "value": ""}, - } - ], - "user_actions": [ - { - "id": "Accept", - "title": "Approve", - "button_style": "default", - } + ParagraphInputConfig(output_variable_name="name").model_dump(mode="json"), + SelectInputConfig( + output_variable_name="decision", + option_source=StringListSource(type="constant", value=["approve", "reject"]), + ).model_dump(mode="json"), + FileInputConfig(output_variable_name="attachment").model_dump(mode="json"), + FileListInputConfig(output_variable_name="attachments", number_limits=2).model_dump(mode="json"), ], + "user_actions": [UserActionConfig(id="Accept", title="Approve").model_dump(mode="json")], }, } @@ -102,7 +134,28 @@ def _build_node(form_content: str = "Please enter your name:\n\n{{#$output.name# rendered_content=form_content, submitted=True, selected_action_id="Accept", - submitted_data={"name": "Alice"}, + submitted_data={ + "name": "Alice", + "decision": "approve", + "attachment": { + "type": "document", + "transfer_method": "remote_url", + "remote_url": "https://example.com/resume.pdf", + "filename": "resume.pdf", + "extension": ".pdf", + "mime_type": "application/pdf", + }, + "attachments": [ + { + "type": "image", + "transfer_method": "remote_url", + "remote_url": "https://example.com/a.png", + "filename": "a.png", + "extension": ".png", + "mime_type": "image/png", + } + ], + }, status=HumanInputFormStatus.SUBMITTED, expiration_time=naive_utc_now() + datetime.timedelta(days=1), ) @@ -147,20 +200,8 @@ def _build_timeout_node() -> HumanInputNode: "data": { "title": "Human Input", "form_content": "Please enter your name:\n\n{{#$output.name#}}", - "inputs": [ - { - "type": "paragraph", - "output_variable_name": "name", - "default": {"type": "constant", "value": ""}, - } - ], - "user_actions": [ - { - "id": "Accept", - "title": "Approve", - "button_style": "default", - } - ], + "inputs": [ParagraphInputConfig(output_variable_name="name").model_dump(mode="json")], + "user_actions": [UserActionConfig(id="Accept", title="Approve").model_dump(mode="json")], }, } @@ -193,9 +234,22 @@ def test_human_input_node_emits_form_filled_event_before_succeeded(): filled_event = events[1] assert filled_event.node_title == "Human Input" - assert filled_event.rendered_content.endswith("Alice") + assert filled_event.rendered_content == ( + "Please enter your name:\n\nAlice\nDecision: approve\nAttachment: [file]\nAttachments: [1 files]" + ) assert filled_event.action_id == "Accept" assert filled_event.action_text == "Approve" + assert filled_event.submitted_data["name"] == StringSegment(value="Alice") + assert filled_event.submitted_data["decision"] == StringSegment(value="approve") + assert isinstance(filled_event.submitted_data["attachment"], FileSegment) + assert filled_event.submitted_data["attachment"].value_type == SegmentType.FILE + assert filled_event.submitted_data["attachment"].value.filename == "resume.pdf" + assert filled_event.submitted_data["attachment"].value.type == FileType.DOCUMENT + assert filled_event.submitted_data["attachment"].value.transfer_method == FileTransferMethod.REMOTE_URL + assert isinstance(filled_event.submitted_data["attachments"], ArrayFileSegment) + assert filled_event.submitted_data["attachments"].value_type == SegmentType.ARRAY_FILE + assert filled_event.submitted_data["attachments"].value[0].filename == "a.png" + assert filled_event.submitted_data["attachments"].value[0].type == FileType.IMAGE def test_human_input_node_emits_timeout_event_before_succeeded(): diff --git a/api/tests/unit_tests/core/workflow/test_form_input_serialization_compat.py b/api/tests/unit_tests/core/workflow/test_form_input_serialization_compat.py new file mode 100644 index 0000000000..cc83a17dfc --- /dev/null +++ b/api/tests/unit_tests/core/workflow/test_form_input_serialization_compat.py @@ -0,0 +1,338 @@ +import json +from typing import Any + +from pydantic import TypeAdapter + +from core.app.entities.task_entities import HumanInputRequiredResponse +from core.entities.execution_extra_content import ( + HumanInputContent, + HumanInputFormDefinition, +) +from graphon.entities.pause_reason import HumanInputRequired +from graphon.nodes.human_input.entities import ( + FormDefinition, + FormInputConfig, + HumanInputNodeData, +) +from graphon.nodes.human_input.enums import ButtonStyle, TimeoutUnit, ValueSourceType + + +def _legacy_form_input_payloads() -> list[dict[str, Any]]: + return [ + { + "type": "paragraph", + "output_variable_name": "name", + "default": { + "type": "constant", + "selector": [], + "value": "Alice", + }, + }, + { + "type": "select", + "output_variable_name": "decision", + "option_source": { + "type": "constant", + "selector": [], + "value": ["approve", "reject"], + }, + }, + { + "type": "file", + "output_variable_name": "attachment", + "allowed_file_types": ["document"], + "allowed_file_extensions": [], + "allowed_file_upload_methods": ["remote_url"], + }, + { + "type": "file-list", + "output_variable_name": "attachments", + "allowed_file_types": ["document"], + "allowed_file_extensions": [], + "allowed_file_upload_methods": ["remote_url"], + "number_limits": 3, + }, + { + "type": "paragraph", + "output_variable_name": "summary", + "default": None, + }, + ] + + +def _legacy_user_action_payloads() -> list[dict[str, Any]]: + return [ + { + "id": "approve", + "title": "Approve", + "button_style": "primary", + }, + { + "id": "reject", + "title": "Reject", + "button_style": "default", + }, + ] + + +def _validate_legacy_json(model_class: type, payload: dict[str, Any]) -> Any: + adapter = TypeAdapter(model_class) + return adapter.validate_json(json.dumps(payload)) + + +def test_form_input_accepts_current_serialized_payload() -> None: + payload = { + "type": "paragraph", + "output_variable_name": "name", + "default": { + "type": "constant", + "selector": [], + "value": "Alice", + }, + } + + restored = _validate_legacy_json(FormInputConfig, payload) + assert restored.default is not None + assert restored.default.type == ValueSourceType.CONSTANT + + +def test_human_input_node_data_accepts_current_serialized_payload() -> None: + payload = { + "type": "human-input", + "title": "Human Input", + "form_content": "Hello {{#$output.name#}}", + "inputs": _legacy_form_input_payloads(), + "user_actions": _legacy_user_action_payloads(), + "timeout": 2, + "timeout_unit": "day", + } + + restored = _validate_legacy_json(HumanInputNodeData, payload) + assert restored.inputs[0].output_variable_name == "name" + assert restored.timeout_unit == TimeoutUnit.DAY + + +def test_form_definition_accepts_current_serialized_payload() -> None: + payload = { + "form_content": "Please confirm", + "inputs": _legacy_form_input_payloads(), + "user_actions": _legacy_user_action_payloads(), + "rendered_content": "Please confirm", + "expiration_time": "2024-01-01T00:00:00Z", + "default_values": {"name": "Alice"}, + "node_title": "Human Input", + "display_in_ui": True, + } + + restored = _validate_legacy_json(FormDefinition, payload) + assert restored.inputs[2].output_variable_name == "attachment" + assert restored.user_actions[0].id == "approve" + assert restored.user_actions[0].button_style == ButtonStyle.PRIMARY + + +def test_human_input_required_pause_reason_accepts_current_serialized_payload() -> None: + payload = { + "TYPE": "human_input_required", + "form_id": "form-1", + "form_content": "Please confirm", + "inputs": _legacy_form_input_payloads(), + "actions": _legacy_user_action_payloads(), + "node_id": "node-1", + "node_title": "Human Input", + "resolved_default_values": {"name": "Alice"}, + } + + restored = _validate_legacy_json(HumanInputRequired, payload) + assert restored.inputs[1].output_variable_name == "decision" + assert restored.actions[0].id == "approve" + assert restored.TYPE == "human_input_required" + + +def test_human_input_form_definition_accepts_current_serialized_payload() -> None: + payload = { + "form_id": "form-1", + "node_id": "node-1", + "node_title": "Human Input", + "form_content": "Please confirm", + "inputs": _legacy_form_input_payloads(), + "actions": _legacy_user_action_payloads(), + "display_in_ui": True, + "form_token": "token-1", + "resolved_default_values": {"name": "Alice"}, + "expiration_time": 1700000000, + } + + restored = _validate_legacy_json(HumanInputFormDefinition, payload) + assert restored.inputs[3].output_variable_name == "attachments" + assert restored.actions[0].id == "approve" + + +def test_human_input_content_accepts_current_serialized_payload() -> None: + payload = { + "workflow_run_id": "run-1", + "submitted": True, + "form_definition": { + "form_id": "form-1", + "node_id": "node-1", + "node_title": "Human Input", + "form_content": "Please confirm", + "inputs": _legacy_form_input_payloads(), + "actions": _legacy_user_action_payloads(), + "display_in_ui": True, + "form_token": "token-1", + "resolved_default_values": {"name": "Alice"}, + "expiration_time": 1700000000, + }, + "form_submission_data": { + "node_id": "node-1", + "node_title": "Human Input", + "rendered_content": "Please confirm", + "action_id": "approve", + "action_text": "Approve", + }, + "type": "human_input", + } + + restored = _validate_legacy_json(HumanInputContent, payload) + assert restored.form_definition is not None + assert restored.form_definition.inputs[0].output_variable_name == "name" + + +def test_human_input_content_accepts_current_serialized_payload_with_form_data() -> None: + payload = { + "workflow_run_id": "run-1", + "submitted": True, + "form_definition": { + "form_id": "form-1", + "node_id": "node-1", + "node_title": "Human Input", + "form_content": "Please confirm", + "inputs": [ + { + "type": "select", + "output_variable_name": "decision", + "option_source": {"type": "constant", "selector": [], "value": ["approve", "reject"]}, + }, + { + "type": "file", + "output_variable_name": "attachment", + "allowed_file_types": ["document"], + "allowed_file_extensions": [], + "allowed_file_upload_methods": ["remote_url"], + }, + { + "type": "file-list", + "output_variable_name": "attachments", + "allowed_file_types": ["document"], + "allowed_file_extensions": [], + "allowed_file_upload_methods": ["remote_url"], + "number_limits": 3, + }, + ], + "actions": _legacy_user_action_payloads(), + "display_in_ui": True, + "form_token": "token-1", + "resolved_default_values": {"decision": "approve"}, + "expiration_time": 1700000000, + }, + "form_submission_data": { + "node_id": "node-1", + "node_title": "Human Input", + "rendered_content": "Please confirm", + "action_id": "approve", + "action_text": "Approve", + "submitted_data": { + "decision": "approve", + "attachment": { + "type": "document", + "transfer_method": "remote_url", + "remote_url": "https://example.com/file.txt", + "filename": "file.txt", + "extension": ".txt", + "mime_type": "text/plain", + }, + "attachments": [ + { + "type": "document", + "transfer_method": "remote_url", + "remote_url": "https://example.com/first.txt", + "filename": "first.txt", + "extension": ".txt", + "mime_type": "text/plain", + } + ], + }, + }, + "type": "human_input", + } + + restored = HumanInputContent.model_validate_json(json.dumps(payload)) + assert restored.form_submission_data is not None + assert restored.form_submission_data.submitted_data == payload["form_submission_data"]["submitted_data"] + + +def test_human_input_content_accepts_legacy_serialized_payload_with_form_data() -> None: + payload = { + "workflow_run_id": "run-1", + "submitted": True, + "form_definition": { + "form_id": "form-1", + "node_id": "node-1", + "node_title": "Human Input", + "form_content": "Please confirm", + "inputs": _legacy_form_input_payloads(), + "actions": _legacy_user_action_payloads(), + "display_in_ui": True, + "form_token": "token-1", + "resolved_default_values": {"decision": "approve"}, + "expiration_time": 1700000000, + }, + "form_submission_data": { + "node_id": "node-1", + "node_title": "Human Input", + "rendered_content": "Please confirm", + "action_id": "approve", + "action_text": "Approve", + "form_data": { + "decision": "approve", + "attachment": { + "type": "document", + "transfer_method": "remote_url", + "remote_url": "https://example.com/file.txt", + "filename": "file.txt", + "extension": ".txt", + "mime_type": "text/plain", + }, + }, + }, + "type": "human_input", + } + + restored = HumanInputContent.model_validate_json(json.dumps(payload)) + assert restored.form_submission_data is not None + assert restored.form_submission_data.submitted_data is None + + +def test_human_input_required_response_accepts_current_serialized_payload() -> None: + payload = { + "event": "human_input_required", + "task_id": "task-1", + "workflow_run_id": "run-1", + "data": { + "form_id": "form-1", + "node_id": "node-1", + "node_title": "Human Input", + "form_content": "Please confirm", + "inputs": _legacy_form_input_payloads(), + "actions": _legacy_user_action_payloads(), + "display_in_ui": True, + "form_token": "token-1", + "resolved_default_values": {"name": "Alice"}, + "expiration_time": 1700000000, + }, + } + + restored = _validate_legacy_json(HumanInputRequiredResponse, payload) + assert restored.data.inputs[1].output_variable_name == "decision" + assert restored.data.actions[0].id == "approve" + assert restored.event == "human_input_required" diff --git a/api/tests/unit_tests/core/workflow/test_human_input_policy.py b/api/tests/unit_tests/core/workflow/test_human_input_policy.py index e6d0366af5..651b69216a 100644 --- a/api/tests/unit_tests/core/workflow/test_human_input_policy.py +++ b/api/tests/unit_tests/core/workflow/test_human_input_policy.py @@ -1,8 +1,14 @@ +import pytest + from core.workflow.human_input_policy import ( HumanInputSurface, get_preferred_form_token, is_recipient_type_allowed_for_surface, + resolve_variable_select_input_options, ) +from graphon.nodes.human_input.entities import SelectInputConfig, StringListSource +from graphon.nodes.human_input.enums import ValueSourceType +from graphon.runtime import VariablePool from models.human_input import RecipientType @@ -48,3 +54,40 @@ def test_preferred_form_token_uses_shared_priority_order() -> None: ] assert get_preferred_form_token(recipients) == "backstage-token" + + +def test_resolve_variable_select_input_options_uses_runtime_values() -> None: + variable_pool = VariablePool() + variable_pool.add(("start", "options"), ["approve", "reject"]) + inputs: list[SelectInputConfig] = [ + SelectInputConfig( + output_variable_name="decision", + option_source=StringListSource( + type=ValueSourceType.VARIABLE, + selector=["start", "options"], + value=[], + ), + ) + ] + + resolved = resolve_variable_select_input_options(inputs, variable_pool=variable_pool) + assert isinstance(resolved[0], SelectInputConfig) + assert resolved[0].option_source.value == ["approve", "reject"] + + +def test_resolve_variable_select_input_options_keeps_original_when_value_not_string_list() -> None: + variable_pool = VariablePool() + variable_pool.add(("start", "options"), [1, 2, 3]) + inputs = [ + SelectInputConfig( + output_variable_name="decision", + option_source=StringListSource( + type=ValueSourceType.VARIABLE, + selector=["start", "options"], + value=[], + ), + ) + ] + + with pytest.raises(TypeError): + resolve_variable_select_input_options(inputs, variable_pool=variable_pool) diff --git a/api/tests/unit_tests/core/workflow/test_node_factory.py b/api/tests/unit_tests/core/workflow/test_node_factory.py index f35624aed1..bd18402c58 100644 --- a/api/tests/unit_tests/core/workflow/test_node_factory.py +++ b/api/tests/unit_tests/core/workflow/test_node_factory.py @@ -590,6 +590,7 @@ class TestDifyNodeFactoryCreateNode: assert kwargs["form_repository"] is form_repository assert kwargs["file_reference_factory"] is sentinel.file_reference_factory assert kwargs["runtime"] is factory._human_input_runtime + assert kwargs["file_reference_factory"] is sentinel.file_reference_factory factory._human_input_runtime.build_form_repository.assert_called_once_with() elif constructor_name == "ToolNode": assert kwargs["tool_file_manager"] is sentinel.tool_file_manager @@ -599,6 +600,50 @@ class TestDifyNodeFactoryCreateNode: assert kwargs["unstructured_api_config"] is sentinel.unstructured_api_config assert kwargs["http_client"] is sentinel.remote_file_http_client + def test_human_input_node_receives_runtime_repository_and_file_reference_factory( + self, + monkeypatch: pytest.MonkeyPatch, + factory, + ) -> None: + created_node = object() + constructor = _node_constructor(return_value=created_node) + form_repository = sentinel.form_repository + factory._human_input_runtime = MagicMock() + factory._human_input_runtime.build_form_repository.return_value = form_repository + monkeypatch.setattr( + factory, + "_resolve_node_class", + MagicMock(return_value=constructor), + ) + + result = factory.create_node({"id": "human-node", "data": {"type": BuiltinNodeTypes.HUMAN_INPUT}}) + + assert result is created_node + kwargs = constructor.call_args.kwargs + assert kwargs["runtime"] is factory._human_input_runtime + assert kwargs["form_repository"] is form_repository + assert kwargs["file_reference_factory"] is sentinel.file_reference_factory + factory._human_input_runtime.build_form_repository.assert_called_once_with() + + def test_tool_node_receives_tool_file_manager(self, monkeypatch: pytest.MonkeyPatch, factory) -> None: + created_node = object() + constructor = _node_constructor(return_value=created_node) + factory._bound_tool_file_manager_factory = MagicMock(return_value=sentinel.tool_file_manager) + monkeypatch.setattr( + factory, + "_resolve_node_class", + MagicMock(return_value=constructor), + ) + + result = factory.create_node({"id": "tool-node", "data": {"type": BuiltinNodeTypes.TOOL}}) + + assert result is created_node + kwargs = constructor.call_args.kwargs + assert kwargs["tool_file_manager"] is sentinel.tool_file_manager + assert kwargs["runtime"] is sentinel.tool_runtime + assert "tool_file_manager_factory" not in kwargs + factory._bound_tool_file_manager_factory.assert_called_once_with() + def test_build_llm_compatible_node_init_kwargs_preserves_structured_output_switch(self, factory): node_data = LLMNodeData.model_validate( { diff --git a/api/tests/unit_tests/core/workflow/test_node_runtime.py b/api/tests/unit_tests/core/workflow/test_node_runtime.py index 5e83863dc2..244e22a867 100644 --- a/api/tests/unit_tests/core/workflow/test_node_runtime.py +++ b/api/tests/unit_tests/core/workflow/test_node_runtime.py @@ -29,11 +29,12 @@ from core.workflow.node_runtime import ( build_dify_llm_file_saver, resolve_dify_run_context, ) -from graphon.file import FileTransferMethod, FileType +from graphon.file import File, FileTransferMethod, FileType from graphon.model_runtime.entities.common_entities import I18nObject from graphon.model_runtime.entities.model_entities import AIModelEntity, FetchFrom, ModelType -from graphon.nodes.human_input.entities import HumanInputNodeData +from graphon.nodes.human_input.entities import FileInputConfig, FileListInputConfig, HumanInputNodeData from graphon.nodes.tool.entities import ToolNodeData, ToolProviderType +from graphon.variables.segments import ArrayFileSegment, FileSegment from tests.workflow_test_utils import build_test_run_context @@ -621,6 +622,70 @@ def test_dify_human_input_runtime_preserves_webapp_delivery_for_web_invocations( assert params.delivery_methods[1].config.recipients.include_bound_group is True +def test_dify_human_input_runtime_restore_submitted_data_rehydrates_files() -> None: + runtime = DifyHumanInputNodeRuntime(_build_run_context()) + file_value = File( + file_id="file-1", + file_type=FileType.DOCUMENT, + transfer_method=FileTransferMethod.LOCAL_FILE, + related_id="upload-1", + filename="resume.pdf", + extension=".pdf", + mime_type="application/pdf", + size=128, + ) + file_list_value = [ + File( + file_id="file-2", + file_type=FileType.DOCUMENT, + transfer_method=FileTransferMethod.LOCAL_FILE, + related_id="upload-2", + filename="first.pdf", + extension=".pdf", + mime_type="application/pdf", + size=64, + ), + File( + file_id="file-3", + file_type=FileType.DOCUMENT, + transfer_method=FileTransferMethod.REMOTE_URL, + remote_url="https://example.com/second.pdf", + filename="second.pdf", + extension=".pdf", + mime_type="application/pdf", + size=96, + ), + ] + runtime._file_reference_factory.build_from_mapping = MagicMock(side_effect=[file_value, *file_list_value]) # type: ignore[method-assign] + node_data = HumanInputNodeData( + title="Human Input", + inputs=[ + FileInputConfig(output_variable_name="attachment"), + FileListInputConfig(output_variable_name="attachments", number_limits=2), + ], + ) + + restored = runtime.restore_submitted_data( + node_data=node_data, + submitted_data={ + "attachment": {"upload_file_id": "upload-1", "type": "document", "transfer_method": "local_file"}, + "attachments": [ + {"upload_file_id": "upload-2", "type": "document", "transfer_method": "local_file"}, + { + "url": "https://example.com/second.pdf", + "type": "document", + "transfer_method": "remote_url", + }, + ], + }, + ) + + assert restored["attachment"] is file_value + assert restored["attachments"] == file_list_value + assert isinstance(FileSegment(value=restored["attachment"]), FileSegment) + assert isinstance(ArrayFileSegment(value=restored["attachments"]), ArrayFileSegment) + + def test_build_dify_llm_file_saver_wires_runtime_adapters(monkeypatch: pytest.MonkeyPatch) -> None: file_saver_cls = MagicMock(return_value=sentinel.file_saver) monkeypatch.setattr("graphon.nodes.llm.file_saver.FileSaverImpl", file_saver_cls) diff --git a/api/tests/unit_tests/factories/test_build_from_mapping.py b/api/tests/unit_tests/factories/test_build_from_mapping.py index 6bc900798d..e2dd0efee3 100644 --- a/api/tests/unit_tests/factories/test_build_from_mapping.py +++ b/api/tests/unit_tests/factories/test_build_from_mapping.py @@ -421,3 +421,29 @@ def test_disallowed_extensions(mock_upload_file): with pytest.raises(ValueError, match="File validation failed"): build_from_mapping(mapping=mapping, tenant_id=TEST_TENANT_ID, config=restricted_config) + + +def test_custom_file_type_uses_extension_validation_under_strict_mode(mock_upload_file): + """Custom form uploads are classified by the configured extension list.""" + mock_upload_file.return_value.extension = "txt" + mock_upload_file.return_value.name = "notes.txt" + mock_upload_file.return_value.mime_type = "text/plain" + + custom_config = FileUploadConfig( + allowed_file_types=[FileType.CUSTOM], + allowed_file_extensions=[".txt"], + ) + mapping = { + "transfer_method": "local_file", + "upload_file_id": TEST_UPLOAD_FILE_ID, + "type": "custom", + } + + file = build_from_mapping( + mapping=mapping, + tenant_id=TEST_TENANT_ID, + config=custom_config, + strict_type_validation=True, + ) + + assert file.type == FileType.CUSTOM diff --git a/api/tests/unit_tests/libs/_human_input/support.py b/api/tests/unit_tests/libs/_human_input/support.py index 6616cec9b8..0f593507fd 100644 --- a/api/tests/unit_tests/libs/_human_input/support.py +++ b/api/tests/unit_tests/libs/_human_input/support.py @@ -4,7 +4,7 @@ from dataclasses import dataclass, field from datetime import datetime, timedelta from typing import Any -from graphon.nodes.human_input.entities import ParagraphInputConfig +from graphon.nodes.human_input.entities import FormInputConfig from graphon.nodes.human_input.enums import TimeoutUnit from libs.datetime_utils import naive_utc_now @@ -45,7 +45,7 @@ class HumanInputForm: tenant_id: str app_id: str | None form_content: str - inputs: list[ParagraphInputConfig] + inputs: list[FormInputConfig] user_actions: list[dict[str, Any]] timeout: int timeout_unit: TimeoutUnit @@ -88,7 +88,7 @@ class HumanInputForm: def to_response_dict(self, *, include_site_info: bool) -> dict[str, Any]: inputs_response = [ { - "type": form_input.type.name.lower().replace("_", "-"), + "type": form_input.type.value, "output_variable_name": form_input.output_variable_name, } for form_input in self.inputs diff --git a/api/tests/unit_tests/libs/_human_input/test_form_service.py b/api/tests/unit_tests/libs/_human_input/test_form_service.py index cb4c2715d0..decd7c484b 100644 --- a/api/tests/unit_tests/libs/_human_input/test_form_service.py +++ b/api/tests/unit_tests/libs/_human_input/test_form_service.py @@ -11,7 +11,6 @@ from graphon.nodes.human_input.entities import ( UserActionConfig, ) from graphon.nodes.human_input.enums import ( - FormInputType, TimeoutUnit, ) from libs.datetime_utils import naive_utc_now @@ -50,7 +49,7 @@ class TestFormService: "tenant_id": "tenant-abc", "app_id": "app-def", "form_content": "# Test Form\n\nInput: {{#$output.input#}}", - "inputs": [ParagraphInputConfig(type=FormInputType.PARAGRAPH, output_variable_name="input", default=None)], + "inputs": [ParagraphInputConfig(output_variable_name="input")], "user_actions": [UserActionConfig(id="submit", title="Submit")], "timeout": 1, "timeout_unit": TimeoutUnit.HOUR, @@ -304,9 +303,7 @@ class TestFormValidation: "tenant_id": "tenant-abc", "app_id": "app-def", "form_content": "Test form", - "inputs": [ - ParagraphInputConfig(type=FormInputType.PARAGRAPH, output_variable_name="required_input", default=None) - ], + "inputs": [ParagraphInputConfig(output_variable_name="required_input")], "user_actions": [UserActionConfig(id="submit", title="Submit")], "timeout": 1, "timeout_unit": TimeoutUnit.HOUR, diff --git a/api/tests/unit_tests/libs/_human_input/test_models.py b/api/tests/unit_tests/libs/_human_input/test_models.py index 1413eed51f..f6e4c9ec18 100644 --- a/api/tests/unit_tests/libs/_human_input/test_models.py +++ b/api/tests/unit_tests/libs/_human_input/test_models.py @@ -11,7 +11,6 @@ from graphon.nodes.human_input.entities import ( UserActionConfig, ) from graphon.nodes.human_input.enums import ( - FormInputType, TimeoutUnit, ) from libs.datetime_utils import naive_utc_now @@ -32,7 +31,7 @@ class TestHumanInputForm: "tenant_id": "tenant-abc", "app_id": "app-def", "form_content": "# Test Form\n\nInput: {{#$output.input#}}", - "inputs": [ParagraphInputConfig(type=FormInputType.PARAGRAPH, output_variable_name="input", default=None)], + "inputs": [ParagraphInputConfig(output_variable_name="input")], "user_actions": [UserActionConfig(id="submit", title="Submit")], "timeout": 2, "timeout_unit": TimeoutUnit.HOUR, diff --git a/api/tests/unit_tests/repositories/test_sqlalchemy_execution_extra_content_repository.py b/api/tests/unit_tests/repositories/test_sqlalchemy_execution_extra_content_repository.py new file mode 100644 index 0000000000..df6805bcdf --- /dev/null +++ b/api/tests/unit_tests/repositories/test_sqlalchemy_execution_extra_content_repository.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +import json +from datetime import timedelta +from typing import cast + +from sqlalchemy.orm import Session, sessionmaker + +from graphon.nodes.human_input.entities import FormDefinition, UserActionConfig +from graphon.nodes.human_input.enums import HumanInputFormStatus +from libs.datetime_utils import naive_utc_now +from models.execution_extra_content import HumanInputContent as HumanInputContentModel +from models.human_input import HumanInputForm +from repositories.sqlalchemy_execution_extra_content_repository import SQLAlchemyExecutionExtraContentRepository + + +def test_map_human_input_content_populates_submission_data_from_stored_form_submission() -> None: + expiration_time = naive_utc_now() + timedelta(days=1) + stored_submission_data = {"decision": "approve", "comment": "Looks good"} + form_definition = FormDefinition( + form_content="content", + inputs=[], + user_actions=[UserActionConfig(id="approve", title="Approve")], + rendered_content="Rendered Approve", + expiration_time=expiration_time, + node_title="Approval", + display_in_ui=True, + ) + form = HumanInputForm( + tenant_id="tenant-1", + app_id="app-1", + workflow_run_id="workflow-run-1", + node_id="node-1", + form_definition=form_definition.model_dump_json(), + rendered_content="Rendered Approve", + expiration_time=expiration_time, + selected_action_id="approve", + submitted_data=json.dumps(stored_submission_data), + submitted_at=naive_utc_now(), + status=HumanInputFormStatus.SUBMITTED, + ) + form.id = "form-1" + model = HumanInputContentModel.new( + workflow_run_id="workflow-run-1", + form_id=form.id, + message_id="message-1", + ) + model.id = "content-1" + model.form = form + repository = SQLAlchemyExecutionExtraContentRepository(cast(sessionmaker[Session], object())) + + content = repository._map_human_input_content(model, {}) + + assert content is not None + assert content.form_submission_data is not None + assert content.form_submission_data.submitted_data == stored_submission_data + + +def test_map_human_input_content_keeps_waiting_form_without_selected_action() -> None: + expiration_time = naive_utc_now() + timedelta(days=1) + form_definition = FormDefinition( + form_content="content", + inputs=[], + user_actions=[UserActionConfig(id="approve", title="Approve")], + rendered_content="Rendered Approval", + expiration_time=expiration_time, + node_title="Approval", + display_in_ui=True, + default_values={"decision": "approve"}, + ) + form = HumanInputForm( + tenant_id="tenant-1", + app_id="app-1", + workflow_run_id="workflow-run-1", + node_id="node-1", + form_definition=form_definition.model_dump_json(), + rendered_content="Rendered Approval", + expiration_time=expiration_time, + status=HumanInputFormStatus.WAITING, + ) + form.id = "form-1" + model = HumanInputContentModel.new( + workflow_run_id="workflow-run-1", + form_id=form.id, + message_id="message-1", + ) + model.id = "content-1" + model.form = form + repository = SQLAlchemyExecutionExtraContentRepository(cast(sessionmaker[Session], object())) + + content = repository._map_human_input_content(model, {}) + + assert content is not None + assert content.submitted is False + assert content.form_submission_data is None + assert content.form_definition is not None + assert content.form_definition.form_id == "form-1" + assert content.form_definition.node_id == "node-1" + assert content.form_definition.node_title == "Approval" + assert content.form_definition.form_content == "Rendered Approval" + assert content.form_definition.resolved_default_values == {"decision": "approve"} diff --git a/api/tests/unit_tests/services/test_human_input_file_upload_service.py b/api/tests/unit_tests/services/test_human_input_file_upload_service.py new file mode 100644 index 0000000000..c39453557c --- /dev/null +++ b/api/tests/unit_tests/services/test_human_input_file_upload_service.py @@ -0,0 +1,304 @@ +from __future__ import annotations + +from datetime import timedelta +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest +from sqlalchemy import create_engine, select +from sqlalchemy.orm import sessionmaker + +import models.account as account_module +import services.human_input_file_upload_service as service_module +from graphon.enums import WorkflowExecutionStatus +from graphon.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus +from libs.datetime_utils import naive_utc_now +from models.account import Account, Tenant, TenantAccountJoin +from models.base import Base +from models.enums import CreatorUserRole, WorkflowRunTriggeredFrom +from models.human_input import ( + HumanInputForm, + HumanInputFormRecipient, + HumanInputFormUploadFile, + HumanInputFormUploadToken, +) +from models.model import App, AppMode, EndUser +from models.workflow import WorkflowRun, WorkflowType +from services.human_input_file_upload_service import HITL_UPLOAD_TOKEN_PREFIX, HumanInputFileUploadService +from services.human_input_service import FormSubmittedError + + +@pytest.fixture +def session_maker(monkeypatch: pytest.MonkeyPatch): + engine = create_engine("sqlite:///:memory:") + monkeypatch.setattr(account_module, "db", SimpleNamespace(engine=engine)) + Base.metadata.create_all( + engine, + tables=[ + Tenant.__table__, + Account.__table__, + TenantAccountJoin.__table__, + App.__table__, + EndUser.__table__, + WorkflowRun.__table__, + HumanInputForm.__table__, + HumanInputFormRecipient.__table__, + HumanInputFormUploadToken.__table__, + HumanInputFormUploadFile.__table__, + ], + ) + try: + yield sessionmaker(bind=engine, expire_on_commit=False) + finally: + Base.metadata.drop_all( + engine, + tables=[ + HumanInputFormUploadFile.__table__, + HumanInputFormUploadToken.__table__, + HumanInputFormRecipient.__table__, + HumanInputForm.__table__, + WorkflowRun.__table__, + EndUser.__table__, + App.__table__, + TenantAccountJoin.__table__, + Account.__table__, + Tenant.__table__, + ], + ) + engine.dispose() + + +def _create_waiting_form( + session_maker, + *, + created_by_role: CreatorUserRole = CreatorUserRole.ACCOUNT, + form_kind: HumanInputFormKind = HumanInputFormKind.RUNTIME, +) -> tuple[str, str, str]: + form_id = "00000000-0000-0000-0000-000000000001" + recipient_id = "00000000-0000-0000-0000-000000000002" + workflow_run_id = None + if form_kind == HumanInputFormKind.RUNTIME: + workflow_run_id = "00000000-0000-0000-0000-000000000012" + tenant_id = "00000000-0000-0000-0000-000000000010" + app_id = "00000000-0000-0000-0000-000000000011" + now = naive_utc_now() + created_by = ( + "00000000-0000-0000-0000-000000000020" + if created_by_role == CreatorUserRole.ACCOUNT + else "00000000-0000-0000-0000-000000000021" + ) + with session_maker.begin() as session: + tenant = Tenant(name="tenant-1") + tenant.id = tenant_id + session.add(tenant) + if created_by_role == CreatorUserRole.ACCOUNT: + account = Account(name="owner", email="owner@example.com") + account.id = created_by + session.add(account) + session.add( + TenantAccountJoin( + tenant_id=tenant_id, + account_id=created_by, + current=True, + ) + ) + app_creator = created_by + else: + end_user = EndUser( + tenant_id=tenant_id, + app_id=app_id, + type="web_app", + is_anonymous=False, + session_id="session-1", + external_user_id="external-1", + ) + end_user.id = created_by + session.add(end_user) + app_creator = "00000000-0000-0000-0000-000000000020" + account = Account(name="owner", email="owner@example.com") + account.id = app_creator + session.add(account) + session.add( + TenantAccountJoin( + tenant_id=tenant_id, + account_id=app_creator, + current=True, + ) + ) + app = App( + tenant_id=tenant_id, + name="app-1", + description="", + mode=AppMode.WORKFLOW, + icon_type="emoji", + icon="app", + icon_background="#ffffff", + enable_site=True, + enable_api=True, + created_by=app_creator, + updated_by=app_creator, + ) + app.id = app_id + session.add(app) + if workflow_run_id is not None: + workflow_run = WorkflowRun( + tenant_id=tenant_id, + app_id=app_id, + workflow_id="00000000-0000-0000-0000-000000000013", + type=WorkflowType.WORKFLOW, + triggered_from=WorkflowRunTriggeredFrom.APP_RUN, + version="1", + graph="{}", + inputs="{}", + status=WorkflowExecutionStatus.RUNNING, + created_by_role=created_by_role, + created_by=created_by, + created_at=now, + ) + workflow_run.id = workflow_run_id + session.add(workflow_run) + session.add( + HumanInputForm( + id=form_id, + tenant_id=tenant_id, + app_id=app_id, + workflow_run_id=workflow_run_id, + form_kind=form_kind, + node_id="node-1", + form_definition="{}", + rendered_content="content", + expiration_time=now + timedelta(hours=1), + created_at=now, + ) + ) + session.add( + HumanInputFormRecipient( + id=recipient_id, + form_id=form_id, + delivery_id="00000000-0000-0000-0000-000000000003", + recipient_type="standalone_web_app", + recipient_payload='{"TYPE": "standalone_web_app"}', + access_token="form-token-1", + ) + ) + return form_id, recipient_id, created_by + + +def _create_service( + session_maker, + workflow_run_repository: MagicMock | None = None, +) -> HumanInputFileUploadService: + return HumanInputFileUploadService( + session_maker, + workflow_run_repository=workflow_run_repository or MagicMock(), + ) + + +def test_issue_upload_token_persists_token_without_technical_end_user( + monkeypatch: pytest.MonkeyPatch, + session_maker, +) -> None: + form_id, recipient_id, _created_by = _create_waiting_form(session_maker) + monkeypatch.setattr(service_module.secrets, "token_urlsafe", lambda _bytes: "random-value") + + token = _create_service(session_maker).issue_upload_token("form-token-1") + + assert token.upload_token == f"{HITL_UPLOAD_TOKEN_PREFIX}random-value" + with session_maker() as session: + token_model = session.scalar(select(HumanInputFormUploadToken)) + assert token_model is not None + assert token_model.form_id == form_id + assert token_model.recipient_id == recipient_id + assert token_model.token == token.upload_token + assert session.scalar(select(EndUser).where(EndUser.type == "human-input")) is None + + +def test_validate_upload_token_returns_account_owner_and_record_file_link(session_maker) -> None: + form_id, recipient_id, created_by = _create_waiting_form(session_maker, created_by_role=CreatorUserRole.ACCOUNT) + token = _create_service(session_maker).issue_upload_token("form-token-1") + workflow_run_repository = MagicMock() + workflow_run_repository.get_workflow_run_by_id.return_value = SimpleNamespace( + tenant_id="00000000-0000-0000-0000-000000000010", + app_id="00000000-0000-0000-0000-000000000011", + created_by_role=CreatorUserRole.ACCOUNT, + created_by=created_by, + ) + + context = HumanInputFileUploadService( + session_maker, + workflow_run_repository=workflow_run_repository, + ).validate_upload_token(token.upload_token) + assert context.form_id == form_id + assert context.recipient_id == recipient_id + assert isinstance(context.owner, Account) + assert context.owner.id == created_by + assert context.owner.current_tenant_id == "00000000-0000-0000-0000-000000000010" + workflow_run_repository.get_workflow_run_by_id.assert_called_once_with( + tenant_id="00000000-0000-0000-0000-000000000010", + app_id="00000000-0000-0000-0000-000000000011", + run_id="00000000-0000-0000-0000-000000000012", + ) + + _create_service(session_maker).record_upload_file( + context=context, + file_id="00000000-0000-0000-0000-000000000099", + ) + + with session_maker() as session: + link = session.scalar(select(HumanInputFormUploadFile)) + assert link is not None + assert link.tenant_id == context.tenant_id + assert link.app_id == context.app_id + assert link.form_id == form_id + assert link.upload_token_id == context.upload_token_id + + +def test_validate_upload_token_returns_end_user_owner(session_maker) -> None: + form_id, recipient_id, created_by = _create_waiting_form(session_maker, created_by_role=CreatorUserRole.END_USER) + token = _create_service(session_maker).issue_upload_token("form-token-1") + workflow_run_repository = MagicMock() + workflow_run_repository.get_workflow_run_by_id.return_value = SimpleNamespace( + tenant_id="00000000-0000-0000-0000-000000000010", + app_id="00000000-0000-0000-0000-000000000011", + created_by_role=CreatorUserRole.END_USER, + created_by=created_by, + ) + + context = HumanInputFileUploadService( + session_maker, + workflow_run_repository=workflow_run_repository, + ).validate_upload_token(token.upload_token) + + assert context.form_id == form_id + assert context.recipient_id == recipient_id + assert isinstance(context.owner, EndUser) + assert context.owner.id == created_by + + +def test_validate_upload_token_allows_delivery_test_form(session_maker) -> None: + form_id, recipient_id, _created_by = _create_waiting_form( + session_maker, + form_kind=HumanInputFormKind.DELIVERY_TEST, + ) + token = _create_service(session_maker).issue_upload_token("form-token-1") + + context = _create_service(session_maker).validate_upload_token(token.upload_token) + + assert context.form_id == form_id + assert context.recipient_id == recipient_id + assert isinstance(context.owner, Account) + assert context.owner.id == "00000000-0000-0000-0000-000000000020" + assert context.owner.current_tenant_id == "00000000-0000-0000-0000-000000000010" + + +def test_validate_upload_token_rejects_submitted_form(session_maker) -> None: + form_id, _recipient_id, _created_by = _create_waiting_form(session_maker) + token = _create_service(session_maker).issue_upload_token("form-token-1") + with session_maker.begin() as session: + form = session.get(HumanInputForm, form_id) + assert form is not None + form.status = HumanInputFormStatus.SUBMITTED + form.submitted_at = naive_utc_now() + + with pytest.raises(FormSubmittedError): + _create_service(session_maker).validate_upload_token(token.upload_token) diff --git a/api/tests/unit_tests/services/test_human_input_service.py b/api/tests/unit_tests/services/test_human_input_service.py index b6370c0365..a0434f9b43 100644 --- a/api/tests/unit_tests/services/test_human_input_service.py +++ b/api/tests/unit_tests/services/test_human_input_service.py @@ -6,18 +6,28 @@ import pytest from pytest_mock import MockerFixture import services.human_input_service as human_input_service_module +from core.app.app_config.entities import WorkflowUIBasedAppConfig +from core.app.entities.app_invoke_entities import InvokeFrom, WorkflowAppGenerateEntity +from core.app.layers.pause_state_persist_layer import WorkflowResumptionContext, _WorkflowGenerateEntityWrapper from core.repositories.human_input_repository import ( HumanInputFormRecord, HumanInputFormSubmissionRepository, ) +from graphon.file import File, FileTransferMethod, FileType from graphon.nodes.human_input.entities import ( + FileInputConfig, + FileListInputConfig, FormDefinition, ParagraphInputConfig, + SelectInputConfig, + StringListSource, UserActionConfig, ) -from graphon.nodes.human_input.enums import FormInputType, HumanInputFormKind, HumanInputFormStatus +from graphon.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus, ValueSourceType +from graphon.runtime import GraphRuntimeState, VariablePool from libs.datetime_utils import naive_utc_now from models.human_input import RecipientType +from models.model import AppMode from services.human_input_service import ( Form, FormExpiredError, @@ -178,6 +188,70 @@ def test_get_form_definition_by_token_for_console_uses_repository(sample_form_re assert form.get_definition() == console_record.definition +def _build_resumption_context_state(*, options: list[str], workflow_run_id: str) -> bytes: + app_config = WorkflowUIBasedAppConfig( + tenant_id="tenant-id", + app_id="app-id", + app_mode=AppMode.WORKFLOW, + workflow_id="workflow-id", + ) + generate_entity = WorkflowAppGenerateEntity( + task_id="task-id", + app_config=app_config, + inputs={}, + files=[], + user_id="user-id", + stream=True, + invoke_from=InvokeFrom.EXPLORE, + call_depth=0, + workflow_execution_id=workflow_run_id, + ) + runtime_state = GraphRuntimeState(variable_pool=VariablePool(), start_at=0.0) + runtime_state.variable_pool.add(("start", "options"), options) + context = WorkflowResumptionContext( + generate_entity=_WorkflowGenerateEntityWrapper(entity=generate_entity), + serialized_graph_runtime_state=runtime_state.dumps(), + ) + return context.dumps().encode() + + +def test_resolve_form_inputs_uses_runtime_select_options(sample_form_record, mock_session_factory, mocker): + session_factory, _ = mock_session_factory + configured_input = SelectInputConfig( + output_variable_name="decision", + option_source=StringListSource( + type=ValueSourceType.VARIABLE, + selector=["start", "options"], + value=["configured"], + ), + ) + record = dataclasses.replace( + sample_form_record, + definition=sample_form_record.definition.model_copy(update={"inputs": [configured_input]}), + ) + pause = MagicMock() + pause.resumed_at = None + pause.get_state.return_value = _build_resumption_context_state( + options=["approve", "reject"], + workflow_run_id=record.workflow_run_id or "", + ) + workflow_run_repo = MagicMock() + workflow_run_repo.get_workflow_pause.return_value = pause + mocker.patch( + "services.human_input_service.DifyAPIRepositoryFactory.create_api_workflow_run_repository", + return_value=workflow_run_repo, + ) + service = HumanInputService(session_factory) + + resolved_inputs = service.resolve_form_inputs(Form(record)) + + assert len(resolved_inputs) == 1 + resolved_input = resolved_inputs[0] + assert isinstance(resolved_input, SelectInputConfig) + assert resolved_input.option_source.value == ["approve", "reject"] + workflow_run_repo.get_workflow_pause.assert_called_once_with(record.workflow_run_id) + + def test_submit_form_by_token_calls_repository_and_enqueue( sample_form_record, mock_session_factory, mocker: MockerFixture ): @@ -280,7 +354,7 @@ def test_submit_form_by_token_missing_inputs(sample_form_record, mock_session_fa definition_with_input = FormDefinition( form_content="hello", - inputs=[ParagraphInputConfig(type=FormInputType.PARAGRAPH, output_variable_name="content")], + inputs=[ParagraphInputConfig(output_variable_name="content")], user_actions=sample_form_record.definition.user_actions, rendered_content="

hello

", expiration_time=sample_form_record.expiration_time, @@ -301,6 +375,123 @@ def test_submit_form_by_token_missing_inputs(sample_form_record, mock_session_fa repo.mark_submitted.assert_not_called() +def test_validate_human_input_submission_accepts_select_file_and_file_list(mock_session_factory): + session_factory, _ = mock_session_factory + service = HumanInputService(session_factory) + definition = FormDefinition.model_validate( + { + "form_content": "Pick one and upload files", + "inputs": [ + { + "type": "select", + "output_variable_name": "decision", + "option_source": { + "type": "constant", + "value": ["approve", "reject"], + }, + }, + { + "type": "file", + "output_variable_name": "attachment", + "allowed_file_types": ["document"], + "allowed_file_upload_methods": ["remote_url"], + }, + { + "type": "file-list", + "output_variable_name": "attachments", + "allowed_file_types": ["document"], + "allowed_file_upload_methods": ["remote_url"], + "number_limits": 3, + }, + ], + "user_actions": [{"id": "submit", "title": "Submit"}], + "rendered_content": "

Pick one and upload files

", + "expiration_time": naive_utc_now() + timedelta(hours=1), + } + ) + + +@pytest.mark.parametrize( + ("input_definition", "submitted_value", "expected_message"), + [ + ( + { + "type": "select", + "output_variable_name": "decision", + "option_source": { + "type": "constant", + "value": ["approve", "reject"], + }, + }, + "unknown", + "decision", + ), + ( + { + "type": "file", + "output_variable_name": "attachment", + "allowed_file_types": ["document"], + "allowed_file_upload_methods": ["remote_url"], + }, + "not-a-file", + "attachment", + ), + ( + { + "type": "file-list", + "output_variable_name": "attachments", + "allowed_file_types": ["document"], + "allowed_file_upload_methods": ["remote_url"], + "number_limits": 2, + }, + [ + { + "type": "document", + "transfer_method": "remote_url", + "remote_url": "https://example.com/ok.txt", + "filename": "ok.txt", + "extension": ".txt", + "mime_type": "text/plain", + }, + "not-a-file", + ], + "attachments", + ), + ], +) +def test_validate_human_input_submission_rejects_invalid_select_and_file_payloads( + sample_form_record, + mock_session_factory, + input_definition, + submitted_value, + expected_message, +): + session_factory, _ = mock_session_factory + repo = MagicMock(spec=HumanInputFormSubmissionRepository) + definition = FormDefinition.model_validate( + { + "form_content": "Validate form data", + "inputs": [input_definition], + "user_actions": [{"id": "submit", "title": "Submit"}], + "rendered_content": "

Validate form data

", + "expiration_time": naive_utc_now() + timedelta(hours=1), + } + ) + repo.get_by_token.return_value = dataclasses.replace(sample_form_record, definition=definition) + service = HumanInputService(session_factory, form_repository=repo) + + with pytest.raises(InvalidFormDataError) as exc_info: + service.submit_form_by_token( + recipient_type=RecipientType.STANDALONE_WEB_APP, + form_token="token", + selected_action_id="submit", + form_data={input_definition["output_variable_name"]: submitted_value}, + ) + + assert expected_message in str(exc_info.value) + repo.mark_submitted.assert_not_called() + + def test_form_properties(sample_form_record): form = Form(sample_form_record) assert form.id == "form-id" @@ -468,3 +659,203 @@ def test_is_globally_expired_zero_timeout(monkeypatch, sample_form_record, mock_ monkeypatch.setattr(human_input_service_module.dify_config, "HUMAN_INPUT_GLOBAL_TIMEOUT_SECONDS", 0) assert service._is_globally_expired(Form(sample_form_record)) is False + + +def test_submit_form_by_token_normalizes_select_and_files(sample_form_record, mock_session_factory, mocker) -> None: + session_factory, _ = mock_session_factory + repo = MagicMock(spec=HumanInputFormSubmissionRepository) + definition = FormDefinition( + form_content="hello", + inputs=[ + SelectInputConfig( + output_variable_name="decision", + option_source=StringListSource(type=ValueSourceType.CONSTANT, value=["approve", "reject"]), + ), + FileInputConfig(output_variable_name="attachment"), + FileListInputConfig(output_variable_name="attachments", number_limits=3), + ], + user_actions=[UserActionConfig(id="submit", title="Submit")], + rendered_content="

hello

", + expiration_time=sample_form_record.expiration_time, + ) + form_with_inputs = dataclasses.replace(sample_form_record, definition=definition) + repo.get_by_token.return_value = form_with_inputs + repo.mark_submitted.return_value = form_with_inputs + service = HumanInputService(session_factory, form_repository=repo) + + single_file = File( + file_id="file-1", + file_type=FileType.DOCUMENT, + transfer_method=FileTransferMethod.LOCAL_FILE, + related_id="upload-1", + filename="resume.pdf", + extension=".pdf", + mime_type="application/pdf", + size=128, + ) + list_files = [ + File( + file_id="file-2", + file_type=FileType.DOCUMENT, + transfer_method=FileTransferMethod.LOCAL_FILE, + related_id="upload-2", + filename="a.pdf", + extension=".pdf", + mime_type="application/pdf", + size=64, + ), + File( + file_id="file-3", + file_type=FileType.DOCUMENT, + transfer_method=FileTransferMethod.REMOTE_URL, + remote_url="https://example.com/b.pdf", + filename="b.pdf", + extension=".pdf", + mime_type="application/pdf", + size=96, + ), + ] + mocker.patch("services.human_input_service.build_from_mapping", return_value=single_file) + mocker.patch("services.human_input_service.build_from_mappings", return_value=list_files) + enqueue_spy = mocker.patch.object(service, "enqueue_resume") + + service.submit_form_by_token( + recipient_type=RecipientType.STANDALONE_WEB_APP, + form_token="token", + selected_action_id="submit", + form_data={ + "decision": "approve", + "attachment": {"transfer_method": "local_file", "upload_file_id": "upload-1", "type": "document"}, + "attachments": [ + {"transfer_method": "local_file", "upload_file_id": "upload-2", "type": "document"}, + {"transfer_method": "remote_url", "url": "https://example.com/b.pdf", "type": "document"}, + ], + }, + ) + + submitted_data = repo.mark_submitted.call_args.kwargs["form_data"] + assert submitted_data["decision"] == "approve" + assert submitted_data["attachment"]["filename"] == "resume.pdf" + assert submitted_data["attachment"]["transfer_method"] == "local_file" + assert submitted_data["attachments"][0]["filename"] == "a.pdf" + assert submitted_data["attachments"][1]["filename"] == "b.pdf" + enqueue_spy.assert_called_once_with(sample_form_record.workflow_run_id) + + +def test_submit_form_by_token_invalid_select_value(sample_form_record, mock_session_factory) -> None: + session_factory, _ = mock_session_factory + repo = MagicMock(spec=HumanInputFormSubmissionRepository) + definition = FormDefinition( + form_content="hello", + inputs=[ + SelectInputConfig( + output_variable_name="decision", + option_source=StringListSource(type=ValueSourceType.CONSTANT, value=["approve", "reject"]), + ) + ], + user_actions=[UserActionConfig(id="submit", title="Submit")], + rendered_content="

hello

", + expiration_time=sample_form_record.expiration_time, + ) + repo.get_by_token.return_value = dataclasses.replace(sample_form_record, definition=definition) + service = HumanInputService(session_factory, form_repository=repo) + + with pytest.raises(InvalidFormDataError, match="Invalid value for select input 'decision'"): + service.submit_form_by_token( + recipient_type=RecipientType.STANDALONE_WEB_APP, + form_token="token", + selected_action_id="submit", + form_data={"decision": "hold"}, + ) + + +def test_submit_form_by_token_invalid_file_list_item(sample_form_record, mock_session_factory) -> None: + session_factory, _ = mock_session_factory + repo = MagicMock(spec=HumanInputFormSubmissionRepository) + definition = FormDefinition( + form_content="hello", + inputs=[FileListInputConfig(output_variable_name="attachments", number_limits=2)], + user_actions=[UserActionConfig(id="submit", title="Submit")], + rendered_content="

hello

", + expiration_time=sample_form_record.expiration_time, + ) + repo.get_by_token.return_value = dataclasses.replace(sample_form_record, definition=definition) + service = HumanInputService(session_factory, form_repository=repo) + + with pytest.raises( + InvalidFormDataError, + match="Invalid value for file list input 'attachments'", + ): + service.submit_form_by_token( + recipient_type=RecipientType.STANDALONE_WEB_APP, + form_token="token", + selected_action_id="submit", + form_data={"attachments": ["not-a-file"]}, + ) + + +def test_submit_form_by_token_rejects_cross_tenant_file(sample_form_record, mock_session_factory, mocker) -> None: + session_factory, _ = mock_session_factory + repo = MagicMock(spec=HumanInputFormSubmissionRepository) + definition = FormDefinition( + form_content="hello", + inputs=[FileInputConfig(output_variable_name="attachment")], + user_actions=[UserActionConfig(id="submit", title="Submit")], + rendered_content="

hello

", + expiration_time=sample_form_record.expiration_time, + ) + repo.get_by_token.return_value = dataclasses.replace(sample_form_record, definition=definition) + service = HumanInputService(session_factory, form_repository=repo) + mocker.patch("services.human_input_service.build_from_mapping", side_effect=ValueError("Invalid upload file")) + + with pytest.raises(InvalidFormDataError, match="Invalid value for file input 'attachment'"): + service.submit_form_by_token( + recipient_type=RecipientType.STANDALONE_WEB_APP, + form_token="token", + selected_action_id="submit", + form_data={ + "attachment": { + "transfer_method": "local_file", + "upload_file_id": "4e0d1b87-52f2-49f6-b8c6-95cd9c954b3e", + "type": "document", + } + }, + ) + + repo.mark_submitted.assert_not_called() + + +def test_submit_form_by_token_rejects_cross_tenant_file_list(sample_form_record, mock_session_factory, mocker) -> None: + session_factory, _ = mock_session_factory + repo = MagicMock(spec=HumanInputFormSubmissionRepository) + definition = FormDefinition( + form_content="hello", + inputs=[FileListInputConfig(output_variable_name="attachments", number_limits=2)], + user_actions=[UserActionConfig(id="submit", title="Submit")], + rendered_content="

hello

", + expiration_time=sample_form_record.expiration_time, + ) + repo.get_by_token.return_value = dataclasses.replace(sample_form_record, definition=definition) + service = HumanInputService(session_factory, form_repository=repo) + mocker.patch("services.human_input_service.build_from_mappings", side_effect=ValueError("Invalid upload file")) + + with pytest.raises( + InvalidFormDataError, + match="Invalid value for file list input 'attachments'", + ): + service.submit_form_by_token( + recipient_type=RecipientType.STANDALONE_WEB_APP, + form_token="token", + selected_action_id="submit", + form_data={ + "attachments": [ + { + "transfer_method": "local_file", + "upload_file_id": "4e0d1b87-52f2-49f6-b8c6-95cd9c954b3e", + "type": "document", + } + ] + }, + ) + + repo.mark_submitted.assert_not_called() diff --git a/api/tests/unit_tests/services/test_workflow_service.py b/api/tests/unit_tests/services/test_workflow_service.py index d384c5a83b..cb494ab8db 100644 --- a/api/tests/unit_tests/services/test_workflow_service.py +++ b/api/tests/unit_tests/services/test_workflow_service.py @@ -2682,6 +2682,7 @@ class TestWorkflowServiceHumanInputOperations: SimpleNamespace(id="submit", title="card_visa_enterprise_001"), ] mock_node.node_data.outputs_field_names.return_value = ["field1"] + mock_node.node_data.inputs = [] mock_node.render_form_content_before_submission.return_value = "Ticket: {{#$output.field1#}}" mock_node.render_form_content_with_outputs.return_value = "Ticket: val1" @@ -2691,7 +2692,10 @@ class TestWorkflowServiceHumanInputOperations: patch("models.workflow.Workflow.get_node_type_from_node_config", return_value=BuiltinNodeTypes.HUMAN_INPUT), patch.object(service, "_build_human_input_variable_pool"), patch("services.workflow_service.HumanInputNode", return_value=mock_node), - patch("services.workflow_service.validate_human_input_submission"), + patch( + "services.workflow_service.HumanInputService.validate_and_normalize_submission", + return_value={"field1": "val1"}, + ) as mock_validate, patch("services.workflow_service.Session"), patch("services.workflow_service.DraftVariableSaver") as mock_saver_cls, ): @@ -2699,6 +2703,7 @@ class TestWorkflowServiceHumanInputOperations: app_model=app_model, account=account, node_id="node-1", form_inputs={"field1": "val1"}, action="submit" ) assert result["__action_id"] == "submit" + mock_validate.assert_called_once() assert result["__action_value"] == "card_visa_enterprise_001" assert result["__rendered_content"] == "Ticket: val1" mock_saver_cls.return_value.save.assert_called_once() @@ -2714,7 +2719,7 @@ class TestWorkflowServiceHumanInputOperations: patch.object(service, "_resolve_human_input_delivery_method") as mock_resolve, patch("services.workflow_service.apply_dify_debug_email_recipient"), patch.object(service, "_build_human_input_variable_pool"), - patch.object(service, "_build_human_input_node"), + patch.object(service, "_build_human_input_node_for_debugging"), patch.object(service, "_create_human_input_delivery_test_form", return_value=("form-1", [])), patch("services.workflow_service.HumanInputDeliveryTestService") as mock_test_srv, ): @@ -2842,8 +2847,8 @@ class TestWorkflowServiceFreeNodeExecution: with pytest.raises(Exception, match="unreachable"): _rebuild_single_file("tenant-1", {}, cast(Any, "invalid_type")) - def test_build_human_input_node(self, service: WorkflowService) -> None: - """Cover _build_human_input_node (lines 1065-1088).""" + def test_build_human_input_node_for_debugging(self, service: WorkflowService) -> None: + """Cover _build_human_input_node_for_debugging.""" workflow = MagicMock() workflow.id = "wf-1" workflow.tenant_id = "t-1" @@ -2863,10 +2868,11 @@ class TestWorkflowServiceFreeNodeExecution: patch("services.workflow_service.build_dify_run_context") as mock_build_dify_run_context, patch("services.workflow_service.DifyFileReferenceFactory") as mock_file_reference_factory_cls, patch("services.workflow_service.DifyHumanInputNodeRuntime") as mock_runtime_cls, + patch("services.workflow_service.DifyFileReferenceFactory") as mock_file_reference_factory_cls, patch("services.workflow_service.HumanInputNode") as mock_node_cls, ): mock_node_cls.validate_node_data.return_value = sentinel.node_data - node = service._build_human_input_node( + node = service._build_human_input_node_for_debugging( workflow=workflow, account=account, node_config=node_config, variable_pool=variable_pool ) assert node == mock_node_cls.return_value @@ -2878,11 +2884,10 @@ class TestWorkflowServiceFreeNodeExecution: call_depth=0, ) mock_runtime_cls.assert_called_once_with(mock_build_dify_run_context.return_value) + mock_file_reference_factory_cls.assert_called_once_with(mock_build_dify_run_context.return_value) mock_adapt_node_data.assert_called_once_with(node_config["data"]) mock_node_cls.validate_node_data.assert_called_once_with(sentinel.adapted_node_data) - mock_file_reference_factory_cls.assert_called_once_with( - mock_graph_init_context_cls.return_value.to_graph_init_params.return_value.run_context - ) + mock_file_reference_factory_cls.assert_called_once_with(mock_build_dify_run_context.return_value) mock_node_cls.assert_called_once_with( node_id="n-1", data=sentinel.node_data, diff --git a/api/tests/unit_tests/services/workflow/test_workflow_event_snapshot_service.py b/api/tests/unit_tests/services/workflow/test_workflow_event_snapshot_service.py index 17e9a077d6..a997ea3583 100644 --- a/api/tests/unit_tests/services/workflow/test_workflow_event_snapshot_service.py +++ b/api/tests/unit_tests/services/workflow/test_workflow_event_snapshot_service.py @@ -18,6 +18,8 @@ from core.app.entities.task_entities import StreamEvent from core.app.layers.pause_state_persist_layer import WorkflowResumptionContext, _WorkflowGenerateEntityWrapper from graphon.entities.pause_reason import HumanInputRequired from graphon.enums import WorkflowExecutionStatus, WorkflowNodeExecutionStatus +from graphon.nodes.human_input.entities import SelectInputConfig, StringListSource +from graphon.nodes.human_input.enums import ValueSourceType from graphon.runtime import GraphRuntimeState, VariablePool from models.enums import CreatorUserRole from models.model import AppMode @@ -106,7 +108,7 @@ def _build_snapshot(status: WorkflowNodeExecutionStatus) -> WorkflowNodeExecutio ) -def _build_resumption_context(task_id: str) -> WorkflowResumptionContext: +def _build_resumption_context(task_id: str, *, select_options: list[str] | None = None) -> WorkflowResumptionContext: app_config = WorkflowUIBasedAppConfig( tenant_id="tenant-1", app_id="app-1", @@ -125,6 +127,8 @@ def _build_resumption_context(task_id: str) -> WorkflowResumptionContext: workflow_execution_id="run-1", ) runtime_state = GraphRuntimeState(variable_pool=VariablePool(), start_at=0.0) + if select_options is not None: + runtime_state.variable_pool.add(("start", "options"), select_options) runtime_state.register_paused_node("node-1") runtime_state.outputs = {"result": "value"} wrapper = _WorkflowGenerateEntityWrapper(entity=generate_entity) @@ -787,6 +791,59 @@ def test_build_snapshot_events_preserves_public_form_token(monkeypatch: pytest.M assert pause_data["reasons"][0]["expiration_time"] == int(datetime(2024, 1, 1, tzinfo=UTC).timestamp()) +def test_build_snapshot_events_resolves_pause_reason_select_options(monkeypatch: pytest.MonkeyPatch) -> None: + workflow_run = _build_workflow_run(WorkflowExecutionStatus.PAUSED) + snapshot = _build_snapshot(WorkflowNodeExecutionStatus.PAUSED) + resumption_context = _build_resumption_context("task-ctx", select_options=["approve", "reject"]) + monkeypatch.setattr( + service_module, "load_form_tokens_by_form_id", lambda form_ids, session=None, surface=None: {"form-1": "wtok"} + ) + session_maker = _SessionMaker( + SimpleNamespace( + execute=lambda _stmt: [("form-1", datetime(2024, 1, 1, tzinfo=UTC), '{"display_in_ui": true}')], + ) + ) + pause_entity = _FakePauseEntity( + pause_id="pause-1", + workflow_run_id="run-1", + paused_at_value=datetime(2024, 1, 1, tzinfo=UTC), + pause_reasons=[ + HumanInputRequired( + form_id="form-1", + form_content="content", + inputs=[ + SelectInputConfig( + output_variable_name="decision", + option_source=StringListSource( + type=ValueSourceType.VARIABLE, + selector=["start", "options"], + value=[], + ), + ) + ], + node_id="node-1", + node_title="Human Input", + ) + ], + ) + + events = _build_snapshot_events( + workflow_run=workflow_run, + node_snapshots=[snapshot], + task_id="task-ctx", + message_context=None, + pause_entity=pause_entity, + resumption_context=resumption_context, + session_maker=cast(sessionmaker[Session], session_maker), + ) + + human_input_event = events[-2] + assert human_input_event["data"]["inputs"][0]["option_source"]["value"] == ["approve", "reject"] + + pause_event = events[-1] + assert pause_event["data"]["reasons"][0]["inputs"][0]["option_source"]["value"] == ["approve", "reject"] + + def test_build_workflow_event_stream_loads_pause_tokens_without_flask_app_context( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/api/tests/unit_tests/services/workflow/test_workflow_human_input_delivery.py b/api/tests/unit_tests/services/workflow/test_workflow_human_input_delivery.py index 170bb24b8a..4b677bca62 100644 --- a/api/tests/unit_tests/services/workflow/test_workflow_human_input_delivery.py +++ b/api/tests/unit_tests/services/workflow/test_workflow_human_input_delivery.py @@ -76,11 +76,11 @@ def test_human_input_delivery_allows_disabled_method(monkeypatch: pytest.MonkeyP service.get_draft_workflow = MagicMock(return_value=workflow) # type: ignore[method-assign] service._build_human_input_variable_pool = MagicMock(return_value=MagicMock()) # type: ignore[attr-defined] node_stub = MagicMock() - node_stub._render_form_content_before_submission.return_value = "rendered" - node_stub._resolve_default_values.return_value = {} - service._build_human_input_node = MagicMock(return_value=node_stub) # type: ignore[attr-defined] + node_stub.render_form_content_before_submission.return_value = "rendered" + node_stub.resolve_default_values.return_value = {} + service._build_human_input_node_for_debugging = MagicMock(return_value=node_stub) # type: ignore[attr-defined] service._create_human_input_delivery_test_form = MagicMock( # type: ignore[attr-defined] - return_value=("form-1", {}) + return_value=("form-1", []) ) test_service_instance = MagicMock() @@ -112,11 +112,11 @@ def test_human_input_delivery_dispatches_to_test_service(monkeypatch: pytest.Mon service.get_draft_workflow = MagicMock(return_value=workflow) # type: ignore[method-assign] service._build_human_input_variable_pool = MagicMock(return_value=MagicMock()) # type: ignore[attr-defined] node_stub = MagicMock() - node_stub._render_form_content_before_submission.return_value = "rendered" - node_stub._resolve_default_values.return_value = {} - service._build_human_input_node = MagicMock(return_value=node_stub) # type: ignore[attr-defined] + node_stub.render_form_content_before_submission.return_value = "rendered" + node_stub.resolve_default_values.return_value = {} + service._build_human_input_node_for_debugging = MagicMock(return_value=node_stub) # type: ignore[attr-defined] service._create_human_input_delivery_test_form = MagicMock( # type: ignore[attr-defined] - return_value=("form-1", {}) + return_value=("form-1", []) ) test_service_instance = MagicMock() @@ -151,11 +151,11 @@ def test_human_input_delivery_debug_mode_overrides_recipients(monkeypatch: pytes service.get_draft_workflow = MagicMock(return_value=workflow) # type: ignore[method-assign] service._build_human_input_variable_pool = MagicMock(return_value=MagicMock()) # type: ignore[attr-defined] node_stub = MagicMock() - node_stub._render_form_content_before_submission.return_value = "rendered" - node_stub._resolve_default_values.return_value = {} - service._build_human_input_node = MagicMock(return_value=node_stub) # type: ignore[attr-defined] + node_stub.render_form_content_before_submission.return_value = "rendered" + node_stub.resolve_default_values.return_value = {} + service._build_human_input_node_for_debugging = MagicMock(return_value=node_stub) # type: ignore[attr-defined] service._create_human_input_delivery_test_form = MagicMock( # type: ignore[attr-defined] - return_value=("form-1", {}) + return_value=("form-1", []) ) test_service_instance = MagicMock() diff --git a/docs/design/human-in-the-loop/hitl-form-file-upload-design.md b/docs/design/human-in-the-loop/hitl-form-file-upload-design.md new file mode 100644 index 0000000000..86a81d70c9 --- /dev/null +++ b/docs/design/human-in-the-loop/hitl-form-file-upload-design.md @@ -0,0 +1,184 @@ +# HITL Standalone Form File Upload Design + +## Context + +HITL standalone forms can be opened directly through a form link and do not require the +submitter to sign in through the Web App. After `file` and `file-list` inputs were introduced, +this standalone entry point also needed file upload support. + +This entry point has a different identity model from the existing upload paths: + +- Web App upload is backed by Web App authentication and an `EndUser` context. +- Service API upload is backed by API key authentication and the `user` parameter. +- HITL standalone form submission is link-based and anonymous from the product perspective. + The standalone submitter is not necessarily the workflow or chatflow initiator. + +The goal is therefore not to add another general-purpose upload channel. The goal is to +provide a constrained, short-lived upload capability that is scoped to one HITL form submission. + +## Goals + +- Support local file upload and remote URL upload from the HITL standalone page. +- Keep the standalone page independent from the Web App login flow. +- Avoid creating a technical HITL `EndUser`. +- Avoid changing the authentication model of existing Web App, Service API, or Console upload endpoints. +- Keep upload request parameters aligned with the equivalent Web App upload endpoints where possible. +- Invalidate upload capability once the form is submitted, expired, or timed out. +- Store uploaded files in a way that remains compatible with the existing workflow resume and file access model. + +## Decision + +HITL standalone upload uses a dedicated upload token that is bound to a form +recipient. The token authorizes file upload only while the related form is still valid. + +Files uploaded through the HITL standalone page are recorded under the workflow or +chatflow initiator, not under the anonymous standalone submitter and not under a technical HITL `EndUser`. + +This keeps workflow resume aligned with the existing execution model: one initiator owns the +workflow run context, and file restoration continues to resolve files through that initiator's +access scope. HITL-specific form/token/file relationships remain available for audit and tracing, +but they do not become the source of truth for file access control. + +## API Shape + +HITL standalone upload has three endpoint categories: + +| Purpose | HITL endpoint | Aligned Web App endpoint | +| --- | --- | --- | +| Issue upload token | `POST /api/form/human_input/{form_token}/upload-token` | No direct equivalent | +| Upload local file | `POST /api/form/human_input/files/upload` | `POST /api/files/upload` | +| Upload remote file | `POST /api/form/human_input/files/remote-upload` | `POST /api/remote-files/upload` | + +Local upload follows the Web App `POST /api/files/upload` parameter shape: + +- `multipart/form-data` +- Required `file` + +Remote upload follows the Web App `POST /api/remote-files/upload` parameter shape: + +- `application/json` +- Required `url` + +HITL upload endpoints do not accept the Service API `user` parameter. That parameter +belongs to the Service API `EndUser` mapping model and does not represent the anonymous +standalone form submitter. + +## Upload Token + +The upload token is issued through the form token: + +```http +POST /api/form/human_input/{form_token}/upload-token +``` + +Upload requests carry the token through the `Authorization` header: + +```http +Authorization: bearer hitl_upload_{random_value} +``` + +The `hitl_upload_` prefix only distinguishes this credential from other bearer token types. +Security comes from the high-entropy random value, server-side hash storage, and server-side state validation. + +The token is bound to at least: + +- The HITL form. +- The form recipient. +- The tenant. +- The app. + +The token must satisfy these rules: + +- It cannot outlive the form expiration. +- It cannot be used after the form is submitted, expired, or timed out. +- It is validated through the HITL upload path, not through the existing app token validation chain. + +## Why Authorization Header + +Putting `upload_token` in the request body would avoid additional CORS header configuration, but it has a bad failure mode for file upload. The server often needs to parse the multipart body before it can read a body token, so invalid requests can still consume upload parsing, temporary file, memory, or disk resources. + +Using `Authorization: bearer hitl_upload_{random_value}` keeps authentication before expensive business processing: + +- Invalid local upload requests can be rejected before reading the multipart body. +- Invalid remote upload requests can be rejected before any outbound network access. +- Bearer credential semantics are explicit and do not mix authentication with business fields. +- The token is not exposed through query strings, access logs, referrers, or browser history. + +The tradeoff is that cross-origin deployments must allow the `Authorization` header and accept browser preflight requests. This is a reasonable configuration cost for an earlier and clearer authentication boundary. + +## File Ownership + +The standalone submitter is not a reliable product identity. Assigning files to a technical `EndUser` would also conflict with workflow resume: existing file restoration expects files to be readable through the workflow or chatflow initiator's scope. + +The selected model is: + +- If the original run was started by an `Account`, standalone HITL uploads are stored under that `Account`. +- If the original run was started by an `EndUser`, standalone HITL uploads are stored under that `EndUser`. + +This means `UploadFile.created_by_role` and `UploadFile.created_by` continue to be the source of truth for file access control. HITL association records provide auditability but do not grant file access by themselves. + +## Persistence And Audit + +The HITL upload model has two responsibilities: + +- Upload tokens authorize a form recipient to upload files while the form remains valid. +- Upload-file association records trace which files were uploaded through which HITL upload token. + +These records are intentionally not tied to an `EndUser`. Their purpose is to preserve the HITL form/token/file relationship for audit and cleanup, not to define a separate file owner identity. + +## Local Upload Boundary + +Local upload should reuse the existing file upload semantics as much as possible: + +- Request parameters stay aligned with Web App local upload. +- Existing file size checks, extension restrictions, and document-type handling remain applicable. +- Response shape stays aligned with the existing file upload response. +- Token validation happens before reading the upload body. + +## Remote Upload Boundary + +Remote upload should reuse the existing remote upload semantics as much as possible: + +- Request parameters stay aligned with Web App remote upload. +- Token validation happens before outbound network access. +- Remote fetching continues to go through the existing SSRF-safe path. +- Remote filename, extension, MIME type, and file size inference stay aligned with existing behavior. +- Response shape stays aligned with the existing remote upload response. + +## Alternatives Considered + +### Unauthenticated Standalone Upload Endpoint + +This is the simplest implementation option and does not require Web App login, `EndUser`, or a new token model. It was not selected because it exposes a public file upload surface that can be abused in SaaS or internet-facing deployments. Adding authentication later would also change the endpoint contract after clients have integrated with it. + +### Reuse Web App Login And Upload + +This would maximize reuse of existing Web App upload behavior, but it would bind HITL standalone forms to Web App login, app code, Web App enablement, and enterprise SSO semantics. That coupling is undesirable because HITL forms can be reached from independent channels such as email. It also makes product behavior unclear when the Web App is disabled or the app code is reset. + +### Create `EndUser` From Form Token + +This would satisfy existing upload paths that require an `EndUser` context and would allow form state to limit upload capability. It was not selected because the created identity would be technical rather than a real submitter identity. It would also mix HITL standalone form behavior into the broader `EndUser` model already used by Web App, Service API, triggers, MCP, and other entry points. + +More importantly, files owned by this technical `EndUser` would not naturally be readable through the workflow initiator scope during workflow resume. + +### Technical `EndUser` With File Access Exception + +This would keep the technical `EndUser` as the file owner and add an access-control exception so the workflow initiator can read files uploaded through the same HITL form. It solves the immediate resume problem, but it pushes a HITL-specific rule into the general file access layer. Over time, that makes permission reasoning harder and increases the chance of accidental access expansion. + +Assigning files directly to the workflow or chatflow initiator avoids that bypass and keeps file access governed by the existing owner model. + +## Design Constraints + +- HITL standalone upload does not reuse the Web App login flow. +- HITL standalone upload does not create a technical HITL `EndUser`. +- Upload token validity is controlled by form state. +- File access control continues to use the `UploadFile` owner as the source of truth. +- HITL association records provide audit and traceability only. +- Workflow resume continues to restore submitted file values through the existing file restoration path. + +## Future Considerations + +- If endpoint parameters change, compare them with the corresponding Web App upload endpoint to avoid unnecessary drift. +- If file ownership changes, verify the workflow resume path and the file access model together. +- If HITL forms support multiple submissions or reopening, token invalidation semantics must be redefined. +- If remote upload policy expands, prefer extending the existing remote upload and SSRF-safe behavior instead of creating a HITL-only network path. diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 7675b4dd77..e1a54ec37c 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -145,11 +145,6 @@ "count": 1 } }, - "web/app/(humanInputLayout)/form/[token]/form.tsx": { - "react/set-state-in-effect": { - "count": 1 - } - }, "web/app/(shareLayout)/components/splash.tsx": { "react/set-state-in-effect": { "count": 1 @@ -857,16 +852,6 @@ "count": 1 } }, - "web/app/components/base/chat/chat/answer/human-input-content/utils.ts": { - "ts/no-explicit-any": { - "count": 1 - } - }, - "web/app/components/base/chat/chat/answer/index.tsx": { - "ts/no-explicit-any": { - "count": 1 - } - }, "web/app/components/base/chat/chat/answer/workflow-process.tsx": { "react/set-state-in-effect": { "count": 1 @@ -903,7 +888,7 @@ }, "web/app/components/base/chat/chat/index.tsx": { "ts/no-explicit-any": { - "count": 3 + "count": 2 }, "ts/no-non-null-asserted-optional-chain": { "count": 1 @@ -921,7 +906,7 @@ }, "web/app/components/base/chat/embedded-chatbot/chat-wrapper.tsx": { "ts/no-explicit-any": { - "count": 7 + "count": 6 } }, "web/app/components/base/chat/embedded-chatbot/context.ts": { @@ -1052,9 +1037,6 @@ "web/app/components/base/file-uploader/hooks.ts": { "react/no-unnecessary-use-prefix": { "count": 1 - }, - "ts/no-explicit-any": { - "count": 3 } }, "web/app/components/base/file-uploader/index.ts": { @@ -1632,11 +1614,6 @@ "count": 2 } }, - "web/app/components/base/prompt-editor/index.tsx": { - "ts/no-explicit-any": { - "count": 3 - } - }, "web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx": { "no-restricted-imports": { "count": 1 @@ -1684,16 +1661,6 @@ "count": 2 } }, - "web/app/components/base/prompt-editor/plugins/hitl-input-block/component-ui.tsx": { - "react-hooks/exhaustive-deps": { - "count": 1 - } - }, - "web/app/components/base/prompt-editor/plugins/hitl-input-block/hitl-input-block-replacement-block.tsx": { - "ts/no-explicit-any": { - "count": 1 - } - }, "web/app/components/base/prompt-editor/plugins/hitl-input-block/index.tsx": { "no-barrel-files/no-barrel-files": { "count": 3 @@ -4032,22 +3999,6 @@ "count": 1 } }, - "web/app/components/workflow/nodes/human-input/components/form-content-preview.tsx": { - "react/unsupported-syntax": { - "count": 1 - } - }, - "web/app/components/workflow/nodes/human-input/components/form-content.tsx": { - "react/no-nested-component-definitions": { - "count": 1 - }, - "react/set-state-in-effect": { - "count": 1 - }, - "ts/no-explicit-any": { - "count": 3 - } - }, "web/app/components/workflow/nodes/human-input/components/timeout.tsx": { "no-restricted-imports": { "count": 1 @@ -4058,11 +4009,6 @@ "count": 1 } }, - "web/app/components/workflow/nodes/human-input/components/variable-in-markdown.tsx": { - "react-refresh/only-export-components": { - "count": 2 - } - }, "web/app/components/workflow/nodes/human-input/types.ts": { "erasable-syntax-only/enums": { "count": 2 @@ -4644,7 +4590,7 @@ }, "web/app/components/workflow/panel/debug-and-preview/chat-wrapper.tsx": { "ts/no-explicit-any": { - "count": 6 + "count": 5 } }, "web/app/components/workflow/panel/debug-and-preview/conversation-variable-modal.tsx": { @@ -4657,7 +4603,7 @@ "count": 2 }, "ts/no-explicit-any": { - "count": 12 + "count": 11 } }, "web/app/components/workflow/panel/env-panel/variable-modal.tsx": { @@ -4683,7 +4629,7 @@ }, "web/app/components/workflow/panel/workflow-preview.tsx": { "ts/no-explicit-any": { - "count": 2 + "count": 1 } }, "web/app/components/workflow/run/agent-log/index.tsx": { @@ -5303,9 +5249,6 @@ "no-restricted-imports": { "count": 1 }, - "regexp/no-unused-capturing-group": { - "count": 1 - }, "ts/no-explicit-any": { "count": 2 } diff --git a/packages/contracts/generated/api/console/apps/types.gen.ts b/packages/contracts/generated/api/console/apps/types.gen.ts index c36a9ef7fc..c1bccffa9c 100644 --- a/packages/contracts/generated/api/console/apps/types.gen.ts +++ b/packages/contracts/generated/api/console/apps/types.gen.ts @@ -1710,6 +1710,9 @@ export type HumanInputFormSubmissionData = { node_id: string node_title: string rendered_content: string + submitted_data?: { + [key: string]: JsonValue2 + } | null } export type ExecutionContentType = 'human_input' @@ -1865,6 +1868,8 @@ export type UserActionConfig = { export type FormInputConfig = unknown +export type JsonValue2 = unknown + export type OutputErrorStrategy = 'default_value' | 'fail_branch' | 'stop' export type DeclaredOutputRetryConfig = { diff --git a/packages/contracts/generated/api/console/apps/zod.gen.ts b/packages/contracts/generated/api/console/apps/zod.gen.ts index 73d37bd958..ccff2c49f0 100644 --- a/packages/contracts/generated/api/console/apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/apps/zod.gen.ts @@ -1848,17 +1848,6 @@ export const zConversationPagination = z.object({ total: z.int(), }) -/** - * HumanInputFormSubmissionData - */ -export const zHumanInputFormSubmissionData = z.object({ - action_id: z.string(), - action_text: z.string(), - node_id: z.string(), - node_title: z.string(), - rendered_content: z.string(), -}) - /** * ExecutionContentType */ @@ -2154,6 +2143,20 @@ export const zAgentSensitiveWordAvoidanceFeatureConfig = z.object({ export const zFormInputConfig = z.unknown() +export const zJsonValue2 = z.unknown() + +/** + * HumanInputFormSubmissionData + */ +export const zHumanInputFormSubmissionData = z.object({ + action_id: z.string(), + action_text: z.string(), + node_id: z.string(), + node_title: z.string(), + rendered_content: z.string(), + submitted_data: z.record(z.string(), zJsonValue2).nullish(), +}) + /** * OutputErrorStrategy * diff --git a/packages/contracts/generated/api/web/orpc.gen.ts b/packages/contracts/generated/api/web/orpc.gen.ts index 4e7c949bdb..ee46faf350 100644 --- a/packages/contracts/generated/api/web/orpc.gen.ts +++ b/packages/contracts/generated/api/web/orpc.gen.ts @@ -64,6 +64,9 @@ import { zPostForgotPasswordValidityResponse, zPostFormHumanInputByFormTokenPath, zPostFormHumanInputByFormTokenResponse, + zPostFormHumanInputByFormTokenUploadTokenPath, + zPostFormHumanInputByFormTokenUploadTokenResponse, + zPostHumanInputFormsFilesResponse, zPostLoginBody, zPostLoginResponse, zPostLogoutResponse, @@ -469,6 +472,34 @@ export const forgotPassword = { validity: validity2, } +/** + * Issue an upload token for a human input form + * + * POST /api/form/human_input//upload-token + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const post13 = oc + .route({ + deprecated: true, + description: + 'POST /api/form/human_input//upload-token\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'POST', + operationId: 'postFormHumanInputByFormTokenUploadToken', + path: '/form/human_input/{form_token}/upload-token', + summary: 'Issue an upload token for a human input form', + tags: ['web'], + }) + .input(z.object({ params: zPostFormHumanInputByFormTokenUploadTokenPath })) + .output(zPostFormHumanInputByFormTokenUploadTokenResponse) + +export const uploadToken = { + post: post13, +} + /** * Get human input form definition by token * @@ -510,7 +541,7 @@ export const get2 = oc * * @deprecated */ -export const post13 = oc +export const post14 = oc .route({ deprecated: true, description: @@ -527,7 +558,8 @@ export const post13 = oc export const byFormToken = { get: get2, - post: post13, + post: post14, + uploadToken, } export const humanInput = { @@ -538,6 +570,29 @@ export const form = { humanInput, } +/** + * Upload one local file or remote URL file for a HITL human input form + */ +export const post15 = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postHumanInputFormsFiles', + path: '/human-input-forms/files', + successStatus: 201, + summary: 'Upload one local file or remote URL file for a HITL human input form', + tags: ['web'], + }) + .output(zPostHumanInputFormsFilesResponse) + +export const files2 = { + post: post15, +} + +export const humanInputForms = { + files: files2, +} + /** * Check login status */ @@ -561,7 +616,7 @@ export const status = { * * Authenticate user for web application access */ -export const post14 = oc +export const post16 = oc .route({ description: 'Authenticate user for web application access', inputStructure: 'detailed', @@ -575,14 +630,14 @@ export const post14 = oc .output(zPostLoginResponse) export const login = { - post: post14, + post: post16, status, } /** * Logout user from web application */ -export const post15 = oc +export const post17 = oc .route({ description: 'Logout user from web application', inputStructure: 'detailed', @@ -594,7 +649,7 @@ export const post15 = oc .output(zPostLogoutResponse) export const logout = { - post: post15, + post: post17, } /** @@ -604,7 +659,7 @@ export const logout = { * * @deprecated */ -export const post16 = oc +export const post18 = oc .route({ deprecated: true, description: @@ -624,7 +679,7 @@ export const post16 = oc .output(zPostMessagesByMessageIdFeedbacksResponse) export const feedbacks = { - post: post16, + post: post18, } /** @@ -813,7 +868,7 @@ export const passport = { * * @deprecated */ -export const post17 = oc +export const post19 = oc .route({ deprecated: true, description: @@ -829,7 +884,7 @@ export const post17 = oc .output(zPostRemoteFilesUploadResponse) export const upload2 = { - post: post17, + post: post19, } /** @@ -921,7 +976,7 @@ export const get11 = oc * * @deprecated */ -export const post18 = oc +export const post20 = oc .route({ deprecated: true, description: @@ -937,7 +992,7 @@ export const post18 = oc export const savedMessages = { get: get11, - post: post18, + post: post20, byMessageId: byMessageId2, } @@ -1014,7 +1069,7 @@ export const systemFeatures = { * * @deprecated */ -export const post19 = oc +export const post21 = oc .route({ deprecated: true, description: @@ -1030,7 +1085,7 @@ export const post19 = oc .output(zPostTextToAudioResponse) export const textToAudio = { - post: post19, + post: post21, } /** @@ -1123,7 +1178,7 @@ export const workflow = { * * @deprecated */ -export const post20 = oc +export const post22 = oc .route({ deprecated: true, description: @@ -1139,7 +1194,7 @@ export const post20 = oc .output(zPostWorkflowsRunResponse) export const run = { - post: post20, + post: post22, } /** @@ -1147,7 +1202,7 @@ export const run = { * * Stop a running workflow task. */ -export const post21 = oc +export const post23 = oc .route({ description: 'Stop a running workflow task.', inputStructure: 'detailed', @@ -1161,7 +1216,7 @@ export const post21 = oc .output(zPostWorkflowsTasksByTaskIdStopResponse) export const stop3 = { - post: post21, + post: post23, } export const byTaskId4 = { @@ -1186,6 +1241,7 @@ export const contract = { files, forgotPassword, form, + humanInputForms, login, logout, messages, diff --git a/packages/contracts/generated/api/web/types.gen.ts b/packages/contracts/generated/api/web/types.gen.ts index cc18ffaf59..d55fc42389 100644 --- a/packages/contracts/generated/api/web/types.gen.ts +++ b/packages/contracts/generated/api/web/types.gen.ts @@ -128,6 +128,15 @@ export type ForgotPasswordSendPayload = { language?: string | null } +export type HumanInputFileUploadFormPayload = { + url?: string | null +} + +export type HumanInputUploadTokenResponse = { + expires_at: number + upload_token: string +} + export type LicenseLimitationModel = { enabled: boolean limit: number @@ -853,6 +862,38 @@ export type PostFormHumanInputByFormTokenResponses = { export type PostFormHumanInputByFormTokenResponse = PostFormHumanInputByFormTokenResponses[keyof PostFormHumanInputByFormTokenResponses] +export type PostFormHumanInputByFormTokenUploadTokenData = { + body?: never + path: { + form_token: string + } + query?: never + url: '/form/human_input/{form_token}/upload-token' +} + +export type PostFormHumanInputByFormTokenUploadTokenResponses = { + 200: { + [key: string]: unknown + } +} + +export type PostFormHumanInputByFormTokenUploadTokenResponse + = PostFormHumanInputByFormTokenUploadTokenResponses[keyof PostFormHumanInputByFormTokenUploadTokenResponses] + +export type PostHumanInputFormsFilesData = { + body?: never + path?: never + query?: never + url: '/human-input-forms/files' +} + +export type PostHumanInputFormsFilesResponses = { + 201: FileResponse +} + +export type PostHumanInputFormsFilesResponse + = PostHumanInputFormsFilesResponses[keyof PostHumanInputFormsFilesResponses] + export type PostLoginData = { body: LoginPayload path?: never diff --git a/packages/contracts/generated/api/web/zod.gen.ts b/packages/contracts/generated/api/web/zod.gen.ts index 9f11a0eaeb..818bceced1 100644 --- a/packages/contracts/generated/api/web/zod.gen.ts +++ b/packages/contracts/generated/api/web/zod.gen.ts @@ -172,6 +172,23 @@ export const zForgotPasswordSendPayload = z.object({ language: z.string().nullish(), }) +/** + * HumanInputFileUploadFormPayload + * + * Parsed multipart form fields for HITL uploads. + */ +export const zHumanInputFileUploadFormPayload = z.object({ + url: z.url().min(1).max(2083).nullish(), +}) + +/** + * HumanInputUploadTokenResponse + */ +export const zHumanInputUploadTokenResponse = z.object({ + expires_at: z.int(), + upload_token: z.string(), +}) + /** * LicenseLimitationModel * @@ -545,6 +562,20 @@ export const zPostFormHumanInputByFormTokenPath = z.object({ */ export const zPostFormHumanInputByFormTokenResponse = z.record(z.string(), z.unknown()) +export const zPostFormHumanInputByFormTokenUploadTokenPath = z.object({ + form_token: z.string(), +}) + +/** + * Success + */ +export const zPostFormHumanInputByFormTokenUploadTokenResponse = z.record(z.string(), z.unknown()) + +/** + * File uploaded successfully + */ +export const zPostHumanInputFormsFilesResponse = zFileResponse + export const zPostLoginBody = zLoginPayload /** diff --git a/web/__mocks__/base-ui-select.tsx b/web/__mocks__/base-ui-select.tsx index a695bebe14..327d89712d 100644 --- a/web/__mocks__/base-ui-select.tsx +++ b/web/__mocks__/base-ui-select.tsx @@ -26,15 +26,21 @@ export const SelectTrigger = ({ children, ...props }: React.ButtonHTMLAttributes & { children?: ReactNode }) => ( - ) export const SelectValue = ({ placeholder }: { placeholder?: ReactNode }) => <>{placeholder} -export const SelectContent = ({ children }: { children?: ReactNode }) => ( -
{children}
+export const SelectContent = ({ + children, + popupClassName, +}: { + children?: ReactNode + popupClassName?: string +}) => ( +
{children}
) export const SelectItem = ({ diff --git a/web/__tests__/base/file-uploader-flow.test.tsx b/web/__tests__/base/file-uploader-flow.test.tsx index 81dccedfe5..c77c92ad31 100644 --- a/web/__tests__/base/file-uploader-flow.test.tsx +++ b/web/__tests__/base/file-uploader-flow.test.tsx @@ -11,6 +11,7 @@ const mockUploadRemoteFileInfo = vi.fn() vi.mock('@/next/navigation', () => ({ useParams: () => ({}), + usePathname: () => '/', })) vi.mock('@/service/common', () => ({ diff --git a/web/app/(humanInputLayout)/form/[token]/__tests__/form.spec.tsx b/web/app/(humanInputLayout)/form/[token]/__tests__/form.spec.tsx new file mode 100644 index 0000000000..ac919ffb05 --- /dev/null +++ b/web/app/(humanInputLayout)/form/[token]/__tests__/form.spec.tsx @@ -0,0 +1,357 @@ +import type { FormData } from '../form' +import type { HumanInputFieldValue } from '@/app/components/base/chat/chat/answer/human-input-content/field-renderer' +import { act, render, renderHook, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { UserActionButtonType } from '@/app/components/workflow/nodes/human-input/types' +import { InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types' +import { TransferMethod } from '@/types/app' +import FormContent from '../form' +import { useFormSubmit } from '../use-form-submit' + +const mockSubmitForm = vi.hoisted(() => vi.fn()) +const mockUseGetHumanInputForm = vi.hoisted(() => vi.fn()) +const mockContentItemState = vi.hoisted(() => ({ + staleAttachmentInputChange: undefined as ((name: string, value: unknown) => void) | undefined, + uploadedFile: { + id: 'file-1', + name: 'review.pdf', + size: 128, + type: 'document', + progress: 100, + transferMethod: 'local_file', + supportFileType: 'document', + uploadedId: 'upload-file-1', + }, + uploadingFile: { + id: 'file-1', + name: 'review.pdf', + size: 128, + type: 'document', + progress: 50, + transferMethod: 'local_file', + supportFileType: 'document', + uploadedId: undefined, + }, +})) + +vi.mock('@/next/navigation', () => ({ + useParams: () => ({ token: 'token-123' }), +})) + +vi.mock('@/service/use-share', () => ({ + useGetHumanInputForm: (...args: unknown[]) => mockUseGetHumanInputForm(...args), + useSubmitHumanInputForm: () => ({ + mutate: mockSubmitForm, + isPending: false, + }), +})) + +vi.mock('@/hooks/use-document-title', () => ({ + __esModule: true, + default: vi.fn(), +})) + +vi.mock('@/app/components/base/chat/chat/answer/human-input-content/content-item', () => ({ + __esModule: true, + default: ({ content, onInputChange }: { content: string, onInputChange: (name: string, value: unknown) => void }) => { + const isSummaryField = content.includes('summary') + const isAttachmentField = content.includes('attachments') + + if (isAttachmentField && !mockContentItemState.staleAttachmentInputChange) + mockContentItemState.staleAttachmentInputChange = onInputChange + + return ( +
+ {content} + {isSummaryField && ( + <> + + + + )} + {isAttachmentField && ( + <> + + + + )} +
+ ) + }, +})) + +vi.mock('@/app/components/base/chat/chat/answer/human-input-content/expiration-time', () => ({ + __esModule: true, + default: () =>
expiration-time
, +})) + +vi.mock('@/app/components/base/loading', () => ({ + __esModule: true, + default: () =>
loading
, +})) + +vi.mock('@/app/components/base/logo/dify-logo', () => ({ + __esModule: true, + default: () =>
dify-logo
, +})) + +vi.mock('@/app/components/base/app-icon', () => ({ + __esModule: true, + default: () =>
app-icon
, +})) + +describe('Human input share form', () => { + const formData: FormData = { + site: { + site: { + title: 'Review App', + icon_type: 'emoji', + icon: 'R', + icon_background: '#fff', + icon_url: '', + default_language: 'en-US', + description: '', + copyright: '', + privacy_policy: '', + custom_disclaimer: '', + prompt_public: false, + use_icon_as_answer_icon: false, + }, + }, + form_content: '{{#$output.summary#}} {{#$output.attachments#}}', + inputs: [ + { + type: InputVarType.paragraph, + output_variable_name: 'summary', + default: { + type: 'constant', + value: 'initial summary', + selector: [], + }, + }, + { + type: InputVarType.multiFiles, + output_variable_name: 'attachments', + allowed_file_extensions: ['.pdf'], + allowed_file_types: [SupportUploadFileTypes.document], + allowed_file_upload_methods: [TransferMethod.local_file], + number_limits: 3, + }, + ], + resolved_default_values: {}, + user_actions: [ + { + id: 'approve', + title: 'Approve', + button_style: UserActionButtonType.Primary, + }, + ], + expiration_time: 60, + } + + beforeEach(() => { + vi.clearAllMocks() + mockContentItemState.staleAttachmentInputChange = undefined + mockUseGetHumanInputForm.mockReturnValue({ + data: formData, + isLoading: false, + error: null, + }) + }) + + it('should render the loading state while the form is being fetched', () => { + mockUseGetHumanInputForm.mockReturnValue({ + data: undefined, + isLoading: true, + error: null, + }) + + render() + + expect(screen.getByText('loading')).toBeInTheDocument() + }) + + it('should render status cards for terminal fetch states', () => { + const cases = [ + { + error: { code: 'human_input_form_expired' }, + title: 'share.humanInput.sorry', + subtitle: 'share.humanInput.expired', + submissionID: true, + }, + { + error: { code: 'human_input_form_submitted' }, + title: 'share.humanInput.sorry', + subtitle: 'share.humanInput.completed', + submissionID: true, + }, + { + error: { code: 'web_form_rate_limit_exceeded' }, + title: 'share.humanInput.rateLimitExceeded', + subtitle: undefined, + submissionID: false, + }, + { + error: null, + title: 'share.humanInput.formNotFound', + subtitle: undefined, + submissionID: false, + }, + ] + + cases.forEach(({ error, title, subtitle, submissionID }) => { + mockUseGetHumanInputForm.mockReturnValue({ + data: undefined, + isLoading: false, + error, + }) + const { unmount } = render() + + expect(screen.getByText(title)).toBeInTheDocument() + if (subtitle) + expect(screen.getByText(subtitle)).toBeInTheDocument() + else + expect(screen.queryByText('share.humanInput.expired')).not.toBeInTheDocument() + + if (submissionID) + expect(screen.getByText('share.humanInput.submissionID:{"id":"token-123"}')).toBeInTheDocument() + else + expect(screen.queryByText(/share\.humanInput\.submissionID/)).not.toBeInTheDocument() + + expect(screen.getByText('share.chat.poweredBy')).toBeInTheDocument() + expect(screen.getByText('dify-logo')).toBeInTheDocument() + unmount() + }) + }) + + it('submits typed human input values through the share form mutation', async () => { + const user = userEvent.setup() + + render() + + await user.click(screen.getByRole('button', { name: 'share-update-summary' })) + await user.click(screen.getByRole('button', { name: 'share-update-attachments' })) + await user.click(screen.getByRole('button', { name: 'Approve' })) + + expect(mockSubmitForm).toHaveBeenCalledWith({ + token: 'token-123', + data: { + action: 'approve', + inputs: { + summary: 'updated summary', + attachments: [{ + type: 'document', + transfer_method: TransferMethod.local_file, + url: '', + upload_file_id: 'upload-file-1', + }], + }, + }, + }, expect.objectContaining({ + onSuccess: expect.any(Function), + })) + }) + + it('should show the success status after the submit mutation succeeds', async () => { + const user = userEvent.setup() + + render() + + await user.click(screen.getByRole('button', { name: 'share-update-summary' })) + await user.click(screen.getByRole('button', { name: 'share-update-attachments' })) + await user.click(screen.getByRole('button', { name: 'Approve' })) + + const options = mockSubmitForm.mock.calls[0]![1] as { onSuccess: () => void } + act(() => { + options.onSuccess() + }) + + expect(screen.getByText('share.humanInput.thanks')).toBeInTheDocument() + expect(screen.getByText('share.humanInput.recorded')).toBeInTheDocument() + expect(screen.getByText('share.humanInput.submissionID:{"id":"token-123"}')).toBeInTheDocument() + }) + + it('should submit empty inputs when there are no form values to process', () => { + const { result } = renderHook(() => useFormSubmit('token-empty')) + + act(() => { + result.current.submit( + undefined as unknown as Record, + 'reject', + [], + ) + }) + + expect(mockSubmitForm).toHaveBeenCalledWith({ + token: 'token-empty', + data: { + action: 'reject', + inputs: {}, + }, + }, expect.objectContaining({ + onSuccess: expect.any(Function), + })) + }) + + it('should keep initialized defaults when file upload uses the initial change callback', async () => { + const user = userEvent.setup() + + render() + + await user.click(screen.getByRole('button', { name: 'share-update-attachments' })) + await user.click(screen.getByRole('button', { name: 'Approve' })) + + expect(mockSubmitForm).toHaveBeenCalledWith({ + token: 'token-123', + data: { + action: 'approve', + inputs: { + summary: 'initial summary', + attachments: [{ + type: 'document', + transfer_method: TransferMethod.local_file, + url: '', + upload_file_id: 'upload-file-1', + }], + }, + }, + }, expect.objectContaining({ + onSuccess: expect.any(Function), + })) + }) + + it('should disable action buttons until every required field is filled and files are uploaded', async () => { + const user = userEvent.setup() + + render() + + const approveButton = screen.getByRole('button', { name: 'Approve' }) + expect(approveButton).toBeDisabled() + + await user.click(screen.getByRole('button', { name: 'share-uploading-attachments' })) + expect(approveButton).toBeDisabled() + + await user.click(screen.getByRole('button', { name: 'share-update-attachments' })) + expect(approveButton).toBeEnabled() + + await user.click(screen.getByRole('button', { name: 'share-clear-summary' })) + expect(approveButton).toBeDisabled() + + await user.click(screen.getByRole('button', { name: 'share-update-summary' })) + expect(approveButton).toBeEnabled() + }) +}) diff --git a/web/app/(humanInputLayout)/form/[token]/__tests__/page.spec.tsx b/web/app/(humanInputLayout)/form/[token]/__tests__/page.spec.tsx new file mode 100644 index 0000000000..b4026a1f55 --- /dev/null +++ b/web/app/(humanInputLayout)/form/[token]/__tests__/page.spec.tsx @@ -0,0 +1,19 @@ +import { render, screen } from '@testing-library/react' +import FormPage from '../page' + +vi.mock('../form', () => ({ + __esModule: true, + default: () =>
form-content
, +})) + +describe('Human input share form page', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render the form content inside the page shell', () => { + render() + + expect(screen.getByText('form-content')).toBeInTheDocument() + }) +}) diff --git a/web/app/(humanInputLayout)/form/[token]/form-status-card.tsx b/web/app/(humanInputLayout)/form/[token]/form-status-card.tsx new file mode 100644 index 0000000000..6b7813402d --- /dev/null +++ b/web/app/(humanInputLayout)/form/[token]/form-status-card.tsx @@ -0,0 +1,51 @@ +import type { ReactNode } from 'react' +import { cn } from '@langgenius/dify-ui/cn' +import { useTranslation } from 'react-i18next' +import DifyLogo from '@/app/components/base/logo/dify-logo' + +type FormStatusCardProps = { + iconClassName: string + title: ReactNode + subtitle?: ReactNode + submissionID?: string +} + +const FormStatusCard = ({ + iconClassName, + title, + subtitle, + submissionID, +}: FormStatusCardProps) => { + const { t } = useTranslation() + + return ( +
+
+
+
+ +
+
+
{title}
+ {!!subtitle && ( +
{subtitle}
+ )} +
+ {submissionID && ( +
+ {t('humanInput.submissionID', { id: submissionID, ns: 'share' })} +
+ )} +
+
+
+
{t('chat.poweredBy', { ns: 'share' })}
+ +
+
+
+
+ ) +} + +export default FormStatusCard diff --git a/web/app/(humanInputLayout)/form/[token]/form.tsx b/web/app/(humanInputLayout)/form/[token]/form.tsx index 0eb62b1cb2..55491462ce 100644 --- a/web/app/(humanInputLayout)/form/[token]/form.tsx +++ b/web/app/(humanInputLayout)/form/[token]/form.tsx @@ -1,34 +1,23 @@ 'use client' -import type { ButtonProps } from '@langgenius/dify-ui/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 { Button } from '@langgenius/dify-ui/button' -import { cn } from '@langgenius/dify-ui/cn' -import { - RiCheckboxCircleFill, - RiErrorWarningFill, - RiInformation2Fill, -} from '@remixicon/react' -import { produce } from 'immer' +import type { HumanInputResolvedValue } from '@/types/workflow' 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 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 { useParams } from '@/next/navigation' -import { useGetHumanInputForm, useSubmitHumanInputForm } from '@/service/use-share' +import { useGetHumanInputForm } from '@/service/use-share' +import FormStatusCard from './form-status-card' +import LoadedFormContent from './loaded-form-content' +import { useFormSubmit } from './use-form-submit' export type FormData = { site: { site: SiteInfo } form_content: string inputs: FormInputItem[] - resolved_default_values: Record + resolved_default_values: Record user_actions: UserAction[] expiration_time: number } @@ -39,58 +28,13 @@ const FormContent = () => { 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 { isSubmitting, submit, success } = useFormSubmit(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 ( @@ -99,190 +43,62 @@ const FormContent = () => { 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' })}
- -
-
-
-
+ ) } diff --git a/web/app/(humanInputLayout)/form/[token]/loaded-form-content.tsx b/web/app/(humanInputLayout)/form/[token]/loaded-form-content.tsx new file mode 100644 index 0000000000..00ab0ac1b7 --- /dev/null +++ b/web/app/(humanInputLayout)/form/[token]/loaded-form-content.tsx @@ -0,0 +1,98 @@ +import type { ButtonProps } from '@langgenius/dify-ui/button' +import type { FormData } from './form' +import type { HumanInputFieldValue } from '@/app/components/base/chat/chat/answer/human-input-content/field-renderer' +import type { UserAction } from '@/app/components/workflow/nodes/human-input/types' +import { Button } from '@langgenius/dify-ui/button' +import { cn } from '@langgenius/dify-ui/cn' +import { produce } from 'immer' +import { useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import AppIcon from '@/app/components/base/app-icon' +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, getRenderedFormInputs, hasInvalidRequiredHumanInput, initializeInputs, splitByOutputVar } from '@/app/components/base/chat/chat/answer/human-input-content/utils' +import DifyLogo from '@/app/components/base/logo/dify-logo' + +type LoadedFormContentProps = { + formData: FormData + isSubmitting: boolean + onSubmit: (inputs: Record, actionID: string, formInputs: FormData['inputs']) => void +} + +const LoadedFormContent = ({ + formData, + isSubmitting, + onSubmit, +}: LoadedFormContentProps) => { + const { t } = useTranslation() + const renderedFormInputs = getRenderedFormInputs(formData.inputs, formData.form_content) + const [inputs, setInputs] = useState>(() => + initializeInputs(renderedFormInputs, formData.resolved_default_values), + ) + + const contentList = useMemo(() => { + return splitByOutputVar(formData.form_content) + }, [formData.form_content]) + + const handleInputsChange = (name: string, value: HumanInputFieldValue) => { + setInputs(prevInputs => produce(prevInputs, (draft) => { + draft[name] = value + })) + } + + const submit = (actionID: string) => { + onSubmit(inputs, actionID, formData.inputs) + } + + const isActionDisabled = isSubmitting || hasInvalidRequiredHumanInput(renderedFormInputs, inputs) + 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 LoadedFormContent diff --git a/web/app/(humanInputLayout)/form/[token]/page.tsx b/web/app/(humanInputLayout)/form/[token]/page.tsx index a7e2305b2b..db7952ef1a 100644 --- a/web/app/(humanInputLayout)/form/[token]/page.tsx +++ b/web/app/(humanInputLayout)/form/[token]/page.tsx @@ -4,7 +4,7 @@ import FormContent from './form' const FormPage = () => { return ( -
+
) diff --git a/web/app/(humanInputLayout)/form/[token]/use-form-submit.ts b/web/app/(humanInputLayout)/form/[token]/use-form-submit.ts new file mode 100644 index 0000000000..de018b28ed --- /dev/null +++ b/web/app/(humanInputLayout)/form/[token]/use-form-submit.ts @@ -0,0 +1,33 @@ +import type { HumanInputFieldValue } from '@/app/components/base/chat/chat/answer/human-input-content/field-renderer' +import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types' +import { useCallback, useState } from 'react' +import { getProcessedHumanInputFormInputs } from '@/app/components/base/chat/chat/answer/human-input-content/utils' +import { useSubmitHumanInputForm } from '@/service/use-share' + +export const useFormSubmit = (token: string) => { + const [success, setSuccess] = useState(false) + const { mutate: submitForm, isPending: isSubmitting } = useSubmitHumanInputForm() + + const submit = useCallback((inputs: Record, actionID: string, formInputs: FormInputItem[]) => { + submitForm( + { + token, + data: { + inputs: getProcessedHumanInputFormInputs(formInputs, inputs) || {}, + action: actionID, + }, + }, + { + onSuccess: () => { + setSuccess(true) + }, + }, + ) + }, [submitForm, token]) + + return { + isSubmitting, + submit, + success, + } +} diff --git a/web/app/components/app/configuration/config-var/config-modal/__tests__/type-select.spec.tsx b/web/app/components/app/configuration/config-var/config-modal/__tests__/type-select.spec.tsx index f1356c9b61..00c96c3e00 100644 --- a/web/app/components/app/configuration/config-var/config-modal/__tests__/type-select.spec.tsx +++ b/web/app/components/app/configuration/config-var/config-modal/__tests__/type-select.spec.tsx @@ -1,5 +1,6 @@ /* eslint-disable ts/no-explicit-any */ -import { fireEvent, render, screen } from '@testing-library/react' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import TypeSelector from '../type-select' vi.mock('@langgenius/dify-ui/select', () => import('@/__mocks__/base-ui-select')) @@ -9,8 +10,9 @@ vi.mock('@/app/components/workflow/nodes/_base/components/input-var-type-icon', })) describe('TypeSelector', () => { - it('should toggle open state and select a new variable type', () => { + it('should select a new variable type when an option is clicked', async () => { const onSelect = vi.fn() + const user = userEvent.setup() render( { />, ) - fireEvent.click(screen.getByRole('button')) - fireEvent.click(screen.getByText('Number')) + await user.click(screen.getByRole('combobox')) + const [, numberOption] = await screen.findAllByRole('option') + await user.click(numberOption!) expect(onSelect).toHaveBeenCalledWith({ value: 'number', name: 'Number' }) }) + + it('should size popup content to match the trigger width', async () => { + const user = userEvent.setup() + + render( + , + ) + + await user.click(screen.getByRole('combobox')) + + const [, numberOption] = await screen.findAllByRole('option') + const popup = numberOption!.closest('[data-side]') + + expect(popup).toHaveClass('w-(--anchor-width)') + }) }) diff --git a/web/app/components/app/configuration/config-var/config-modal/type-select.tsx b/web/app/components/app/configuration/config-var/config-modal/type-select.tsx index acd3253f6b..352b3139ff 100644 --- a/web/app/components/app/configuration/config-var/config-modal/type-select.tsx +++ b/web/app/components/app/configuration/config-var/config-modal/type-select.tsx @@ -59,9 +59,10 @@ const TypeSelector: FC = ({
{selectedItem?.name} @@ -73,7 +74,7 @@ const TypeSelector: FC = ({ {items.map((item: Item) => ( diff --git a/web/app/components/app/text-generate/item/index.tsx b/web/app/components/app/text-generate/item/index.tsx index c75350e18d..0fb085dd87 100644 --- a/web/app/components/app/text-generate/item/index.tsx +++ b/web/app/components/app/text-generate/item/index.tsx @@ -1,5 +1,6 @@ 'use client' import type { FC } from 'react' +import type { HumanInputFormSubmitData } from '@/app/components/base/chat/chat/answer/human-input-content/type' import type { FeedbackType } from '@/app/components/base/chat/chat/type' import type { WorkflowProcess } from '@/app/components/base/chat/types' import type { SiteInfo } from '@/models/share' @@ -178,7 +179,7 @@ const GenerationItem: FC = ({ // eslint-disable-next-line react/set-state-in-effect setCurrentTab(getDefaultGenerationTab(workflowProcessData)) }, [workflowProcessData]) - const handleSubmitHumanInputForm = useCallback(async (formToken: string, formData: { inputs: Record, action: string }) => { + const handleSubmitHumanInputForm = useCallback(async (formToken: string, formData: HumanInputFormSubmitData) => { if (appSourceType === AppSourceType.installedApp) await submitHumanInputFormService(formToken, formData) else diff --git a/web/app/components/app/text-generate/item/workflow-body.tsx b/web/app/components/app/text-generate/item/workflow-body.tsx index fb73161d5d..c15895b567 100644 --- a/web/app/components/app/text-generate/item/workflow-body.tsx +++ b/web/app/components/app/text-generate/item/workflow-body.tsx @@ -1,5 +1,6 @@ 'use client' import type { FC } from 'react' +import type { HumanInputFormSubmitData } from '@/app/components/base/chat/chat/answer/human-input-content/type' import type { WorkflowProcess } from '@/app/components/base/chat/types' import type { SiteInfo } from '@/models/share' import { cn } from '@langgenius/dify-ui/cn' @@ -17,7 +18,7 @@ type WorkflowBodyProps = { depth: number hideProcessDetail?: boolean isError: boolean - onSubmitHumanInputForm: (formToken: string, formData: { inputs: Record, action: string }) => Promise + onSubmitHumanInputForm: (formToken: string, formData: HumanInputFormSubmitData) => Promise onSwitchTab: (tab: string) => void showResultTabs: boolean siteInfo: SiteInfo | null diff --git a/web/app/components/base/chat/chat-with-history/__tests__/hooks.spec.tsx b/web/app/components/base/chat/chat-with-history/__tests__/hooks.spec.tsx index e2dee20cad..35478bae7b 100644 --- a/web/app/components/base/chat/chat-with-history/__tests__/hooks.spec.tsx +++ b/web/app/components/base/chat/chat-with-history/__tests__/hooks.spec.tsx @@ -1076,6 +1076,10 @@ describe('useChatWithHistory', () => { await waitFor(() => { expect(result!.current.appPrevChatTree.length).toBeGreaterThan(0) }) + + const answerNode = result!.current.appPrevChatTree[0]?.children?.[0] + expect(answerNode?.humanInputFormDataList).toHaveLength(1) + expect(answerNode?.workflow_run_id).toBe('wf-run-1') }) it('should set workflow_run_id for normal messages with submitted human_input', async () => { @@ -1114,6 +1118,75 @@ describe('useChatWithHistory', () => { await waitFor(() => { expect(result!.current.appPrevChatTree.length).toBeGreaterThan(0) }) + + const answerNode = result!.current.appPrevChatTree[0]?.children?.[0] + expect(answerNode?.humanInputFilledFormDataList).toHaveLength(1) + }) + + it('should parse human input payloads regardless of message status', async () => { + const listData = createConversationData({ + data: [createConversationItem({ id: 'conversation-1' })], + }) + const chatListData = { + data: [ + { + id: 'msg-status-agnostic', + query: 'Needs review', + answer: 'Pending follow-up', + message_files: [], + feedback: null, + retriever_resources: [], + agent_thoughts: null, + parent_message_id: null, + inputs: {}, + status: 'error', + extra_contents: [ + { + type: 'human_input', + submitted: true, + form_definition: { + form_id: 'form-1', + node_id: 'node-1', + node_title: 'Human Input', + form_content: '{{#$output.summary#}}', + inputs: [], + actions: [], + form_token: 'token-1', + resolved_default_values: {}, + display_in_ui: true, + expiration_time: 0, + }, + workflow_run_id: 'wf-run-status-agnostic', + form_submission_data: { + node_id: 'node-1', + node_title: 'Human Input', + rendered_content: 'Submitted summary', + action_id: 'submit', + action_text: 'Submit', + submitted_data: { + summary: 'approved', + }, + }, + }, + ], + }, + ], + } + mockFetchConversations.mockResolvedValue(listData) + mockFetchChatList.mockResolvedValue(chatListData) + + const { result } = await renderWithClient(() => useChatWithHistory()) + + await waitFor(() => { + expect(result!.current.appPrevChatTree.length).toBeGreaterThan(0) + }) + + const answerNode = result!.current.appPrevChatTree[0]?.children?.[0] + expect(answerNode?.humanInputFormDataList).toHaveLength(0) + expect(answerNode?.humanInputFilledFormDataList).toHaveLength(1) + expect(answerNode?.humanInputFilledFormDataList?.[0]?.form_content).toBe('{{#$output.summary#}}') + expect(answerNode?.humanInputFilledFormDataList?.[0]?.inputs).toEqual([]) + expect(answerNode?.workflow_run_id).toBe('wf-run-status-agnostic') }) it('should return empty appPrevChatTree when there is no currentConversationId', async () => { @@ -1835,6 +1908,15 @@ describe('useChatWithHistory', () => { expect(messageWithFiles?.message_files).toHaveLength(1) expect(messageWithFiles?.children?.[0]?.message_files).toHaveLength(1) expect(messageWithFiles?.children?.[0]?.agent_thoughts?.[0]?.message_files).toHaveLength(1) + + const normalAnswerNode = messageWithFiles?.children?.[0] + const pausedAnswerNode = result!.current.appPrevChatTree.find(item => item.id === 'question-msg-paused-branch')?.children?.[0] + + expect(normalAnswerNode?.humanInputFilledFormDataList).toHaveLength(1) + expect(normalAnswerNode?.humanInputFormDataList).toHaveLength(0) + expect(pausedAnswerNode?.humanInputFormDataList).toHaveLength(1) + expect(pausedAnswerNode?.humanInputFilledFormDataList).toHaveLength(0) + expect(pausedAnswerNode?.workflow_run_id).toBe('wf-run-branch') }) }) 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 9f89b2e231..765a32ba69 100644 --- a/web/app/components/base/chat/chat-with-history/hooks.tsx +++ b/web/app/components/base/chat/chat-with-history/hooks.tsx @@ -18,6 +18,7 @@ import { AppSourceType, delConversation, pinConversation, renameConversation, un import { useInvalidateShareConversations, useShareChatList, useShareConversationName, useShareConversations } from '@/service/use-share' import { TransferMethod } from '@/types/app' import { addFileInfos, sortAgentSorts } from '../../../tools/utils' +import { enrichSubmittedHumanInputFormData } from '../chat/answer/human-input-content/submitted-utils' import { CONVERSATION_ID_INFO } from '../constants' import { buildChatItemTree, getProcessedSystemVariablesFromUrlParams, getRawInputsFromUrlParams, getRawUserVariablesFromUrlParams } from '../utils' @@ -39,21 +40,30 @@ function getFormattedChatList(messages: any[]) { 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) - } - }) - } + item.extra_contents?.forEach((content: ExtraContent) => { + if (content.type !== 'human_input') + return + + const formDefinition = 'form_definition' in content ? content.form_definition : undefined + if (!content.submitted) { + if (!formDefinition) + return + humanInputFormDataList.push(formDefinition) + workflowRunId = content.workflow_run_id || workflowRunId + return + } + + if (!('form_submission_data' in content) || !content.form_submission_data) + return + const currentFormIndex = humanInputFormDataList.findIndex(item => item.node_id === content.form_submission_data.node_id) + const requiredFormData = formDefinition || (currentFormIndex > -1 + ? humanInputFormDataList[currentFormIndex] + : undefined) + if (currentFormIndex > -1) + humanInputFormDataList.splice(currentFormIndex, 1) + workflowRunId = content.workflow_run_id || workflowRunId + humanInputFilledFormDataList.push(enrichSubmittedHumanInputFormData(content.form_submission_data, requiredFormData)) + }) newChatList.push({ id: item.id, content: item.answer, diff --git a/web/app/components/base/chat/chat/__tests__/hooks.spec.tsx b/web/app/components/base/chat/chat/__tests__/hooks.spec.tsx index 738ab79bca..3a63ca0886 100644 --- a/web/app/components/base/chat/chat/__tests__/hooks.spec.tsx +++ b/web/app/components/base/chat/chat/__tests__/hooks.spec.tsx @@ -411,7 +411,7 @@ describe('useChat', () => { // Human input required callbacks.onHumanInputRequired({ data: { node_id: 'n-human' } }) - callbacks.onHumanInputRequired({ data: { node_id: 'n-human', updated: true } }) // update existing + callbacks.onHumanInputRequired({ data: { node_id: 'n-human', updated: true, form_content: '{{#$output.answer#}}', inputs: [] } }) // update existing // setTimeout for timeout form callbacks.onHumanInputFormTimeout({ data: { node_id: 'n-human', expiration_time: 123456 } }) @@ -437,6 +437,10 @@ describe('useChat', () => { const lastResponse = result.current.chatList[1] expect(lastResponse!.humanInputFormDataList).toHaveLength(0) // Removed when filled expect(lastResponse!.humanInputFilledFormDataList).toHaveLength(2) + expect(lastResponse!.humanInputFilledFormDataList![0]).toEqual(expect.objectContaining({ + form_content: '{{#$output.answer#}}', + inputs: [], + })) expect(sseGet).toHaveBeenCalled() // from workflowPaused expect(lastResponse!.annotation?.id).toBe('anno-1') expect(lastResponse!.content).toBe('Replaced content') diff --git a/web/app/components/base/chat/chat/answer/__tests__/human-input-filled-form-list.spec.tsx b/web/app/components/base/chat/chat/answer/__tests__/human-input-filled-form-list.spec.tsx index 37556550ca..09cced154f 100644 --- a/web/app/components/base/chat/chat/answer/__tests__/human-input-filled-form-list.spec.tsx +++ b/web/app/components/base/chat/chat/answer/__tests__/human-input-filled-form-list.spec.tsx @@ -1,5 +1,6 @@ import type { HumanInputFilledFormData } from '@/types/workflow' import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { describe, expect, it } from 'vitest' import HumanInputFilledFormList from '../human-input-filled-form-list' @@ -12,13 +13,15 @@ const createFormData = ( ): HumanInputFilledFormData => ({ node_id: 'node-1', node_title: 'Node Title', + rendered_content: 'fallback content', + action_id: 'approve', + action_text: 'Approve', + submitted_data: { + summary: 'Approved', + }, - // 👇 IMPORTANT - // DO NOT guess properties like `inputs` - // Only include fields that actually exist in your project type. - // Leave everything else empty via spread. ...overrides, -} as HumanInputFilledFormData) +}) describe('HumanInputFilledFormList', () => { it('renders nothing when list is empty', () => { @@ -27,12 +30,15 @@ describe('HumanInputFilledFormList', () => { expect(screen.queryByText('Node Title')).not.toBeInTheDocument() }) - it('renders one form item', () => { + it('renders one form item', async () => { + const user = userEvent.setup() const data = [createFormData()] render() expect(screen.getByText('Node Title')).toBeInTheDocument() + await user.click(screen.getByTestId('expand-icon')) + expect(screen.getByTestId('submitted-field-summary')).toHaveTextContent('Approved') }) it('renders multiple form items', () => { diff --git a/web/app/components/base/chat/chat/answer/__tests__/operation.spec.tsx b/web/app/components/base/chat/chat/answer/__tests__/operation.spec.tsx index 816fd33341..bd3de0cec3 100644 --- a/web/app/components/base/chat/chat/answer/__tests__/operation.spec.tsx +++ b/web/app/components/base/chat/chat/answer/__tests__/operation.spec.tsx @@ -248,6 +248,13 @@ describe('Operation', () => { expect(screen.getByTestId('log-btn'))!.toBeInTheDocument() }) + it('should keep hover-only controls visible when a descendant popup is open', () => { + renderOperation({ ...baseProps, showPromptLog: true }) + + expect(screen.getByTestId('operation-actions')).toHaveClass('group-has-[[data-popup-open]]:flex') + expect(screen.getByTestId('log-btn').parentElement).toHaveClass('group-has-[[data-popup-open]]:block') + }) + it('should not show prompt log for opening statements', () => { const item = { ...baseItem, isOpeningStatement: true } renderOperation({ ...baseProps, item, showPromptLog: true }) diff --git a/web/app/components/base/chat/chat/answer/human-input-content/__tests__/content-item.spec.tsx b/web/app/components/base/chat/chat/answer/human-input-content/__tests__/content-item.spec.tsx index b1a6ec51ae..db5da11777 100644 --- a/web/app/components/base/chat/chat/answer/human-input-content/__tests__/content-item.spec.tsx +++ b/web/app/components/base/chat/chat/answer/human-input-content/__tests__/content-item.spec.tsx @@ -1,13 +1,35 @@ +import type { FileEntity } from '@/app/components/base/file-uploader/types' import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types' import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { describe, expect, it, vi } from 'vitest' +import { TransferMethod } from '@/types/app' import ContentItem from '../content-item' vi.mock('@/app/components/base/markdown', () => ({ Markdown: ({ content }: { content: string }) =>
{content}
, })) +vi.mock('../field-renderer', () => ({ + __esModule: true, + default: ({ + field, + onChange, + }: { + field: FormInputItem + onChange: (value: unknown) => void + }) => ( + + ), +})) + describe('ContentItem', () => { const mockOnInputChange = vi.fn() const mockFormInputFields: FormInputItem[] = [ @@ -49,9 +71,9 @@ describe('ContentItem', () => { />, ) - const textarea = screen.getByTestId('content-item-textarea') + const textarea = screen.getByTestId('renderer-paragraph') expect(textarea).toBeInTheDocument() - expect(textarea).toHaveValue('Initial bio') + expect(screen.getByRole('button', { name: 'user_bio' })).toBeInTheDocument() expect(screen.queryByTestId('mock-markdown')).not.toBeInTheDocument() }) @@ -66,10 +88,9 @@ describe('ContentItem', () => { />, ) - const textarea = screen.getByTestId('content-item-textarea') - await user.type(textarea, 'x') + await user.click(screen.getByTestId('renderer-paragraph')) - expect(mockOnInputChange).toHaveBeenCalledWith('user_bio', 'Initial biox') + expect(mockOnInputChange).toHaveBeenCalledWith('user_bio', 'updated value') }) it('should render nothing if field name is valid but not found in formInputFields', () => { @@ -85,18 +106,20 @@ describe('ContentItem', () => { expect(container.firstChild).toBeNull() }) - it('should render nothing if input type is not supported', () => { - const { container } = render( + it('should delegate select fields to the shared renderer', async () => { + const user = userEvent.setup() + + render( { />, ) - expect(container.querySelector('[data-testid="content-item-textarea"]')).not.toBeInTheDocument() - expect(container.querySelector('.py-3')?.textContent).toBe('') + await user.click(screen.getByTestId('renderer-select')) + + expect(mockOnInputChange).toHaveBeenCalledWith('user_bio', 'select') + }) + + it('should delegate single-file fields to the shared renderer', async () => { + const user = userEvent.setup() + + render( + , + ) + + await user.click(screen.getByTestId('renderer-file')) + + expect(mockOnInputChange).toHaveBeenCalledWith('attachment', 'file') + }) + + it('should delegate file-list fields to the shared renderer', async () => { + const user = userEvent.setup() + const existingFiles: FileEntity[] = [{ + id: 'file-1', + name: 'brief.pdf', + size: 128, + type: 'document', + progress: 100, + transferMethod: TransferMethod.local_file, + supportFileType: 'document', + }] + + render( + , + ) + + await user.click(screen.getByTestId('renderer-file-list')) + + expect(mockOnInputChange).toHaveBeenCalledWith('attachments', 'file-list') }) }) diff --git a/web/app/components/base/chat/chat/answer/human-input-content/__tests__/field-renderer.spec.tsx b/web/app/components/base/chat/chat/answer/human-input-content/__tests__/field-renderer.spec.tsx new file mode 100644 index 0000000000..9dc4fecf71 --- /dev/null +++ b/web/app/components/base/chat/chat/answer/human-input-content/__tests__/field-renderer.spec.tsx @@ -0,0 +1,278 @@ +import type { FileEntity } from '@/app/components/base/file-uploader/types' +import { fireEvent, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types' +import { TransferMethod } from '@/types/app' +import HumanInputFieldRenderer from '../field-renderer' + +function MockTextarea({ + value, + onChange, + onValueChange, + ...props +}: { + value: string + onChange?: (event: { target: { value: string } }) => void + onValueChange?: (value: string) => void +} & React.TextareaHTMLAttributes) { + return ( +