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 01:54:28 +00:00
committed by GitHub
co-authored by JzoNg GPT 5.4 autofix-ci[bot] -LAN-
parent 44725dde74
commit 3c98f96ae8
188 changed files with 11092 additions and 1156 deletions
@@ -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",
@@ -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",
@@ -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",
[
@@ -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",
)
@@ -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",