mirror of
https://github.com/langgenius/dify.git
synced 2026-03-06 16:16:38 +08:00
feat: Human Input Node (#32060)
The frontend and backend implementation for the human input node. Co-authored-by: twwu <twwu@dify.ai> Co-authored-by: JzoNg <jzongcode@gmail.com> Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com> Co-authored-by: zhsama <torvalds@linux.do>
This commit is contained in:
@ -16,11 +16,9 @@ if not hasattr(builtins, "MethodView"):
|
||||
builtins.MethodView = MethodView # type: ignore[attr-defined]
|
||||
|
||||
|
||||
def _load_app_module():
|
||||
@pytest.fixture(scope="module")
|
||||
def app_module():
|
||||
module_name = "controllers.console.app.app"
|
||||
if module_name in sys.modules:
|
||||
return sys.modules[module_name]
|
||||
|
||||
root = Path(__file__).resolve().parents[5]
|
||||
module_path = root / "controllers" / "console" / "app" / "app.py"
|
||||
|
||||
@ -59,8 +57,12 @@ def _load_app_module():
|
||||
|
||||
stub_namespace = _StubNamespace()
|
||||
|
||||
original_console = sys.modules.get("controllers.console")
|
||||
original_app_pkg = sys.modules.get("controllers.console.app")
|
||||
original_modules: dict[str, ModuleType | None] = {
|
||||
"controllers.console": sys.modules.get("controllers.console"),
|
||||
"controllers.console.app": sys.modules.get("controllers.console.app"),
|
||||
"controllers.common.schema": sys.modules.get("controllers.common.schema"),
|
||||
module_name: sys.modules.get(module_name),
|
||||
}
|
||||
stubbed_modules: list[tuple[str, ModuleType | None]] = []
|
||||
|
||||
console_module = ModuleType("controllers.console")
|
||||
@ -105,35 +107,35 @@ def _load_app_module():
|
||||
module = util.module_from_spec(spec)
|
||||
sys.modules[module_name] = module
|
||||
|
||||
assert spec.loader is not None
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
try:
|
||||
assert spec.loader is not None
|
||||
spec.loader.exec_module(module)
|
||||
yield module
|
||||
finally:
|
||||
for name, original in reversed(stubbed_modules):
|
||||
if original is not None:
|
||||
sys.modules[name] = original
|
||||
else:
|
||||
sys.modules.pop(name, None)
|
||||
if original_console is not None:
|
||||
sys.modules["controllers.console"] = original_console
|
||||
else:
|
||||
sys.modules.pop("controllers.console", None)
|
||||
if original_app_pkg is not None:
|
||||
sys.modules["controllers.console.app"] = original_app_pkg
|
||||
else:
|
||||
sys.modules.pop("controllers.console.app", None)
|
||||
|
||||
return module
|
||||
for name, original in original_modules.items():
|
||||
if original is not None:
|
||||
sys.modules[name] = original
|
||||
else:
|
||||
sys.modules.pop(name, None)
|
||||
|
||||
|
||||
_app_module = _load_app_module()
|
||||
AppDetailWithSite = _app_module.AppDetailWithSite
|
||||
AppPagination = _app_module.AppPagination
|
||||
AppPartial = _app_module.AppPartial
|
||||
@pytest.fixture(scope="module")
|
||||
def app_models(app_module):
|
||||
return SimpleNamespace(
|
||||
AppDetailWithSite=app_module.AppDetailWithSite,
|
||||
AppPagination=app_module.AppPagination,
|
||||
AppPartial=app_module.AppPartial,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def patch_signed_url(monkeypatch):
|
||||
def patch_signed_url(monkeypatch, app_module):
|
||||
"""Ensure icon URL generation uses a deterministic helper for tests."""
|
||||
|
||||
def _fake_signed_url(key: str | None) -> str | None:
|
||||
@ -141,7 +143,7 @@ def patch_signed_url(monkeypatch):
|
||||
return None
|
||||
return f"signed:{key}"
|
||||
|
||||
monkeypatch.setattr(_app_module.file_helpers, "get_signed_file_url", _fake_signed_url)
|
||||
monkeypatch.setattr(app_module.file_helpers, "get_signed_file_url", _fake_signed_url)
|
||||
|
||||
|
||||
def _ts(hour: int = 12) -> datetime:
|
||||
@ -169,7 +171,8 @@ def _dummy_workflow():
|
||||
)
|
||||
|
||||
|
||||
def test_app_partial_serialization_uses_aliases():
|
||||
def test_app_partial_serialization_uses_aliases(app_models):
|
||||
AppPartial = app_models.AppPartial
|
||||
created_at = _ts()
|
||||
app_obj = SimpleNamespace(
|
||||
id="app-1",
|
||||
@ -204,7 +207,8 @@ def test_app_partial_serialization_uses_aliases():
|
||||
assert serialized["tags"][0]["name"] == "Utilities"
|
||||
|
||||
|
||||
def test_app_detail_with_site_includes_nested_serialization():
|
||||
def test_app_detail_with_site_includes_nested_serialization(app_models):
|
||||
AppDetailWithSite = app_models.AppDetailWithSite
|
||||
timestamp = _ts(14)
|
||||
site = SimpleNamespace(
|
||||
code="site-code",
|
||||
@ -253,7 +257,8 @@ def test_app_detail_with_site_includes_nested_serialization():
|
||||
assert serialized["site"]["created_at"] == int(timestamp.timestamp())
|
||||
|
||||
|
||||
def test_app_pagination_aliases_per_page_and_has_next():
|
||||
def test_app_pagination_aliases_per_page_and_has_next(app_models):
|
||||
AppPagination = app_models.AppPagination
|
||||
item_one = SimpleNamespace(
|
||||
id="app-10",
|
||||
name="Paginated One",
|
||||
|
||||
@ -0,0 +1,229 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from pydantic import ValidationError
|
||||
|
||||
from controllers.console import wraps as console_wraps
|
||||
from controllers.console.app import workflow as workflow_module
|
||||
from controllers.console.app import wraps as app_wraps
|
||||
from libs import login as login_lib
|
||||
from models.account import Account, AccountStatus, TenantAccountRole
|
||||
from models.model import AppMode
|
||||
|
||||
|
||||
def _make_account() -> Account:
|
||||
account = Account(name="tester", email="tester@example.com")
|
||||
account.status = AccountStatus.ACTIVE
|
||||
account.role = TenantAccountRole.OWNER
|
||||
account.id = "account-123" # type: ignore[assignment]
|
||||
account._current_tenant = SimpleNamespace(id="tenant-123") # type: ignore[attr-defined]
|
||||
account._get_current_object = lambda: account # type: ignore[attr-defined]
|
||||
return account
|
||||
|
||||
|
||||
def _make_app(mode: AppMode) -> SimpleNamespace:
|
||||
return SimpleNamespace(id="app-123", tenant_id="tenant-123", mode=mode.value)
|
||||
|
||||
|
||||
def _patch_console_guards(monkeypatch: pytest.MonkeyPatch, account: Account, app_model: SimpleNamespace) -> None:
|
||||
# Skip setup and auth guardrails
|
||||
monkeypatch.setattr("configs.dify_config.EDITION", "CLOUD")
|
||||
monkeypatch.setattr(login_lib.dify_config, "LOGIN_DISABLED", True)
|
||||
monkeypatch.setattr(login_lib, "current_user", account)
|
||||
monkeypatch.setattr(login_lib, "current_account_with_tenant", lambda: (account, account.current_tenant_id))
|
||||
monkeypatch.setattr(login_lib, "check_csrf_token", lambda *_, **__: None)
|
||||
monkeypatch.setattr(console_wraps, "current_account_with_tenant", lambda: (account, account.current_tenant_id))
|
||||
monkeypatch.setattr(app_wraps, "current_account_with_tenant", lambda: (account, account.current_tenant_id))
|
||||
monkeypatch.setattr(workflow_module, "current_account_with_tenant", lambda: (account, account.current_tenant_id))
|
||||
monkeypatch.setattr(console_wraps.dify_config, "EDITION", "CLOUD")
|
||||
monkeypatch.delenv("INIT_PASSWORD", raising=False)
|
||||
|
||||
# Avoid hitting the database when resolving the app model
|
||||
monkeypatch.setattr(app_wraps, "_load_app_model", lambda _app_id: app_model)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PreviewCase:
|
||||
resource_cls: type
|
||||
path: str
|
||||
mode: AppMode
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"case",
|
||||
[
|
||||
PreviewCase(
|
||||
resource_cls=workflow_module.AdvancedChatDraftHumanInputFormPreviewApi,
|
||||
path="/console/api/apps/app-123/advanced-chat/workflows/draft/human-input/nodes/node-42/form/preview",
|
||||
mode=AppMode.ADVANCED_CHAT,
|
||||
),
|
||||
PreviewCase(
|
||||
resource_cls=workflow_module.WorkflowDraftHumanInputFormPreviewApi,
|
||||
path="/console/api/apps/app-123/workflows/draft/human-input/nodes/node-42/form/preview",
|
||||
mode=AppMode.WORKFLOW,
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_human_input_preview_delegates_to_service(
|
||||
app: Flask, monkeypatch: pytest.MonkeyPatch, case: PreviewCase
|
||||
) -> None:
|
||||
account = _make_account()
|
||||
app_model = _make_app(case.mode)
|
||||
_patch_console_guards(monkeypatch, account, app_model)
|
||||
|
||||
preview_payload = {
|
||||
"form_id": "node-42",
|
||||
"form_content": "<div>example</div>",
|
||||
"inputs": [{"name": "topic"}],
|
||||
"actions": [{"id": "continue"}],
|
||||
}
|
||||
service_instance = MagicMock()
|
||||
service_instance.get_human_input_form_preview.return_value = preview_payload
|
||||
monkeypatch.setattr(workflow_module, "WorkflowService", MagicMock(return_value=service_instance))
|
||||
|
||||
with app.test_request_context(case.path, method="POST", json={"inputs": {"topic": "tech"}}):
|
||||
response = case.resource_cls().post(app_id=app_model.id, node_id="node-42")
|
||||
|
||||
assert response == preview_payload
|
||||
service_instance.get_human_input_form_preview.assert_called_once_with(
|
||||
app_model=app_model,
|
||||
account=account,
|
||||
node_id="node-42",
|
||||
inputs={"topic": "tech"},
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SubmitCase:
|
||||
resource_cls: type
|
||||
path: str
|
||||
mode: AppMode
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"case",
|
||||
[
|
||||
SubmitCase(
|
||||
resource_cls=workflow_module.AdvancedChatDraftHumanInputFormRunApi,
|
||||
path="/console/api/apps/app-123/advanced-chat/workflows/draft/human-input/nodes/node-99/form/run",
|
||||
mode=AppMode.ADVANCED_CHAT,
|
||||
),
|
||||
SubmitCase(
|
||||
resource_cls=workflow_module.WorkflowDraftHumanInputFormRunApi,
|
||||
path="/console/api/apps/app-123/workflows/draft/human-input/nodes/node-99/form/run",
|
||||
mode=AppMode.WORKFLOW,
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_human_input_submit_forwards_payload(app: Flask, monkeypatch: pytest.MonkeyPatch, case: SubmitCase) -> None:
|
||||
account = _make_account()
|
||||
app_model = _make_app(case.mode)
|
||||
_patch_console_guards(monkeypatch, account, app_model)
|
||||
|
||||
result_payload = {"node_id": "node-99", "outputs": {"__rendered_content": "<p>done</p>"}, "action": "approve"}
|
||||
service_instance = MagicMock()
|
||||
service_instance.submit_human_input_form_preview.return_value = result_payload
|
||||
monkeypatch.setattr(workflow_module, "WorkflowService", MagicMock(return_value=service_instance))
|
||||
|
||||
with app.test_request_context(
|
||||
case.path,
|
||||
method="POST",
|
||||
json={"form_inputs": {"answer": "42"}, "inputs": {"#node-1.result#": "LLM output"}, "action": "approve"},
|
||||
):
|
||||
response = case.resource_cls().post(app_id=app_model.id, node_id="node-99")
|
||||
|
||||
assert response == result_payload
|
||||
service_instance.submit_human_input_form_preview.assert_called_once_with(
|
||||
app_model=app_model,
|
||||
account=account,
|
||||
node_id="node-99",
|
||||
form_inputs={"answer": "42"},
|
||||
inputs={"#node-1.result#": "LLM output"},
|
||||
action="approve",
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeliveryTestCase:
|
||||
resource_cls: type
|
||||
path: str
|
||||
mode: AppMode
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"case",
|
||||
[
|
||||
DeliveryTestCase(
|
||||
resource_cls=workflow_module.WorkflowDraftHumanInputDeliveryTestApi,
|
||||
path="/console/api/apps/app-123/workflows/draft/human-input/nodes/node-7/delivery-test",
|
||||
mode=AppMode.ADVANCED_CHAT,
|
||||
),
|
||||
DeliveryTestCase(
|
||||
resource_cls=workflow_module.WorkflowDraftHumanInputDeliveryTestApi,
|
||||
path="/console/api/apps/app-123/workflows/draft/human-input/nodes/node-7/delivery-test",
|
||||
mode=AppMode.WORKFLOW,
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_human_input_delivery_test_calls_service(
|
||||
app: Flask, monkeypatch: pytest.MonkeyPatch, case: DeliveryTestCase
|
||||
) -> None:
|
||||
account = _make_account()
|
||||
app_model = _make_app(case.mode)
|
||||
_patch_console_guards(monkeypatch, account, app_model)
|
||||
|
||||
service_instance = MagicMock()
|
||||
monkeypatch.setattr(workflow_module, "WorkflowService", MagicMock(return_value=service_instance))
|
||||
|
||||
with app.test_request_context(
|
||||
case.path,
|
||||
method="POST",
|
||||
json={"delivery_method_id": "delivery-123"},
|
||||
):
|
||||
response = case.resource_cls().post(app_id=app_model.id, node_id="node-7")
|
||||
|
||||
assert response == {}
|
||||
service_instance.test_human_input_delivery.assert_called_once_with(
|
||||
app_model=app_model,
|
||||
account=account,
|
||||
node_id="node-7",
|
||||
delivery_method_id="delivery-123",
|
||||
inputs={},
|
||||
)
|
||||
|
||||
|
||||
def test_human_input_delivery_test_maps_validation_error(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
account = _make_account()
|
||||
app_model = _make_app(AppMode.ADVANCED_CHAT)
|
||||
_patch_console_guards(monkeypatch, account, app_model)
|
||||
|
||||
service_instance = MagicMock()
|
||||
service_instance.test_human_input_delivery.side_effect = ValueError("bad delivery method")
|
||||
monkeypatch.setattr(workflow_module, "WorkflowService", MagicMock(return_value=service_instance))
|
||||
|
||||
with app.test_request_context(
|
||||
"/console/api/apps/app-123/workflows/draft/human-input/nodes/node-1/delivery-test",
|
||||
method="POST",
|
||||
json={"delivery_method_id": "bad"},
|
||||
):
|
||||
with pytest.raises(ValueError):
|
||||
workflow_module.WorkflowDraftHumanInputDeliveryTestApi().post(app_id=app_model.id, node_id="node-1")
|
||||
|
||||
|
||||
def test_human_input_preview_rejects_non_mapping(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
account = _make_account()
|
||||
app_model = _make_app(AppMode.ADVANCED_CHAT)
|
||||
_patch_console_guards(monkeypatch, account, app_model)
|
||||
|
||||
with app.test_request_context(
|
||||
"/console/api/apps/app-123/advanced-chat/workflows/draft/human-input/nodes/node-1/form/preview",
|
||||
method="POST",
|
||||
json={"inputs": ["not-a-dict"]},
|
||||
):
|
||||
with pytest.raises(ValidationError):
|
||||
workflow_module.AdvancedChatDraftHumanInputFormPreviewApi().post(app_id=app_model.id, node_id="node-1")
|
||||
@ -0,0 +1,110 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
from controllers.console import wraps as console_wraps
|
||||
from controllers.console.app import workflow_run as workflow_run_module
|
||||
from controllers.web.error import NotFoundError
|
||||
from core.workflow.entities.pause_reason import HumanInputRequired
|
||||
from core.workflow.enums import WorkflowExecutionStatus
|
||||
from core.workflow.nodes.human_input.entities import FormInput, UserAction
|
||||
from core.workflow.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
|
||||
|
||||
|
||||
def _make_account() -> Account:
|
||||
account = Account(name="tester", email="tester@example.com")
|
||||
account.status = AccountStatus.ACTIVE
|
||||
account.role = TenantAccountRole.OWNER
|
||||
account.id = "account-123" # type: ignore[assignment]
|
||||
account._current_tenant = SimpleNamespace(id="tenant-123") # type: ignore[attr-defined]
|
||||
account._get_current_object = lambda: account # type: ignore[attr-defined]
|
||||
return account
|
||||
|
||||
|
||||
def _patch_console_guards(monkeypatch: pytest.MonkeyPatch, account: Account) -> None:
|
||||
monkeypatch.setattr(login_lib.dify_config, "LOGIN_DISABLED", True)
|
||||
monkeypatch.setattr(login_lib, "current_user", account)
|
||||
monkeypatch.setattr(login_lib, "current_account_with_tenant", lambda: (account, account.current_tenant_id))
|
||||
monkeypatch.setattr(login_lib, "check_csrf_token", lambda *_, **__: None)
|
||||
monkeypatch.setattr(console_wraps, "current_account_with_tenant", lambda: (account, account.current_tenant_id))
|
||||
monkeypatch.setattr(workflow_run_module, "current_user", account)
|
||||
monkeypatch.setattr(console_wraps.dify_config, "EDITION", "CLOUD")
|
||||
|
||||
|
||||
class _PauseEntity:
|
||||
def __init__(self, paused_at: datetime, reasons: list[HumanInputRequired]):
|
||||
self.paused_at = paused_at
|
||||
self._reasons = reasons
|
||||
|
||||
def get_pause_reasons(self):
|
||||
return self._reasons
|
||||
|
||||
|
||||
def test_pause_details_returns_backstage_input_url(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
account = _make_account()
|
||||
_patch_console_guards(monkeypatch, account)
|
||||
monkeypatch.setattr(workflow_run_module.dify_config, "APP_WEB_URL", "https://web.example.com")
|
||||
|
||||
workflow_run = Mock(spec=WorkflowRun)
|
||||
workflow_run.tenant_id = "tenant-123"
|
||||
workflow_run.status = WorkflowExecutionStatus.PAUSED
|
||||
workflow_run.created_at = datetime(2024, 1, 1, 12, 0, 0)
|
||||
fake_db = SimpleNamespace(engine=Mock(), session=SimpleNamespace(get=lambda *_: workflow_run))
|
||||
monkeypatch.setattr(workflow_run_module, "db", fake_db)
|
||||
|
||||
reason = HumanInputRequired(
|
||||
form_id="form-1",
|
||||
form_content="content",
|
||||
inputs=[FormInput(type=FormInputType.TEXT_INPUT, output_variable_name="name")],
|
||||
actions=[UserAction(id="approve", title="Approve")],
|
||||
node_id="node-1",
|
||||
node_title="Ask Name",
|
||||
form_token="backstage-token",
|
||||
)
|
||||
pause_entity = _PauseEntity(paused_at=datetime(2024, 1, 1, 12, 0, 0), reasons=[reason])
|
||||
|
||||
repo = Mock()
|
||||
repo.get_workflow_pause.return_value = pause_entity
|
||||
monkeypatch.setattr(
|
||||
workflow_run_module.DifyAPIRepositoryFactory,
|
||||
"create_api_workflow_run_repository",
|
||||
lambda *_, **__: repo,
|
||||
)
|
||||
|
||||
with app.test_request_context("/console/api/workflow/run-1/pause-details", method="GET"):
|
||||
response, status = workflow_run_module.ConsoleWorkflowPauseDetailsApi().get(workflow_run_id="run-1")
|
||||
|
||||
assert status == 200
|
||||
assert response["paused_at"] == "2024-01-01T12:00:00Z"
|
||||
assert response["paused_nodes"][0]["node_id"] == "node-1"
|
||||
assert response["paused_nodes"][0]["pause_type"]["type"] == "human_input"
|
||||
assert (
|
||||
response["paused_nodes"][0]["pause_type"]["backstage_input_url"]
|
||||
== "https://web.example.com/form/backstage-token"
|
||||
)
|
||||
assert "pending_human_inputs" not in response
|
||||
|
||||
|
||||
def test_pause_details_tenant_isolation(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
account = _make_account()
|
||||
_patch_console_guards(monkeypatch, account)
|
||||
monkeypatch.setattr(workflow_run_module.dify_config, "APP_WEB_URL", "https://web.example.com")
|
||||
|
||||
workflow_run = Mock(spec=WorkflowRun)
|
||||
workflow_run.tenant_id = "tenant-456"
|
||||
workflow_run.status = WorkflowExecutionStatus.PAUSED
|
||||
workflow_run.created_at = datetime(2024, 1, 1, 12, 0, 0)
|
||||
fake_db = SimpleNamespace(engine=Mock(), session=SimpleNamespace(get=lambda *_: workflow_run))
|
||||
monkeypatch.setattr(workflow_run_module, "db", fake_db)
|
||||
|
||||
with pytest.raises(NotFoundError):
|
||||
with app.test_request_context("/console/api/workflow/run-1/pause-details", method="GET"):
|
||||
response, status = workflow_run_module.ConsoleWorkflowPauseDetailsApi().get(workflow_run_id="run-1")
|
||||
Reference in New Issue
Block a user