feat(api): introduce select, file and file list form input types to Human Input node (#36322)

Co-authored-by: JzoNg <jzongcode@gmail.com>
Co-authored-by: GPT 5.4 <codex@openai.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: -LAN- <laipz8200@outlook.com>
This commit is contained in:
QuantumGhost 2026-06-04 09:54:28 +08:00 committed by GitHub
parent 44725dde74
commit 3c98f96ae8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
188 changed files with 11094 additions and 1158 deletions

View File

@ -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

View File

@ -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

View File

@ -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")

View File

@ -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",

View File

@ -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

View File

@ -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/<string:form_token>/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/<form_token>/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/<string:form_token>")
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):

View File

@ -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

View File

@ -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():

View File

@ -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):
"""

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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(
*,

View File

@ -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,

View File

@ -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")

View File

@ -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",

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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/<form_token>/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

View File

@ -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,
),
)

View File

@ -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)

View File

@ -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,
)

View File

@ -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

View File

@ -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

View File

@ -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"]

View File

@ -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()

View File

@ -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,

View File

@ -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,

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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"},
},
}
]

View File

@ -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()

View File

@ -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",

View File

@ -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",

View File

@ -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",
[

View File

@ -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",
)

View File

@ -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",

View File

@ -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(

View File

@ -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"),
[

View File

@ -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"]

View File

@ -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

View File

@ -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="<p>x</p>",
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",

View File

@ -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):

View File

@ -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"])

View File

@ -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"]

View File

@ -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():

View File

@ -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"

View File

@ -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)

View File

@ -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(
{

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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,

View File

@ -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,

View File

@ -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"}

View File

@ -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)

View File

@ -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="<p>hello</p>",
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": "<p>Pick one and upload files</p>",
"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": "<p>Validate form data</p>",
"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="<p>hello</p>",
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="<p>hello</p>",
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="<p>hello</p>",
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="<p>hello</p>",
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="<p>hello</p>",
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()

View File

@ -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,

View File

@ -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:

View File

@ -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()

View File

@ -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.

View File

@ -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
}

View File

@ -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 = {

View File

@ -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
*

View File

@ -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/<form_token>/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/<form_token>/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,

View File

@ -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

View File

@ -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
/**

View File

@ -26,15 +26,21 @@ export const SelectTrigger = ({
children,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { children?: ReactNode }) => (
<button type="button" {...props}>
<button type="button" role="combobox" {...props}>
{children}
</button>
)
export const SelectValue = ({ placeholder }: { placeholder?: ReactNode }) => <>{placeholder}</>
export const SelectContent = ({ children }: { children?: ReactNode }) => (
<div data-testid="select-content">{children}</div>
export const SelectContent = ({
children,
popupClassName,
}: {
children?: ReactNode
popupClassName?: string
}) => (
<div data-side="bottom" data-testid="select-content" className={popupClassName}>{children}</div>
)
export const SelectItem = ({

View File

@ -11,6 +11,7 @@ const mockUploadRemoteFileInfo = vi.fn()
vi.mock('@/next/navigation', () => ({
useParams: () => ({}),
usePathname: () => '/',
}))
vi.mock('@/service/common', () => ({

View File

@ -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 (
<div data-testid="share-form-content-item">
{content}
{isSummaryField && (
<>
<button type="button" onClick={() => onInputChange('summary', '')}>
share-clear-summary
</button>
<button type="button" onClick={() => onInputChange('summary', 'updated summary')}>
share-update-summary
</button>
</>
)}
{isAttachmentField && (
<>
<button
type="button"
onClick={() => mockContentItemState.staleAttachmentInputChange?.('attachments', [mockContentItemState.uploadingFile])}
>
share-uploading-attachments
</button>
<button
type="button"
onClick={() => mockContentItemState.staleAttachmentInputChange?.('attachments', [mockContentItemState.uploadedFile])}
>
share-update-attachments
</button>
</>
)}
</div>
)
},
}))
vi.mock('@/app/components/base/chat/chat/answer/human-input-content/expiration-time', () => ({
__esModule: true,
default: () => <div>expiration-time</div>,
}))
vi.mock('@/app/components/base/loading', () => ({
__esModule: true,
default: () => <div>loading</div>,
}))
vi.mock('@/app/components/base/logo/dify-logo', () => ({
__esModule: true,
default: () => <div>dify-logo</div>,
}))
vi.mock('@/app/components/base/app-icon', () => ({
__esModule: true,
default: () => <div>app-icon</div>,
}))
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(<FormContent />)
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(<FormContent />)
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(<FormContent />)
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(<FormContent />)
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<string, HumanInputFieldValue>,
'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(<FormContent />)
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(<FormContent />)
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()
})
})

View File

@ -0,0 +1,19 @@
import { render, screen } from '@testing-library/react'
import FormPage from '../page'
vi.mock('../form', () => ({
__esModule: true,
default: () => <div>form-content</div>,
}))
describe('Human input share form page', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render the form content inside the page shell', () => {
render(<FormPage />)
expect(screen.getByText('form-content')).toBeInTheDocument()
})
})

View File

@ -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 (
<div className={cn('flex size-full flex-col items-center justify-center')}>
<div className="max-w-160 min-w-120">
<div className="flex h-80 flex-col gap-4 rounded-[20px] border border-divider-subtle bg-chat-bubble-bg p-10 pb-9 shadow-lg backdrop-blur-xs">
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge p-3">
<span className={cn('size-8', iconClassName)} />
</div>
<div className="grow">
<div className="title-4xl-semi-bold text-text-primary">{title}</div>
{!!subtitle && (
<div className="title-4xl-semi-bold text-text-primary">{subtitle}</div>
)}
</div>
{submissionID && (
<div className="shrink-0 system-2xs-regular-uppercase text-text-tertiary">
{t('humanInput.submissionID', { id: submissionID, ns: 'share' })}
</div>
)}
</div>
<div className="flex flex-row-reverse px-2 py-3">
<div className="flex shrink-0 items-center gap-1.5 px-1">
<div className="system-2xs-medium-uppercase text-text-tertiary">{t('chat.poweredBy', { ns: 'share' })}</div>
<DifyLogo size="small" />
</div>
</div>
</div>
</div>
)
}
export default FormStatusCard

View File

@ -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<string, string>
resolved_default_values: Record<string, HumanInputResolvedValue>
user_actions: UserAction[]
expiration_time: number
}
@ -39,58 +28,13 @@ const FormContent = () => {
const { token } = useParams<{ token: string }>()
useDocumentTitle('')
const [inputs, setInputs] = useState<Record<string, string>>({})
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<string, string> = {}
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 (
<Loading type="app" />
@ -99,190 +43,62 @@ const FormContent = () => {
if (success) {
return (
<div className={cn('flex size-full flex-col items-center justify-center')}>
<div className="max-w-[640px] min-w-[480px]">
<div className="border-components-divider-subtle flex h-[320px] flex-col gap-4 rounded-[20px] border bg-chat-bubble-bg p-10 pb-9 shadow-lg backdrop-blur-xs">
<div className="h-[56px] w-[56px] shrink-0 rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge p-3">
<RiCheckboxCircleFill className="size-8 text-text-success" />
</div>
<div className="grow">
<div className="title-4xl-semi-bold text-text-primary">{t('humanInput.thanks', { ns: 'share' })}</div>
<div className="title-4xl-semi-bold text-text-primary">{t('humanInput.recorded', { ns: 'share' })}</div>
</div>
<div className="shrink-0 system-2xs-regular-uppercase text-text-tertiary">{t('humanInput.submissionID', { id: token, ns: 'share' })}</div>
</div>
<div className="flex flex-row-reverse px-2 py-3">
<div className={cn(
'flex shrink-0 items-center gap-1.5 px-1',
)}
>
<div className="system-2xs-medium-uppercase text-text-tertiary">{t('chat.poweredBy', { ns: 'share' })}</div>
<DifyLogo size="small" />
</div>
</div>
</div>
</div>
<FormStatusCard
iconClassName="i-ri-checkbox-circle-fill text-text-success"
title={t('humanInput.thanks', { ns: 'share' })}
subtitle={t('humanInput.recorded', { ns: 'share' })}
submissionID={token}
/>
)
}
if (expired) {
return (
<div className={cn('flex size-full flex-col items-center justify-center')}>
<div className="max-w-[640px] min-w-[480px]">
<div className="border-components-divider-subtle flex h-[320px] flex-col gap-4 rounded-[20px] border bg-chat-bubble-bg p-10 pb-9 shadow-lg backdrop-blur-xs">
<div className="flex size-14 shrink-0 items-center justify-center rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge p-3">
<RiInformation2Fill className="size-8 text-text-accent" />
</div>
<div className="grow">
<div className="title-4xl-semi-bold text-text-primary">{t('humanInput.sorry', { ns: 'share' })}</div>
<div className="title-4xl-semi-bold text-text-primary">{t('humanInput.expired', { ns: 'share' })}</div>
</div>
<div className="shrink-0 system-2xs-regular-uppercase text-text-tertiary">{t('humanInput.submissionID', { id: token, ns: 'share' })}</div>
</div>
<div className="flex flex-row-reverse px-2 py-3">
<div className={cn(
'flex shrink-0 items-center gap-1.5 px-1',
)}
>
<div className="system-2xs-medium-uppercase text-text-tertiary">{t('chat.poweredBy', { ns: 'share' })}</div>
<DifyLogo size="small" />
</div>
</div>
</div>
</div>
<FormStatusCard
iconClassName="i-ri-information-2-fill text-text-accent"
title={t('humanInput.sorry', { ns: 'share' })}
subtitle={t('humanInput.expired', { ns: 'share' })}
submissionID={token}
/>
)
}
if (submitted) {
return (
<div className={cn('flex size-full flex-col items-center justify-center')}>
<div className="max-w-[640px] min-w-[480px]">
<div className="border-components-divider-subtle flex h-[320px] flex-col gap-4 rounded-[20px] border bg-chat-bubble-bg p-10 pb-9 shadow-lg backdrop-blur-xs">
<div className="flex size-14 shrink-0 items-center justify-center rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge p-3">
<RiInformation2Fill className="size-8 text-text-accent" />
</div>
<div className="grow">
<div className="title-4xl-semi-bold text-text-primary">{t('humanInput.sorry', { ns: 'share' })}</div>
<div className="title-4xl-semi-bold text-text-primary">{t('humanInput.completed', { ns: 'share' })}</div>
</div>
<div className="shrink-0 system-2xs-regular-uppercase text-text-tertiary">{t('humanInput.submissionID', { id: token, ns: 'share' })}</div>
</div>
<div className="flex flex-row-reverse px-2 py-3">
<div className={cn(
'flex shrink-0 items-center gap-1.5 px-1',
)}
>
<div className="system-2xs-medium-uppercase text-text-tertiary">{t('chat.poweredBy', { ns: 'share' })}</div>
<DifyLogo size="small" />
</div>
</div>
</div>
</div>
<FormStatusCard
iconClassName="i-ri-information-2-fill text-text-accent"
title={t('humanInput.sorry', { ns: 'share' })}
subtitle={t('humanInput.completed', { ns: 'share' })}
submissionID={token}
/>
)
}
if (rateLimitExceeded) {
return (
<div className={cn('flex size-full flex-col items-center justify-center')}>
<div className="max-w-[640px] min-w-[480px]">
<div className="border-components-divider-subtle flex h-[320px] flex-col gap-4 rounded-[20px] border bg-chat-bubble-bg p-10 pb-9 shadow-lg backdrop-blur-xs">
<div className="flex size-14 shrink-0 items-center justify-center rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge p-3">
<RiErrorWarningFill className="size-8 text-text-destructive" />
</div>
<div className="grow">
<div className="title-4xl-semi-bold text-text-primary">{t('humanInput.rateLimitExceeded', { ns: 'share' })}</div>
</div>
</div>
<div className="flex flex-row-reverse px-2 py-3">
<div className={cn(
'flex shrink-0 items-center gap-1.5 px-1',
)}
>
<div className="system-2xs-medium-uppercase text-text-tertiary">{t('chat.poweredBy', { ns: 'share' })}</div>
<DifyLogo size="small" />
</div>
</div>
</div>
</div>
<FormStatusCard
iconClassName="i-ri-error-warning-fill text-text-destructive"
title={t('humanInput.rateLimitExceeded', { ns: 'share' })}
/>
)
}
if (!formData) {
return (
<div className={cn('flex size-full flex-col items-center justify-center')}>
<div className="max-w-[640px] min-w-[480px]">
<div className="border-components-divider-subtle flex h-[320px] flex-col gap-4 rounded-[20px] border bg-chat-bubble-bg p-10 pb-9 shadow-lg backdrop-blur-xs">
<div className="flex size-14 shrink-0 items-center justify-center rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge p-3">
<RiErrorWarningFill className="size-8 text-text-destructive" />
</div>
<div className="grow">
<div className="title-4xl-semi-bold text-text-primary">{t('humanInput.formNotFound', { ns: 'share' })}</div>
</div>
</div>
<div className="flex flex-row-reverse px-2 py-3">
<div className={cn(
'flex shrink-0 items-center gap-1.5 px-1',
)}
>
<div className="system-2xs-medium-uppercase text-text-tertiary">{t('chat.poweredBy', { ns: 'share' })}</div>
<DifyLogo size="small" />
</div>
</div>
</div>
</div>
<FormStatusCard
iconClassName="i-ri-error-warning-fill text-text-destructive"
title={t('humanInput.formNotFound', { ns: 'share' })}
/>
)
}
const site = formData.site.site
return (
<div className={cn('mx-auto flex h-full w-full max-w-[720px] flex-col items-center')}>
<div className="mt-4 flex w-full shrink-0 items-center gap-3 py-3">
<AppIcon
size="large"
iconType={site.icon_type}
icon={site.icon}
background={site.icon_background}
imageUrl={site.icon_url}
/>
<div className="grow system-xl-semibold text-text-primary">{site.title}</div>
</div>
<div className="h-0 w-full grow overflow-y-auto">
<div className="border-components-divider-subtle rounded-[20px] border bg-chat-bubble-bg p-4 shadow-lg backdrop-blur-xs">
{contentList.map((content, index) => (
<ContentItem
key={index}
content={content}
formInputFields={formData.inputs}
inputs={inputs}
onInputChange={handleInputsChange}
/>
))}
<div className="flex flex-wrap gap-1 py-1">
{formData.user_actions.map((action: UserAction) => (
<Button
key={action.id}
disabled={isSubmitting}
variant={getButtonStyle(action.button_style) as ButtonProps['variant']}
onClick={() => submit(action.id)}
>
{action.title}
</Button>
))}
</div>
<ExpirationTime expirationTime={formData.expiration_time * 1000} />
</div>
<div className="flex flex-row-reverse px-2 py-3">
<div className={cn(
'flex shrink-0 items-center gap-1.5 px-1',
)}
>
<div className="system-2xs-medium-uppercase text-text-tertiary">{t('chat.poweredBy', { ns: 'share' })}</div>
<DifyLogo size="small" />
</div>
</div>
</div>
</div>
<LoadedFormContent
key={token}
formData={formData}
isSubmitting={isSubmitting}
onSubmit={submit}
/>
)
}

View File

@ -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<string, HumanInputFieldValue>, 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<Record<string, HumanInputFieldValue>>(() =>
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 (
<div className={cn('mx-auto flex size-full max-w-180 flex-col items-center')}>
<div className="mt-4 flex w-full shrink-0 items-center gap-3 py-3">
<AppIcon
size="large"
iconType={site.icon_type}
icon={site.icon}
background={site.icon_background}
imageUrl={site.icon_url}
/>
<div className="grow system-xl-semibold text-text-primary">{site.title}</div>
</div>
<div className="h-0 w-full grow overflow-y-auto">
<div className="rounded-[20px] border border-divider-subtle bg-chat-bubble-bg p-4 shadow-lg backdrop-blur-xs">
{contentList.map((content, index) => (
<ContentItem
key={index}
content={content}
formInputFields={formData.inputs}
inputs={inputs}
onInputChange={handleInputsChange}
/>
))}
<div className="flex flex-wrap gap-1 py-1">
{formData.user_actions.map((action: UserAction) => (
<Button
key={action.id}
disabled={isActionDisabled}
variant={getButtonStyle(action.button_style) as ButtonProps['variant']}
onClick={() => submit(action.id)}
>
{action.title}
</Button>
))}
</div>
<ExpirationTime expirationTime={formData.expiration_time * 1000} />
</div>
<div className="flex flex-row-reverse px-2 py-3">
<div className="flex shrink-0 items-center gap-1.5 px-1">
<div className="system-2xs-medium-uppercase text-text-tertiary">{t('chat.poweredBy', { ns: 'share' })}</div>
<DifyLogo size="small" />
</div>
</div>
</div>
</div>
)
}
export default LoadedFormContent

View File

@ -4,7 +4,7 @@ import FormContent from './form'
const FormPage = () => {
return (
<div className="h-full min-w-[300px] bg-chatbot-bg pb-[env(safe-area-inset-bottom)]">
<div className="h-full min-w-75 bg-chatbot-bg pb-[env(safe-area-inset-bottom)]">
<FormContent />
</div>
)

View File

@ -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<string, HumanInputFieldValue>, actionID: string, formInputs: FormInputItem[]) => {
submitForm(
{
token,
data: {
inputs: getProcessedHumanInputFormInputs(formInputs, inputs) || {},
action: actionID,
},
},
{
onSuccess: () => {
setSuccess(true)
},
},
)
}, [submitForm, token])
return {
isSubmitting,
submit,
success,
}
}

View File

@ -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(
<TypeSelector
@ -23,9 +25,32 @@ describe('TypeSelector', () => {
/>,
)
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(
<TypeSelector
value="text-input"
onSelect={vi.fn()}
items={[
{ value: 'text-input' as any, name: 'Text' },
{ value: 'number' as any, name: 'Number' },
]}
/>,
)
await user.click(screen.getByRole('combobox'))
const [, numberOption] = await screen.findAllByRole('option')
const popup = numberOption!.closest('[data-side]')
expect(popup).toHaveClass('w-(--anchor-width)')
})
})

View File

@ -59,9 +59,10 @@ const TypeSelector: FC<Props> = ({
<div className="flex items-center">
<InputVarTypeIcon type={selectedItem?.value as InputVarType} className="size-4 shrink-0 text-text-secondary" />
<span
className={`
ml-1.5 text-components-input-text-filled ${!selectedItem?.name && 'text-components-input-text-placeholder'}
`}
className={cn(
'ml-1.5 truncate text-components-input-text-filled',
!selectedItem?.name && 'text-components-input-text-placeholder',
)}
>
{selectedItem?.name}
</span>
@ -73,7 +74,7 @@ const TypeSelector: FC<Props> = ({
</SelectTrigger>
<SelectContent
sideOffset={4}
popupClassName={cn('w-[432px] rounded-md px-1 py-1 text-base sm:text-sm', popupInnerClassName)}
popupClassName={cn('w-(--anchor-width) rounded-md px-1 py-1 text-base sm:text-sm', popupInnerClassName)}
listClassName="max-h-80 p-0"
>
{items.map((item: Item) => (

View File

@ -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<IGenerationItemProps> = ({
// eslint-disable-next-line react/set-state-in-effect
setCurrentTab(getDefaultGenerationTab(workflowProcessData))
}, [workflowProcessData])
const handleSubmitHumanInputForm = useCallback(async (formToken: string, formData: { inputs: Record<string, string>, action: string }) => {
const handleSubmitHumanInputForm = useCallback(async (formToken: string, formData: HumanInputFormSubmitData) => {
if (appSourceType === AppSourceType.installedApp)
await submitHumanInputFormService(formToken, formData)
else

View File

@ -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<string, string>, action: string }) => Promise<void>
onSubmitHumanInputForm: (formToken: string, formData: HumanInputFormSubmitData) => Promise<void>
onSwitchTab: (tab: string) => void
showResultTabs: boolean
siteInfo: SiteInfo | null

View File

@ -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')
})
})

View File

@ -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,

View File

@ -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')

View File

@ -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(<HumanInputFilledFormList humanInputFilledFormDataList={data} />)
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', () => {

View File

@ -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 })

View File

@ -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 }) => <div data-testid="mock-markdown">{content}</div>,
}))
vi.mock('../field-renderer', () => ({
__esModule: true,
default: ({
field,
onChange,
}: {
field: FormInputItem
onChange: (value: unknown) => void
}) => (
<button
type="button"
data-testid={`renderer-${field.type}`}
aria-label={field.output_variable_name}
onClick={() => onChange(field.type === 'paragraph' ? 'updated value' : field.type)}
>
{field.type}
</button>
),
}))
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(
<ContentItem
content="{{#$output.user_bio#}}"
formInputFields={[
{
type: 'text-input',
type: 'select',
output_variable_name: 'user_bio',
default: {
option_source: {
type: 'constant',
value: '',
selector: [],
value: [],
},
} as FormInputItem,
]}
@ -105,7 +128,68 @@ describe('ContentItem', () => {
/>,
)
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(
<ContentItem
content="{{#$output.attachment#}}"
formInputFields={[
{
type: 'file',
output_variable_name: 'attachment',
allowed_file_extensions: ['.pdf'],
allowed_file_types: ['document'],
allowed_file_upload_methods: ['local_file'],
} as FormInputItem,
]}
inputs={{ attachment: null }}
onInputChange={mockOnInputChange}
/>,
)
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(
<ContentItem
content="{{#$output.attachments#}}"
formInputFields={[
{
type: 'file-list',
output_variable_name: 'attachments',
allowed_file_extensions: ['.pdf'],
allowed_file_types: ['document'],
allowed_file_upload_methods: ['local_file'],
number_limits: 4,
} as FormInputItem,
]}
inputs={{ attachments: existingFiles }}
onInputChange={mockOnInputChange}
/>,
)
await user.click(screen.getByTestId('renderer-file-list'))
expect(mockOnInputChange).toHaveBeenCalledWith('attachments', 'file-list')
})
})

View File

@ -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<HTMLTextAreaElement>) {
return (
<textarea
data-testid="content-item-textarea"
value={value}
onChange={(event) => {
onChange?.({ target: { value: event.target.value } })
onValueChange?.(event.target.value)
}}
{...props}
/>
)
}
vi.mock('@langgenius/dify-ui/textarea', () => ({
Textarea: MockTextarea,
}))
vi.mock('@langgenius/dify-ui/select', () => ({
Select: ({ children, onValueChange }: { children: React.ReactNode, onValueChange: (value: string | null) => void }) => (
<div>
<button type="button" data-testid="content-item-select-root" onClick={() => onValueChange('alice')}>select alice</button>
<button type="button" data-testid="content-item-select-null" onClick={() => onValueChange(null)}>select null</button>
{children}
</div>
),
SelectTrigger: ({ children }: { children: React.ReactNode }) => <button type="button" data-testid="content-item-select">{children}</button>,
SelectContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SelectItem: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SelectItemText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
SelectItemIndicator: () => <span>selected</span>,
}))
vi.mock('@/app/components/base/file-uploader', () => ({
FileUploaderInAttachmentWrapper: ({ value, onChange, fileConfig }: {
value?: FileEntity[]
onChange: (files: FileEntity[]) => void
fileConfig: { number_limits?: number }
}) => (
<>
<button
type="button"
data-testid={`content-item-file-${fileConfig.number_limits ?? 0}`}
onClick={() => onChange([{ id: 'file-1', name: 'report.pdf', size: 1, type: 'document', progress: 100, transferMethod: TransferMethod.local_file, supportFileType: 'document' }])}
>
{(value || []).map(file => file.name).join(',')}
</button>
<button
type="button"
data-testid={`content-item-file-clear-${fileConfig.number_limits ?? 0}`}
onClick={() => onChange([])}
>
clear
</button>
</>
),
}))
describe('HumanInputFieldRenderer', () => {
it('renders paragraph input and emits string changes', async () => {
const onChange = vi.fn()
render(
<HumanInputFieldRenderer
field={{
type: InputVarType.paragraph,
output_variable_name: 'summary',
default: { type: 'constant', selector: [], value: '' },
}}
value="hello"
onChange={onChange}
/>,
)
fireEvent.change(screen.getByTestId('content-item-textarea'), {
target: { value: 'hello world' },
})
expect(onChange).toHaveBeenLastCalledWith('hello world')
})
it('renders paragraph input with an accessible name', () => {
render(
<HumanInputFieldRenderer
field={{
type: InputVarType.paragraph,
output_variable_name: 'summary',
default: { type: 'constant', selector: [], value: '' },
}}
value="hello"
onChange={vi.fn()}
/>,
)
expect(screen.getByLabelText('summary')).toHaveValue('hello')
})
it('renders paragraph input with an empty value when the current value is not a string', () => {
render(
<HumanInputFieldRenderer
field={{
type: InputVarType.paragraph,
output_variable_name: 'summary',
default: { type: 'constant', selector: [], value: '' },
}}
value={null}
onChange={vi.fn()}
/>,
)
expect(screen.getByTestId('content-item-textarea')).toHaveValue('')
})
it('renders select input and emits selected values', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<HumanInputFieldRenderer
field={{
type: InputVarType.select,
output_variable_name: 'reviewer',
option_source: { type: 'constant', selector: [], value: ['alice', 'bob'] },
}}
value=""
onChange={onChange}
/>,
)
await user.click(screen.getByTestId('content-item-select-root'))
expect(onChange).toHaveBeenCalledWith('alice')
})
it('ignores null select values', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<HumanInputFieldRenderer
field={{
type: InputVarType.select,
output_variable_name: 'reviewer',
option_source: { type: 'constant', selector: [], value: ['alice', 'bob'] },
}}
value={null}
onChange={onChange}
/>,
)
expect(screen.getByTestId('content-item-select')).toHaveTextContent('')
await user.click(screen.getByTestId('content-item-select-null'))
expect(onChange).not.toHaveBeenCalled()
})
it('renders single-file input and emits one file', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<HumanInputFieldRenderer
field={{
type: InputVarType.singleFile,
output_variable_name: 'attachment',
allowed_file_extensions: ['.pdf'],
allowed_file_types: [SupportUploadFileTypes.document],
allowed_file_upload_methods: [TransferMethod.local_file],
}}
value={null}
onChange={onChange}
/>,
)
await user.click(screen.getByTestId('content-item-file-1'))
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ name: 'report.pdf' }))
})
it('renders existing single-file values and emits null when cleared', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<HumanInputFieldRenderer
field={{
type: InputVarType.singleFile,
output_variable_name: 'attachment',
allowed_file_extensions: ['.pdf'],
allowed_file_types: [SupportUploadFileTypes.document],
allowed_file_upload_methods: [TransferMethod.local_file],
}}
value={{ id: 'file-2', name: 'existing.pdf', size: 1, type: 'document', progress: 100, transferMethod: TransferMethod.local_file, supportFileType: 'document' }}
onChange={onChange}
/>,
)
expect(screen.getByTestId('content-item-file-1')).toHaveTextContent('existing.pdf')
await user.click(screen.getByTestId('content-item-file-clear-1'))
expect(onChange).toHaveBeenCalledWith(null)
})
it('renders file-list input and emits file arrays with max count', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<HumanInputFieldRenderer
field={{
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,
}}
value={[]}
onChange={onChange}
/>,
)
await user.click(screen.getByTestId('content-item-file-3'))
expect(onChange).toHaveBeenCalledWith([expect.objectContaining({ name: 'report.pdf' })])
})
it('uses the default max count for file-list inputs without an explicit limit', () => {
render(
<HumanInputFieldRenderer
field={{
type: InputVarType.multiFiles,
output_variable_name: 'attachments',
allowed_file_extensions: ['.pdf'],
allowed_file_types: [SupportUploadFileTypes.document],
allowed_file_upload_methods: [TransferMethod.local_file],
}}
value={null}
onChange={vi.fn()}
/>,
)
expect(screen.getByTestId('content-item-file-5')).toBeInTheDocument()
})
it('renders nothing for unsupported input types', () => {
const { container } = render(
<HumanInputFieldRenderer
field={{
type: 'unsupported',
output_variable_name: 'unknown',
} as unknown as Parameters<typeof HumanInputFieldRenderer>[0]['field']}
value=""
onChange={vi.fn()}
/>,
)
expect(container).toBeEmptyDOMElement()
})
})

View File

@ -4,13 +4,74 @@ import { act, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { UserActionButtonType } from '@/app/components/workflow/nodes/human-input/types'
import { InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types'
import { TransferMethod } from '@/types/app'
import HumanInputForm from '../human-input-form'
vi.mock('../content-item', () => ({
default: ({ content, onInputChange }: { content: string, onInputChange: (name: string, value: string) => void }) => (
default: ({ content, onInputChange }: { content: string, onInputChange: (name: string, value: unknown) => void }) => (
<div data-testid="mock-content-item">
{content}
<button data-testid="update-input" onClick={() => onInputChange('field1', 'new value')}>Update</button>
<button data-testid="update-select" onClick={() => onInputChange('field2', 'approved')}>Update Select</button>
<button
data-testid="update-single-file"
onClick={() => onInputChange('field4', {
id: 'file-2',
name: 'main.png',
size: 256,
type: 'image/png',
progress: 100,
transferMethod: TransferMethod.local_file,
supportFileType: 'image',
uploadedId: 'upload-file-2',
})}
>
Update Single File
</button>
<button
data-testid="update-pending-single-file"
onClick={() => onInputChange('field4', {
id: 'file-2',
name: 'main.png',
size: 256,
type: 'image/png',
progress: 50,
transferMethod: TransferMethod.local_file,
supportFileType: 'image',
})}
>
Update Pending Single File
</button>
<button
data-testid="update-input-file"
onClick={() => onInputChange('field3', [{
id: 'file-1',
name: 'avatar.png',
size: 128,
type: 'image/png',
progress: 100,
transferMethod: TransferMethod.local_file,
supportFileType: 'image',
uploadedId: 'upload-file-1',
}])}
>
Update File
</button>
<button
data-testid="update-pending-input-file"
onClick={() => onInputChange('field3', [{
id: 'file-1',
name: 'avatar.png',
size: 128,
type: 'image/png',
progress: 50,
transferMethod: TransferMethod.local_file,
supportFileType: 'image',
}])}
>
Update Pending File
</button>
</div>
),
}))
@ -50,12 +111,10 @@ describe('HumanInputForm', () => {
expect(contentItems[1])!.toHaveTextContent('{{#$output.field1#}}')
expect(contentItems[2])!.toHaveTextContent('Part 2')
const buttons = screen.getAllByRole('button').filter(button => button.textContent !== 'Update')
expect(buttons).toHaveLength(4)
expect(buttons[0])!.toHaveTextContent('Submit')
expect(buttons[1])!.toHaveTextContent('Cancel')
expect(buttons[2])!.toHaveTextContent('Accent')
expect(buttons[3])!.toHaveTextContent('Ghost')
expect(screen.getByRole('button', { name: 'Submit' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Accent' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Ghost' })).toBeInTheDocument()
})
it('should handle input changes and submit correctly', async () => {
@ -76,6 +135,175 @@ describe('HumanInputForm', () => {
})
})
it('should submit file field values using the backend payload shape', async () => {
const user = userEvent.setup()
const mockOnSubmit = vi.fn().mockResolvedValue(undefined)
const formDataWithFileList: HumanInputFormData = {
...mockFormData,
form_content: '{{#$output.field1#}} {{#$output.field3#}}',
inputs: [
{
type: InputVarType.paragraph,
output_variable_name: 'field1',
default: { type: 'constant', value: 'initial', selector: [] },
},
{
type: InputVarType.multiFiles,
output_variable_name: 'field3',
allowed_file_extensions: ['.png'],
allowed_file_types: [SupportUploadFileTypes.image],
allowed_file_upload_methods: [TransferMethod.local_file],
number_limits: 5,
},
] as FormInputItem[],
}
render(<HumanInputForm formData={formDataWithFileList} onSubmit={mockOnSubmit} />)
await user.click(screen.getAllByTestId('update-input')[0]!)
await user.click(screen.getAllByTestId('update-input-file')[0]!)
await user.click(screen.getByRole('button', { name: 'Submit' }))
expect(mockOnSubmit).toHaveBeenCalledWith('token_123', {
action: 'action_1',
inputs: {
field1: 'new value',
field3: [{
type: 'image',
transfer_method: TransferMethod.local_file,
url: '',
upload_file_id: 'upload-file-1',
}],
},
})
})
it('should disable buttons until select, file, and file list inputs have uploaded values', async () => {
const user = userEvent.setup()
const mockOnSubmit = vi.fn().mockResolvedValue(undefined)
const formDataWithRequiredInteractiveFields: HumanInputFormData = {
...mockFormData,
form_content: '{{#$output.field2#}} {{#$output.field3#}} {{#$output.field4#}}',
inputs: [
{
type: InputVarType.select,
output_variable_name: 'field2',
option_source: {
type: 'constant',
value: ['approved'],
selector: [],
},
},
{
type: InputVarType.multiFiles,
output_variable_name: 'field3',
allowed_file_extensions: ['.png'],
allowed_file_types: [SupportUploadFileTypes.image],
allowed_file_upload_methods: [TransferMethod.local_file],
number_limits: 5,
},
{
type: InputVarType.singleFile,
output_variable_name: 'field4',
allowed_file_extensions: ['.png'],
allowed_file_types: [SupportUploadFileTypes.image],
allowed_file_upload_methods: [TransferMethod.local_file],
},
] as FormInputItem[],
}
render(<HumanInputForm formData={formDataWithRequiredInteractiveFields} onSubmit={mockOnSubmit} />)
const submitButton = screen.getByRole('button', { name: 'Submit' })
expect(submitButton).toBeDisabled()
await user.click(screen.getAllByTestId('update-select')[0]!)
await user.click(screen.getAllByTestId('update-pending-single-file')[0]!)
await user.click(screen.getAllByTestId('update-input-file')[0]!)
expect(submitButton).toBeDisabled()
await user.click(screen.getAllByTestId('update-single-file')[0]!)
await user.click(screen.getAllByTestId('update-pending-input-file')[0]!)
expect(submitButton).toBeDisabled()
await user.click(screen.getAllByTestId('update-input-file')[0]!)
expect(submitButton).toBeEnabled()
await user.click(submitButton)
expect(mockOnSubmit).toHaveBeenCalledWith('token_123', {
action: 'action_1',
inputs: {
field2: 'approved',
field3: [{
type: 'image',
transfer_method: TransferMethod.local_file,
url: '',
upload_file_id: 'upload-file-1',
}],
field4: {
type: 'image',
transfer_method: TransferMethod.local_file,
url: '',
upload_file_id: 'upload-file-2',
},
},
})
})
it('should ignore input fields that are not rendered in form content when checking actions', async () => {
const user = userEvent.setup()
const mockOnSubmit = vi.fn().mockResolvedValue(undefined)
const formDataWithStaleInput: HumanInputFormData = {
...mockFormData,
form_content: '{{#$output.field2#}}',
inputs: [
{
type: InputVarType.select,
output_variable_name: 'field2',
option_source: {
type: 'constant',
value: ['approved'],
selector: [],
},
},
{
type: InputVarType.select,
output_variable_name: 'stale_select',
option_source: {
type: 'constant',
value: ['unused'],
selector: [],
},
},
{
type: InputVarType.singleFile,
output_variable_name: 'stale_file',
allowed_file_extensions: ['.png'],
allowed_file_types: [SupportUploadFileTypes.image],
allowed_file_upload_methods: [TransferMethod.local_file],
},
] as FormInputItem[],
}
render(<HumanInputForm formData={formDataWithStaleInput} onSubmit={mockOnSubmit} />)
const submitButton = screen.getByRole('button', { name: 'Submit' })
expect(submitButton).toBeDisabled()
await user.click(screen.getByTestId('update-select'))
expect(submitButton).toBeEnabled()
await user.click(submitButton)
expect(mockOnSubmit).toHaveBeenCalledWith('token_123', {
action: 'action_1',
inputs: {
field2: 'approved',
},
})
})
it('should disable buttons during submission', async () => {
const user = userEvent.setup()
let resolveSubmit: (value: void | PromiseLike<void>) => void
@ -109,19 +337,21 @@ describe('HumanInputForm', () => {
expect(screen.getAllByTestId('mock-content-item')).toHaveLength(3)
})
it('should handle unsupported input types in initializeInputs', () => {
it('should handle mixed supported input types in initializeInputs', () => {
const formDataWithUnsupported = {
...mockFormData,
inputs: [
{
type: 'text-input',
type: InputVarType.select,
output_variable_name: 'field2',
default: { type: 'variable', value: '', selector: [] },
option_source: { type: 'variable', value: [], selector: [] },
} as FormInputItem,
{
type: 'number',
type: InputVarType.singleFile,
output_variable_name: 'field3',
default: { type: 'constant', value: '0', selector: [] },
allowed_file_extensions: [],
allowed_file_types: [],
allowed_file_upload_methods: [],
} as FormInputItem,
],
resolved_default_values: { field2: 'default value' },

View File

@ -0,0 +1,181 @@
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
import type { FileResponse } from '@/types/workflow'
import { render, screen } from '@testing-library/react'
import { InputVarType } from '@/app/components/workflow/types'
import { TransferMethod } from '@/types/app'
import SubmittedContentItem from '../submitted-content-item'
const attachmentValue: FileResponse = {
related_id: 'file-1',
upload_file_id: 'upload-1',
filename: 'decision.pdf',
extension: 'pdf',
size: 128,
mime_type: 'application/pdf',
transfer_method: TransferMethod.local_file,
type: 'document',
url: 'https://example.com/decision.pdf',
remote_url: '',
}
const evidenceValues: FileResponse[] = [
{
related_id: 'file-2',
upload_file_id: 'upload-2',
filename: 'evidence.png',
extension: 'png',
size: 256,
mime_type: 'image/png',
transfer_method: TransferMethod.remote_url,
type: 'image',
url: 'https://example.com/evidence.png',
remote_url: 'https://example.com/evidence.png',
},
{
related_id: 'file-3',
upload_file_id: 'upload-3',
filename: 'evidence.pdf',
extension: 'pdf',
size: 512,
mime_type: 'application/pdf',
transfer_method: TransferMethod.local_file,
type: 'document',
url: 'https://example.com/evidence.pdf',
remote_url: '',
},
]
const fields: FormInputItem[] = [
{
type: InputVarType.paragraph,
output_variable_name: 'summary',
default: { type: 'constant', value: '', selector: [] },
},
{
type: InputVarType.select,
output_variable_name: 'decision',
option_source: { type: 'constant', value: ['approve', 'reject'], selector: [] },
},
{
type: InputVarType.singleFile,
output_variable_name: 'attachment',
allowed_file_extensions: [],
allowed_file_types: [],
allowed_file_upload_methods: [],
},
{
type: InputVarType.multiFiles,
output_variable_name: 'evidence',
allowed_file_extensions: [],
allowed_file_types: [],
allowed_file_upload_methods: [],
number_limits: 5,
},
]
describe('SubmittedContentItem', () => {
it('renders file-list output placeholders as readonly file lists', () => {
render(
<SubmittedContentItem
content="{{#$output.evidence#}}"
formInputFields={fields}
values={{ evidence: evidenceValues }}
/>,
)
expect(screen.getByTestId('submitted-field-evidence')).toBeInTheDocument()
expect(screen.getByRole('img', { name: 'Preview' })).toHaveAttribute('src', 'https://example.com/evidence.png')
expect(screen.getByText('evidence.pdf')).toBeInTheDocument()
})
it('renders empty text for paragraph and select placeholders with non-string values', () => {
const { rerender } = render(
<SubmittedContentItem
content="{{#$output.summary#}}"
formInputFields={fields}
values={{ summary: attachmentValue }}
/>,
)
expect(screen.getByTestId('submitted-field-summary')).toHaveTextContent('')
rerender(
<SubmittedContentItem
content="{{#$output.decision#}}"
formInputFields={fields}
values={{ decision: evidenceValues }}
/>,
)
expect(screen.getByRole('combobox', { name: 'decision' })).toHaveTextContent('')
})
it('renders nothing when output placeholders do not have a usable submitted value', () => {
const { container, rerender } = render(
<SubmittedContentItem
content="{{#$output.missing#}}"
formInputFields={fields}
values={{ missing: 'value' }}
/>,
)
expect(container).toBeEmptyDOMElement()
rerender(
<SubmittedContentItem
content="{{#$output.attachment#}}"
formInputFields={fields}
values={{}}
/>,
)
expect(container).toBeEmptyDOMElement()
})
it('skips file placeholders when submitted values have incompatible shapes', () => {
const { container, rerender } = render(
<SubmittedContentItem
content="{{#$output.attachment#}}"
formInputFields={fields}
values={{ attachment: 'not-a-file' }}
/>,
)
expect(container).toBeEmptyDOMElement()
rerender(
<SubmittedContentItem
content="{{#$output.attachment#}}"
formInputFields={fields}
values={{ attachment: evidenceValues }}
/>,
)
expect(container).toBeEmptyDOMElement()
rerender(
<SubmittedContentItem
content="{{#$output.evidence#}}"
formInputFields={fields}
values={{ evidence: attachmentValue }}
/>,
)
expect(container).toBeEmptyDOMElement()
})
it('renders nothing for unsupported output field types', () => {
const { container } = render(
<SubmittedContentItem
content="{{#$output.unknown#}}"
formInputFields={[{
type: 'unsupported',
output_variable_name: 'unknown',
} as unknown as FormInputItem]}
values={{ unknown: 'value' }}
/>,
)
expect(container).toBeEmptyDOMElement()
})
})

View File

@ -0,0 +1,140 @@
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
import type { FileResponse, HumanInputFormValue } from '@/types/workflow'
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { InputVarType } from '@/app/components/workflow/types'
import { TransferMethod } from '@/types/app'
import SubmittedFieldValues from '../submitted-field-values'
const fields: FormInputItem[] = [
{
type: InputVarType.paragraph,
output_variable_name: 'summary',
default: { type: 'constant', value: '', selector: [] },
},
{
type: InputVarType.select,
output_variable_name: 'decision',
option_source: { type: 'constant', value: ['approve', 'reject'], selector: [] },
},
{
type: InputVarType.singleFile,
output_variable_name: 'attachment',
allowed_file_extensions: [],
allowed_file_types: [],
allowed_file_upload_methods: [],
},
{
type: InputVarType.multiFiles,
output_variable_name: 'evidence',
allowed_file_extensions: [],
allowed_file_types: [],
allowed_file_upload_methods: [],
number_limits: 5,
},
]
const attachmentValue: FileResponse = {
related_id: 'file-1',
upload_file_id: 'upload-1',
filename: 'decision.pdf',
extension: 'pdf',
size: 128,
mime_type: 'application/pdf',
transfer_method: TransferMethod.local_file,
type: 'document',
url: 'https://example.com/decision.pdf',
remote_url: '',
}
const evidenceValues: FileResponse[] = [
{
related_id: 'file-2',
upload_file_id: 'upload-2',
filename: 'evidence-1.png',
extension: 'png',
size: 256,
mime_type: 'image/png',
transfer_method: TransferMethod.remote_url,
type: 'image',
url: 'https://example.com/evidence-1.png',
remote_url: 'https://example.com/evidence-1.png',
},
{
related_id: 'file-3',
upload_file_id: 'upload-3',
filename: 'evidence-2.pdf',
extension: 'pdf',
size: 512,
mime_type: 'application/pdf',
transfer_method: TransferMethod.local_file,
type: 'document',
url: 'https://example.com/evidence-2.pdf',
remote_url: '',
},
]
const values: Record<string, HumanInputFormValue> = {
summary: 'Need more context',
decision: 'approve',
attachment: attachmentValue,
evidence: evidenceValues,
}
describe('SubmittedFieldValues', () => {
it('renders text and select values as text', () => {
render(<SubmittedFieldValues fields={fields} values={values} />)
expect(screen.getByTestId('submitted-field-summary')).toHaveTextContent('Need more context')
expect(screen.getByTestId('submitted-field-decision')).toHaveTextContent('approve')
})
it('renders file and file-list values as file lists', () => {
render(<SubmittedFieldValues fields={fields} values={values} />)
expect(screen.getByTestId('submitted-field-attachment')).toHaveTextContent('decision.pdf')
expect(screen.getByRole('img', { name: 'Preview' })).toHaveAttribute('src', 'https://example.com/evidence-1.png')
expect(screen.getByText('evidence-2.pdf')).toBeInTheDocument()
expect(screen.getAllByTestId('file-list')).toHaveLength(2)
})
it('skips fields with missing values', () => {
render(<SubmittedFieldValues fields={fields} values={{ summary: 'Only one field' }} />)
expect(screen.getByTestId('submitted-field-summary')).toHaveTextContent('Only one field')
expect(screen.queryByTestId('submitted-field-decision')).not.toBeInTheDocument()
expect(screen.queryByTestId('submitted-field-attachment')).not.toBeInTheDocument()
})
it('infers value types when field definitions are unavailable', () => {
render(
<SubmittedFieldValues
values={{
summary: 'Unstructured summary',
attachment: attachmentValue,
evidence: evidenceValues,
}}
/>,
)
expect(screen.getByTestId('submitted-field-summary')).toHaveTextContent('Unstructured summary')
expect(screen.getByTestId('submitted-field-attachment')).toHaveTextContent('decision.pdf')
expect(screen.getByRole('img', { name: 'Preview' })).toHaveAttribute('src', 'https://example.com/evidence-1.png')
expect(screen.getByText('evidence-2.pdf')).toBeInTheDocument()
})
it('skips file fields when submitted values have incompatible shapes', () => {
render(
<SubmittedFieldValues
fields={fields}
values={{
attachment: 'not-a-file',
evidence: attachmentValue,
}}
/>,
)
expect(screen.queryByTestId('submitted-field-attachment')).not.toBeInTheDocument()
expect(screen.queryByTestId('submitted-field-evidence')).not.toBeInTheDocument()
})
})

View File

@ -1,6 +1,8 @@
import type { HumanInputFilledFormData } from '@/types/workflow'
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { InputVarType } from '@/app/components/workflow/types'
import { TransferMethod } from '@/types/app'
import { SubmittedHumanInputContent } from '../submitted'
vi.mock('@/app/components/base/markdown', () => ({
@ -28,4 +30,99 @@ describe('SubmittedHumanInputContent Integration', () => {
// Trans component for triggered action. The mock usually renders the key.
expect(screen.getByText('nodes.humanInput.userActions.triggered')).toBeInTheDocument()
})
it('should prefer structured form data over rendered markdown when available', () => {
render(
<SubmittedHumanInputContent formData={{
...mockFormData,
form_content: 'Decision: {{#$output.answer#}}',
inputs: [{
type: InputVarType.paragraph,
output_variable_name: 'answer',
default: { type: 'constant', value: '', selector: [] },
}],
submitted_data: {
answer: 'approved',
},
}}
/>,
)
expect(screen.getByTestId('submitted-form-content')).toBeInTheDocument()
expect(screen.getByTestId('submitted-field-answer')).toHaveTextContent('approved')
expect(screen.queryByTestId('submitted-content')).not.toBeInTheDocument()
})
it('should render submitted select and file fields with the original form layout', () => {
render(
<SubmittedHumanInputContent formData={{
...mockFormData,
form_content: '{{#$output.decision#}} {{#$output.attachment#}}',
inputs: [
{
type: InputVarType.select,
output_variable_name: 'decision',
option_source: { type: 'constant', value: ['approve', 'reject'], selector: [] },
},
{
type: InputVarType.singleFile,
output_variable_name: 'attachment',
allowed_file_extensions: [],
allowed_file_types: [],
allowed_file_upload_methods: [],
},
],
submitted_data: {
decision: 'approve',
attachment: {
related_id: 'file-1',
upload_file_id: 'upload-1',
filename: 'decision.pdf',
extension: 'pdf',
size: 128,
mime_type: 'application/pdf',
transfer_method: TransferMethod.local_file,
type: 'document',
url: 'https://example.com/decision.pdf',
remote_url: '',
},
},
}}
/>,
)
expect(screen.getByRole('combobox', { name: 'decision' })).toBeDisabled()
expect(screen.getByRole('combobox', { name: 'decision' })).toHaveTextContent('approve')
expect(screen.getByTestId('submitted-field-attachment')).toHaveTextContent('decision.pdf')
})
it('should fallback to rendered markdown when structured form data is empty', () => {
render(
<SubmittedHumanInputContent formData={{
...mockFormData,
submitted_data: {},
}}
/>,
)
expect(screen.getByTestId('submitted-content')).toBeInTheDocument()
expect(screen.getByTestId('mock-markdown')).toHaveTextContent('Rendered **Markdown** content')
expect(screen.queryByTestId('submitted-field-values')).not.toBeInTheDocument()
})
it('should render submitted field values when original form layout is unavailable', () => {
render(
<SubmittedHumanInputContent formData={{
...mockFormData,
submitted_data: {
answer: 'approved',
},
}}
/>,
)
expect(screen.getByTestId('submitted-field-values')).toBeInTheDocument()
expect(screen.getByTestId('submitted-field-answer')).toHaveTextContent('approved')
expect(screen.queryByTestId('submitted-content')).not.toBeInTheDocument()
})
})

View File

@ -1,29 +1,78 @@
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
import type { Locale } from '@/i18n-config/language'
import { UserActionButtonType } from '@/app/components/workflow/nodes/human-input/types'
import { InputVarType } from '@/app/components/workflow/types'
import { InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types'
import { TransferMethod } from '@/types/app'
import {
getButtonStyle,
getFormContentInputNames,
getProcessedHumanInputFormInputs,
getRelativeTime,
getRenderedFormInputs,
hasInvalidRequiredHumanInput,
hasInvalidSelectOrFileInput,
initializeInputs,
isRelativeTimeSameOrAfter,
splitByOutputVar,
} from '../utils'
const createInput = (overrides: Partial<FormInputItem>): FormInputItem => ({
label: 'field',
variable: 'field',
required: false,
max_length: 128,
type: InputVarType.textInput,
default: {
type: 'constant' as const,
value: '',
selector: [], // Dummy selector
},
const paragraphInput = (overrides: Partial<Extract<FormInputItem, { type: InputVarType.paragraph }>> = {}): FormInputItem => ({
type: InputVarType.paragraph,
output_variable_name: 'field',
default: {
type: 'constant',
value: '',
selector: [],
},
...overrides,
} as unknown as FormInputItem)
})
const selectInput = (overrides: Partial<Extract<FormInputItem, { type: InputVarType.select }>> = {}): FormInputItem => ({
type: InputVarType.select,
output_variable_name: 'field',
option_source: {
type: 'constant',
value: ['option-a', 'option-b'],
selector: [],
},
...overrides,
})
const fileInput = (overrides: Partial<Extract<FormInputItem, { type: InputVarType.singleFile }>> = {}): FormInputItem => ({
type: InputVarType.singleFile,
output_variable_name: 'field',
allowed_file_extensions: [],
allowed_file_types: [SupportUploadFileTypes.image],
allowed_file_upload_methods: [TransferMethod.local_file],
...overrides,
})
const fileListInput = (overrides: Partial<Extract<FormInputItem, { type: InputVarType.multiFiles }>> = {}): FormInputItem => ({
type: InputVarType.multiFiles,
output_variable_name: 'field',
allowed_file_extensions: [],
allowed_file_types: [SupportUploadFileTypes.image],
allowed_file_upload_methods: [TransferMethod.local_file],
number_limits: 5,
...overrides,
})
const uploadedFile = {
id: 'file-1',
name: 'avatar.png',
size: 128,
type: 'image',
progress: 100,
transferMethod: TransferMethod.local_file,
supportFileType: 'image',
uploadedId: 'upload-file-1',
}
const uploadingFile = {
...uploadedFile,
uploadedId: undefined,
progress: 50,
}
describe('human-input utils', () => {
describe('getButtonStyle', () => {
@ -53,16 +102,31 @@ describe('human-input utils', () => {
})
})
describe('form content inputs', () => {
it('should extract and filter input fields rendered in form content', () => {
const formInputs: FormInputItem[] = [
selectInput({ output_variable_name: 'visible_select' }),
paragraphInput({ output_variable_name: 'visible_paragraph' }),
fileInput({ output_variable_name: 'stale_file' }),
]
const content = 'Select {{#$output.visible_select#}} and write {{#$output.visible_paragraph#}}'
expect(getFormContentInputNames(content)).toEqual(['visible_select', 'visible_paragraph'])
expect(getRenderedFormInputs(formInputs, content).map(input => input.output_variable_name)).toEqual([
'visible_select',
'visible_paragraph',
])
})
})
describe('initializeInputs', () => {
it('should initialize text fields with constants and variable defaults', () => {
const formInputs = [
createInput({
type: InputVarType.textInput,
it('should initialize paragraph fields with constants and variable defaults', () => {
const formInputs: FormInputItem[] = [
paragraphInput({
output_variable_name: 'name',
default: { type: 'constant', value: 'John', selector: [] },
}),
createInput({
type: InputVarType.paragraph,
paragraphInput({
output_variable_name: 'bio',
default: { type: 'variable', value: '', selector: [] },
}),
@ -74,23 +138,45 @@ describe('human-input utils', () => {
})
})
it('should set non text-like inputs to undefined', () => {
const formInputs = [
createInput({
type: InputVarType.select,
it('should initialize select fields with empty strings', () => {
const formInputs: FormInputItem[] = [
selectInput({
output_variable_name: 'role',
}),
]
expect(initializeInputs(formInputs)).toEqual({
role: undefined,
role: '',
})
})
it('should initialize single file fields with null', () => {
const formInputs: FormInputItem[] = [
fileInput({
output_variable_name: 'avatar',
}),
]
expect(initializeInputs(formInputs)).toEqual({
avatar: null,
})
})
it('should initialize file list fields with empty arrays', () => {
const formInputs: FormInputItem[] = [
fileListInput({
output_variable_name: 'attachments',
}),
]
expect(initializeInputs(formInputs)).toEqual({
attachments: [],
})
})
it('should fallback to empty string when variable default is missing', () => {
const formInputs = [
createInput({
type: InputVarType.textInput,
const formInputs: FormInputItem[] = [
paragraphInput({
output_variable_name: 'summary',
default: { type: 'variable', value: '', selector: [] },
}),
@ -100,6 +186,200 @@ describe('human-input utils', () => {
summary: '',
})
})
it('should fallback to default paragraph values when resolved defaults are not strings', () => {
const formInputs: FormInputItem[] = [
paragraphInput({
output_variable_name: 'summary',
default: { type: 'variable', value: '', selector: [] },
}),
]
expect(initializeInputs(formInputs, {
summary: {
related_id: 'file-1',
size: 128,
extension: '.pdf',
filename: 'brief.pdf',
mime_type: 'application/pdf',
transfer_method: TransferMethod.local_file,
type: 'document',
url: 'https://example.com/brief.pdf',
upload_file_id: 'upload-file-1',
remote_url: '',
},
})).toEqual({
summary: '',
})
})
it('should ignore resolved defaults for non-paragraph fields', () => {
const formInputs: FormInputItem[] = [
selectInput({
output_variable_name: 'role',
}),
fileInput({
output_variable_name: 'avatar',
}),
fileListInput({
output_variable_name: 'attachments',
}),
]
expect(initializeInputs(formInputs, {
role: 'maintainer',
avatar: {
related_id: 'file-2',
size: 64,
extension: '.png',
filename: 'avatar.png',
mime_type: 'image/png',
transfer_method: TransferMethod.local_file,
type: 'image',
url: 'https://example.com/avatar.png',
upload_file_id: 'upload-file-2',
remote_url: '',
},
attachments: [{
related_id: 'file-3',
size: 16,
extension: '.txt',
filename: 'notes.txt',
mime_type: 'text/plain',
transfer_method: TransferMethod.local_file,
type: 'document',
url: 'https://example.com/notes.txt',
upload_file_id: 'upload-file-3',
remote_url: '',
}],
})).toEqual({
role: '',
avatar: null,
attachments: [],
})
})
it('should ignore unsupported input types', () => {
expect(initializeInputs([
{
type: 'unsupported',
output_variable_name: 'unknown',
} as unknown as FormInputItem,
])).toEqual({})
})
})
describe('required input checks', () => {
it('should ignore fields that are not present in values', () => {
const formInputs: FormInputItem[] = [
selectInput({ output_variable_name: 'visible_select' }),
fileInput({ output_variable_name: 'stale_file' }),
paragraphInput({ output_variable_name: 'stale_paragraph' }),
]
const values = {
visible_select: 'approved',
}
expect(hasInvalidSelectOrFileInput(formInputs, values)).toBe(false)
expect(hasInvalidRequiredHumanInput(formInputs, values)).toBe(false)
})
it('should detect empty select values and unuploaded file values', () => {
expect(hasInvalidSelectOrFileInput([
selectInput({ output_variable_name: 'decision' }),
], {
decision: '',
})).toBe(true)
expect(hasInvalidRequiredHumanInput([
paragraphInput({ output_variable_name: 'summary' }),
], {
summary: ' ',
})).toBe(true)
expect(hasInvalidSelectOrFileInput([
fileInput({ output_variable_name: 'attachment' }),
], {
attachment: uploadingFile,
})).toBe(true)
expect(hasInvalidRequiredHumanInput([
fileListInput({ output_variable_name: 'attachments' }),
], {
attachments: [uploadedFile, uploadingFile],
})).toBe(true)
})
it('should accept uploaded single and multiple file values', () => {
expect(hasInvalidSelectOrFileInput([
fileInput({ output_variable_name: 'attachment' }),
fileListInput({ output_variable_name: 'attachments' }),
], {
attachment: [uploadedFile],
attachments: [uploadedFile],
})).toBe(false)
expect(hasInvalidRequiredHumanInput([
fileInput({ output_variable_name: 'attachment' }),
fileListInput({ output_variable_name: 'attachments' }),
], {
attachment: [uploadedFile],
attachments: [uploadedFile],
})).toBe(false)
})
it('should treat unsupported input types as valid by default', () => {
const unsupportedInput = {
type: 'unsupported',
output_variable_name: 'unknown',
} as unknown as FormInputItem
expect(hasInvalidSelectOrFileInput([unsupportedInput], {
unknown: 'value',
})).toBe(false)
expect(hasInvalidRequiredHumanInput([unsupportedInput], {
unknown: 'value',
})).toBe(false)
})
})
describe('getProcessedHumanInputFormInputs', () => {
it('should return undefined when no values are provided', () => {
expect(getProcessedHumanInputFormInputs([], undefined)).toBeUndefined()
})
it('should process file values and fallback invalid file values', () => {
expect(getProcessedHumanInputFormInputs([
fileInput({ output_variable_name: 'attachment' }),
fileInput({ output_variable_name: 'attachmentFromArray' }),
fileInput({ output_variable_name: 'emptyAttachment' }),
fileListInput({ output_variable_name: 'attachments' }),
fileListInput({ output_variable_name: 'emptyAttachments' }),
], {
attachment: uploadedFile,
attachmentFromArray: [uploadedFile],
emptyAttachment: '',
attachments: [uploadedFile],
emptyAttachments: null,
})).toEqual({
attachment: {
type: 'image',
transfer_method: TransferMethod.local_file,
url: '',
upload_file_id: 'upload-file-1',
},
attachmentFromArray: {
type: 'image',
transfer_method: TransferMethod.local_file,
url: '',
upload_file_id: 'upload-file-1',
},
emptyAttachment: undefined,
attachments: [{
type: 'image',
transfer_method: TransferMethod.local_file,
url: '',
upload_file_id: 'upload-file-1',
}],
emptyAttachments: [],
})
})
})
describe('time helpers', () => {

View File

@ -1,8 +1,8 @@
import type { ContentItemProps } from './type'
import { Textarea } from '@langgenius/dify-ui/textarea'
import * as React from 'react'
import { useMemo } from 'react'
import { Markdown } from '@/app/components/base/markdown'
import HumanInputFieldRenderer from './field-renderer'
const ContentItem = ({
content,
@ -40,15 +40,11 @@ const ContentItem = ({
return (
<div className="py-3">
{formInputField.type === 'paragraph' && (
<Textarea
aria-label={fieldName}
className="h-[104px] sm:text-xs"
value={inputs[fieldName]!}
onValueChange={(value) => { onInputChange(fieldName, value) }}
data-testid="content-item-textarea"
/>
)}
<HumanInputFieldRenderer
field={formInputField}
value={inputs[fieldName]}
onChange={value => onInputChange(fieldName, value)}
/>
</div>
)
}

View File

@ -0,0 +1,113 @@
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
import {
Select,
SelectContent,
SelectItem,
SelectItemIndicator,
SelectItemText,
SelectTrigger,
} from '@langgenius/dify-ui/select'
import { Textarea } from '@langgenius/dify-ui/textarea'
import * as React from 'react'
import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader'
import {
isFileFormInput,
isFileListFormInput,
isParagraphFormInput,
isSelectFormInput,
} from '@/app/components/workflow/nodes/human-input/types'
export type HumanInputFieldValue = string | FileEntity | FileEntity[] | null
type Props = {
field: FormInputItem
value?: HumanInputFieldValue
onChange: (value: HumanInputFieldValue) => void
}
const HumanInputFieldRenderer = ({
field,
value,
onChange,
}: Props) => {
if (isParagraphFormInput(field)) {
return (
<Textarea
aria-label={field.output_variable_name}
className="h-[104px] sm:text-xs"
value={typeof value === 'string' ? value : ''}
onValueChange={nextValue => onChange(nextValue)}
data-testid="content-item-textarea"
/>
)
}
if (isSelectFormInput(field)) {
const options = field.option_source.value.map(option => ({
name: option,
value: option,
}))
return (
<Select
value={typeof value === 'string' ? value : ''}
onValueChange={(nextValue) => {
if (nextValue == null)
return
onChange(nextValue)
}}
>
<SelectTrigger size="large" className="w-full" aria-label={field.output_variable_name}>
{typeof value === 'string' ? value : ''}
</SelectTrigger>
<SelectContent listClassName="max-h-[140px] overflow-y-auto">
{options.map(option => (
<SelectItem key={option.value} value={option.value}>
<SelectItemText>{option.name}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
)
}
if (isFileFormInput(field)) {
const singleFileValue = value && !Array.isArray(value) && typeof value !== 'string'
? [value]
: []
return (
<FileUploaderInAttachmentWrapper
value={singleFileValue}
onChange={files => onChange(files[0] || null)}
fileConfig={{
allowed_file_types: field.allowed_file_types,
allowed_file_extensions: field.allowed_file_extensions,
allowed_file_upload_methods: field.allowed_file_upload_methods,
number_limits: 1,
}}
/>
)
}
if (isFileListFormInput(field)) {
return (
<FileUploaderInAttachmentWrapper
value={Array.isArray(value) ? value : []}
onChange={files => onChange(files)}
fileConfig={{
allowed_file_types: field.allowed_file_types,
allowed_file_extensions: field.allowed_file_extensions,
allowed_file_upload_methods: field.allowed_file_upload_methods,
number_limits: field.number_limits || 5,
}}
/>
)
}
return null
}
export default React.memo(HumanInputFieldRenderer)

View File

@ -1,36 +1,43 @@
'use client'
import type { ButtonProps } from '@langgenius/dify-ui/button'
import type { HumanInputFieldValue } from './field-renderer'
import type { HumanInputFormProps } from './type'
import type { UserAction } from '@/app/components/workflow/nodes/human-input/types'
import { Button } from '@langgenius/dify-ui/button'
import * as React from 'react'
import { useCallback, useState } from 'react'
import ContentItem from './content-item'
import { getButtonStyle, initializeInputs, splitByOutputVar } from './utils'
import { getButtonStyle, getProcessedHumanInputFormInputs, getRenderedFormInputs, hasInvalidSelectOrFileInput, initializeInputs, splitByOutputVar } from './utils'
const HumanInputForm = ({
formData,
onSubmit,
}: HumanInputFormProps) => {
const formToken = formData.form_token
const defaultInputs = initializeInputs(formData.inputs, formData.resolved_default_values || {})
const contentList = splitByOutputVar(formData.form_content)
const renderedFormInputs = getRenderedFormInputs(formData.inputs, formData.form_content)
const defaultInputs = initializeInputs(renderedFormInputs, formData.resolved_default_values || {})
const [inputs, setInputs] = useState(defaultInputs)
const [isSubmitting, setIsSubmitting] = useState(false)
const handleInputsChange = useCallback((name: string, value: string) => {
const handleInputsChange = useCallback((name: string, value: HumanInputFieldValue) => {
setInputs(prev => ({
...prev,
[name]: value,
}))
}, [])
const submit = async (formToken: string, actionID: string, inputs: Record<string, string>) => {
const submit = async (formToken: string, actionID: string, inputs: Record<string, HumanInputFieldValue>) => {
setIsSubmitting(true)
await onSubmit?.(formToken, { inputs, action: actionID })
await onSubmit?.(formToken, {
inputs: getProcessedHumanInputFormInputs(renderedFormInputs, inputs) || {},
action: actionID,
})
setIsSubmitting(false)
}
const isActionDisabled = isSubmitting || hasInvalidSelectOrFileInput(renderedFormInputs, inputs)
return (
<>
{contentList.map((content, index) => (
@ -46,7 +53,7 @@ const HumanInputForm = ({
{formData.actions.map((action: UserAction) => (
<Button
key={action.id}
disabled={isSubmitting}
disabled={isActionDisabled}
variant={getButtonStyle(action.button_style) as ButtonProps['variant']}
onClick={() => submit(formToken, action.id, inputs)}
>

View File

@ -0,0 +1,121 @@
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
import type { HumanInputFormValue } from '@/types/workflow'
import {
Select,
SelectContent,
SelectItem,
SelectItemIndicator,
SelectItemText,
SelectTrigger,
} from '@langgenius/dify-ui/select'
import * as React from 'react'
import { FileList } from '@/app/components/base/file-uploader'
import { getProcessedFilesFromResponse } from '@/app/components/base/file-uploader/utils'
import { Markdown } from '@/app/components/base/markdown'
import {
isFileFormInput,
isFileListFormInput,
isParagraphFormInput,
isSelectFormInput,
} from '@/app/components/workflow/nodes/human-input/types'
type SubmittedContentItemProps = {
content: string
formInputFields: FormInputItem[]
values: Record<string, HumanInputFormValue>
}
const outputVarRegex = /\{\{#\$output\.([^#]+)#\}\}/
const isOutputField = (content: string) => outputVarRegex.test(content)
const extractFieldName = (content: string) => {
const match = outputVarRegex.exec(content)
return match ? match[1]! : ''
}
const SubmittedContentItem = ({
content,
formInputFields,
values,
}: SubmittedContentItemProps) => {
if (!isOutputField(content)) {
return (
<Markdown content={content} />
)
}
const fieldName = extractFieldName(content)
const field = formInputFields.find(field => field.output_variable_name === fieldName)
const value = values[fieldName]
if (!field || value == null)
return null
if (isParagraphFormInput(field)) {
return (
<span
className="body-md-regular break-words text-text-primary"
data-testid={`submitted-field-${fieldName}`}
>
{typeof value === 'string' ? value : ''}
</span>
)
}
if (isSelectFormInput(field)) {
const selectedValue = typeof value === 'string' ? value : ''
return (
<div className="py-3" data-testid={`submitted-field-${fieldName}`}>
<Select value={selectedValue} disabled>
<SelectTrigger size="large" className="w-full" aria-label={field.output_variable_name} disabled>
{selectedValue}
</SelectTrigger>
<SelectContent listClassName="max-h-[140px] overflow-y-auto">
{field.option_source.value.map(option => (
<SelectItem key={option} value={option}>
<SelectItemText>{option}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}
if (isFileFormInput(field)) {
if (typeof value === 'string' || Array.isArray(value))
return null
return (
<div className="py-3" data-testid={`submitted-field-${fieldName}`}>
<FileList
files={getProcessedFilesFromResponse([value])}
showDeleteAction={false}
showDownloadAction
/>
</div>
)
}
if (isFileListFormInput(field)) {
if (typeof value === 'string' || !Array.isArray(value))
return null
return (
<div className="py-3" data-testid={`submitted-field-${fieldName}`}>
<FileList
files={getProcessedFilesFromResponse(value)}
showDeleteAction={false}
showDownloadAction
/>
</div>
)
}
return null
}
export default React.memo(SubmittedContentItem)

View File

@ -0,0 +1,88 @@
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
import type { HumanInputFormValue } from '@/types/workflow'
import * as React from 'react'
import { FileList } from '@/app/components/base/file-uploader'
import { getProcessedFilesFromResponse } from '@/app/components/base/file-uploader/utils'
import {
isFileFormInput,
isFileListFormInput,
} from '@/app/components/workflow/nodes/human-input/types'
type SubmittedFieldValuesProps = {
fields?: FormInputItem[]
values: Record<string, HumanInputFormValue>
}
const SubmittedFieldValues = ({
fields,
values,
}: SubmittedFieldValuesProps) => {
const fieldNames = fields?.map(field => field.output_variable_name) ?? Object.keys(values)
const fieldMap = new Map(fields?.map(field => [field.output_variable_name, field]) ?? [])
return (
<div className="flex flex-col gap-3" data-testid="submitted-field-values">
{fieldNames.map((fieldName) => {
const field = fieldMap.get(fieldName)
const value = values[fieldName]
if (value == null)
return null
let valueKind: 'text' | 'file' | 'file-list' = 'text'
if (field && isFileFormInput(field))
valueKind = 'file'
else if (field && isFileListFormInput(field))
valueKind = 'file-list'
else if (typeof value === 'string')
valueKind = 'text'
else if (Array.isArray(value))
valueKind = 'file-list'
else
valueKind = 'file'
if (valueKind === 'file') {
if (typeof value === 'string' || Array.isArray(value))
return null
return (
<div key={fieldName} data-testid={`submitted-field-${fieldName}`}>
<FileList
files={getProcessedFilesFromResponse([value])}
showDeleteAction={false}
showDownloadAction
/>
</div>
)
}
if (valueKind === 'file-list') {
if (typeof value === 'string' || !Array.isArray(value))
return null
return (
<div key={fieldName} data-testid={`submitted-field-${fieldName}`}>
<FileList
files={getProcessedFilesFromResponse(value)}
showDeleteAction={false}
showDownloadAction
/>
</div>
)
}
return (
<div
key={fieldName}
className="body-md-regular break-words text-text-primary"
data-testid={`submitted-field-${fieldName}`}
>
{String(value)}
</div>
)
})}
</div>
)
}
export default React.memo(SubmittedFieldValues)

Some files were not shown because too many files have changed in this diff Show More