diff --git a/api/controllers/service_api/__init__.py b/api/controllers/service_api/__init__.py index 4f7f7d9a98..182631e8f5 100644 --- a/api/controllers/service_api/__init__.py +++ b/api/controllers/service_api/__init__.py @@ -23,9 +23,11 @@ from .app import ( conversation, file, file_preview, + human_input_form, message, site, workflow, + workflow_events, ) from .dataset import ( dataset, @@ -50,6 +52,7 @@ __all__ = [ "file", "file_preview", "hit_testing", + "human_input_form", "index", "message", "metadata", @@ -58,6 +61,7 @@ __all__ = [ "segment", "site", "workflow", + "workflow_events", ] api.add_namespace(service_api_ns) diff --git a/api/controllers/service_api/app/human_input_form.py b/api/controllers/service_api/app/human_input_form.py new file mode 100644 index 0000000000..c556255530 --- /dev/null +++ b/api/controllers/service_api/app/human_input_form.py @@ -0,0 +1,133 @@ +""" +Service API human input form endpoints. + +This module exposes app-token authenticated APIs for fetching and submitting +paused human input forms in workflow/chatflow runs. +""" + +import json +import logging +from datetime import datetime +from typing import Any + +from flask import Response +from flask_restx import Resource +from pydantic import BaseModel +from werkzeug.exceptions import InternalServerError, NotFound + +from controllers.common.schema import register_schema_models +from controllers.service_api import service_api_ns +from controllers.service_api.wraps import FetchUserArg, WhereisUserArg, validate_app_token +from extensions.ext_database import db +from models.model import App, EndUser +from services.human_input_service import Form, FormNotFoundError, HumanInputService + +logger = logging.getLogger(__name__) + + +class HumanInputFormSubmitPayload(BaseModel): + inputs: dict[str, Any] + action: str + + +register_schema_models(service_api_ns, HumanInputFormSubmitPayload) + + +def _stringify_default_values(values: dict[str, object]) -> dict[str, str]: + result: dict[str, str] = {} + for key, value in values.items(): + if value is None: + result[key] = "" + elif isinstance(value, (dict, list)): + result[key] = json.dumps(value, ensure_ascii=False) + else: + result[key] = str(value) + return result + + +def _to_timestamp(value: datetime) -> int: + return int(value.timestamp()) + + +def _jsonify_form_definition(form: Form) -> Response: + definition_payload = form.get_definition().model_dump() + payload = { + "form_content": definition_payload["rendered_content"], + "inputs": definition_payload["inputs"], + "resolved_default_values": _stringify_default_values(definition_payload["default_values"]), + "user_actions": definition_payload["user_actions"], + "expiration_time": _to_timestamp(form.expiration_time), + } + return Response(json.dumps(payload, ensure_ascii=False), mimetype="application/json") + + +def _ensure_form_belongs_to_app(form: Form, app_model: App) -> None: + if form.app_id != app_model.id or form.tenant_id != app_model.tenant_id: + raise NotFound("Form not found") + + +@service_api_ns.route("/form/human_input/") +class WorkflowHumanInputFormApi(Resource): + @service_api_ns.doc("get_human_input_form") + @service_api_ns.doc(description="Get a paused human input form by token") + @service_api_ns.doc(params={"form_token": "Human input form token"}) + @service_api_ns.doc( + responses={ + 200: "Form retrieved successfully", + 401: "Unauthorized - invalid API token", + 404: "Form not found", + 412: "Form already submitted or expired", + } + ) + @validate_app_token + def get(self, app_model: App, form_token: str): + service = HumanInputService(db.engine) + form = service.get_form_by_token(form_token) + if form is None: + raise NotFound("Form not found") + + _ensure_form_belongs_to_app(form, app_model) + service.ensure_form_active(form) + return _jsonify_form_definition(form) + + @service_api_ns.expect(service_api_ns.models[HumanInputFormSubmitPayload.__name__]) + @service_api_ns.doc("submit_human_input_form") + @service_api_ns.doc(description="Submit a paused human input form by token") + @service_api_ns.doc(params={"form_token": "Human input form token"}) + @service_api_ns.doc( + responses={ + 200: "Form submitted successfully", + 400: "Bad request - invalid submission data", + 401: "Unauthorized - invalid API token", + 404: "Form not found", + 412: "Form already submitted or expired", + } + ) + @validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.JSON, required=True)) + def post(self, app_model: App, end_user: EndUser, form_token: str): + payload = HumanInputFormSubmitPayload.model_validate(service_api_ns.payload or {}) + + service = HumanInputService(db.engine) + form = service.get_form_by_token(form_token) + if form is None: + raise NotFound("Form not found") + + _ensure_form_belongs_to_app(form, app_model) + + recipient_type = form.recipient_type + if recipient_type is None: + logger.warning("Recipient type is None for form, form_id=%s", form.id) + raise InternalServerError("Form recipient type is invalid") + + try: + service.submit_form_by_token( + recipient_type=recipient_type, + form_token=form_token, + selected_action_id=payload.action, + form_data=payload.inputs, + submission_end_user_id=end_user.id, + ) + except FormNotFoundError: + raise NotFound("Form not found") + + return {}, 200 diff --git a/api/controllers/service_api/app/workflow_events.py b/api/controllers/service_api/app/workflow_events.py new file mode 100644 index 0000000000..58bbbbbd1f --- /dev/null +++ b/api/controllers/service_api/app/workflow_events.py @@ -0,0 +1,125 @@ +""" +Service API workflow resume event stream endpoints. +""" + +import json +from collections.abc import Generator + +from flask import Response, request +from flask_restx import Resource +from sqlalchemy.orm import sessionmaker +from werkzeug.exceptions import NotFound + +from controllers.service_api import service_api_ns +from controllers.service_api.app.error import NotWorkflowAppError +from controllers.service_api.wraps import FetchUserArg, WhereisUserArg, validate_app_token +from core.app.apps.advanced_chat.app_generator import AdvancedChatAppGenerator +from core.app.apps.base_app_generator import BaseAppGenerator +from core.app.apps.common.workflow_response_converter import WorkflowResponseConverter +from core.app.apps.message_generator import MessageGenerator +from core.app.apps.workflow.app_generator import WorkflowAppGenerator +from extensions.ext_database import db +from models.enums import CreatorUserRole +from models.model import App, AppMode, EndUser +from repositories.factory import DifyAPIRepositoryFactory +from services.workflow_event_snapshot_service import build_workflow_event_stream + + +@service_api_ns.route("/workflow//events") +class WorkflowEventsApi(Resource): + """Service API for getting workflow execution events after resume.""" + + @service_api_ns.doc("get_workflow_events") + @service_api_ns.doc(description="Get workflow execution events stream after resume") + @service_api_ns.doc( + params={ + "task_id": "Workflow run ID", + "user": "End user identifier (query param)", + "include_state_snapshot": "Whether to replay from persisted state snapshot", + } + ) + @service_api_ns.doc( + responses={ + 200: "SSE event stream", + 401: "Unauthorized - invalid API token", + 404: "Workflow run not found", + } + ) + @validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.QUERY, required=True)) + def get(self, app_model: App, end_user: EndUser, task_id: str): + app_mode = AppMode.value_of(app_model.mode) + if app_mode not in {AppMode.WORKFLOW, AppMode.ADVANCED_CHAT}: + raise NotWorkflowAppError() + + session_maker = sessionmaker(db.engine) + repo = DifyAPIRepositoryFactory.create_api_workflow_run_repository(session_maker) + workflow_run = repo.get_workflow_run_by_id_and_tenant_id( + tenant_id=app_model.tenant_id, + run_id=task_id, + ) + + if workflow_run is None: + raise NotFound("Workflow run not found") + + if workflow_run.app_id != app_model.id: + raise NotFound("Workflow run not found") + + if workflow_run.created_by_role != CreatorUserRole.END_USER: + raise NotFound("Workflow run not found") + + if workflow_run.created_by != end_user.id: + raise NotFound("Workflow run not found") + + workflow_run_entity = workflow_run + + if workflow_run_entity.finished_at is not None: + response = WorkflowResponseConverter.workflow_run_result_to_finish_response( + task_id=workflow_run_entity.id, + workflow_run=workflow_run_entity, + creator_user=end_user, + ) + + payload = response.model_dump(mode="json") + payload["event"] = response.event.value + + def _generate_finished_events() -> Generator[str, None, None]: + yield f"data: {json.dumps(payload)}\n\n" + + event_generator = _generate_finished_events + else: + msg_generator = MessageGenerator() + generator: BaseAppGenerator + if app_mode == AppMode.ADVANCED_CHAT: + generator = AdvancedChatAppGenerator() + elif app_mode == AppMode.WORKFLOW: + generator = WorkflowAppGenerator() + else: + raise NotWorkflowAppError() + + include_state_snapshot = request.args.get("include_state_snapshot", "false").lower() == "true" + + def _generate_stream_events(): + if include_state_snapshot: + return generator.convert_to_event_stream( + build_workflow_event_stream( + app_mode=app_mode, + workflow_run=workflow_run_entity, + tenant_id=app_model.tenant_id, + app_id=app_model.id, + session_maker=session_maker, + ) + ) + return generator.convert_to_event_stream( + msg_generator.retrieve_events(app_mode, workflow_run_entity.id), + ) + + event_generator = _generate_stream_events + + return Response( + event_generator(), + mimetype="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + }, + ) diff --git a/api/tests/unit_tests/controllers/service_api/app/test_human_input_form.py b/api/tests/unit_tests/controllers/service_api/app/test_human_input_form.py new file mode 100644 index 0000000000..b3c4845fdd --- /dev/null +++ b/api/tests/unit_tests/controllers/service_api/app/test_human_input_form.py @@ -0,0 +1,111 @@ +"""Unit tests for Service API human input form endpoints.""" + +from __future__ import annotations + +import json +import sys +from datetime import UTC, datetime +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest +from werkzeug.exceptions import NotFound + +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 + + +class TestWorkflowHumanInputFormApi: + def test_get_success(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + definition = SimpleNamespace( + model_dump=lambda: { + "rendered_content": "Rendered form content", + "inputs": [{"output_variable_name": "name"}], + "default_values": {"name": "Alice", "age": 30, "meta": {"k": "v"}}, + "user_actions": [{"id": "approve", "title": "Approve"}], + } + ) + form = SimpleNamespace( + app_id="app-1", + tenant_id="tenant-1", + expiration_time=datetime(2099, 1, 1, tzinfo=UTC), + get_definition=lambda: definition, + ) + 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.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 == { + "form_content": "Rendered form content", + "inputs": [{"output_variable_name": "name"}], + "resolved_default_values": {"name": "Alice", "age": "30", "meta": '{"k": "v"}'}, + "user_actions": [{"id": "approve", "title": "Approve"}], + "expiration_time": int(form.expiration_time.timestamp()), + } + service_mock.get_form_by_token.assert_called_once_with("token-1") + service_mock.ensure_form_active.assert_called_once_with(form) + + def test_get_form_not_in_app(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + form = SimpleNamespace( + app_id="another-app", + tenant_id="tenant-1", + expiration_time=datetime(2099, 1, 1, tzinfo=UTC), + ) + 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.get) + app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1") + + with app.test_request_context("/form/human_input/token-1", method="GET"): + with pytest.raises(NotFound): + handler(api, app_model=app_model, form_token="token-1") + + def test_post_success(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + form = SimpleNamespace( + app_id="app-1", + tenant_id="tenant-1", + recipient_type=RecipientType.BACKSTAGE, + ) + 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") + + with app.test_request_context( + "/form/human_input/token-1", + method="POST", + json={"inputs": {"name": "Alice"}, "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.BACKSTAGE, + form_token="token-1", + selected_action_id="approve", + form_data={"name": "Alice"}, + submission_end_user_id="end-user-1", + ) diff --git a/api/tests/unit_tests/controllers/service_api/app/test_workflow_events.py b/api/tests/unit_tests/controllers/service_api/app/test_workflow_events.py new file mode 100644 index 0000000000..6ec33e4884 --- /dev/null +++ b/api/tests/unit_tests/controllers/service_api/app/test_workflow_events.py @@ -0,0 +1,162 @@ +"""Unit tests for Service API workflow event stream endpoints.""" + +from __future__ import annotations + +import json +import sys +from datetime import UTC, datetime +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest +from werkzeug.exceptions import NotFound + +from controllers.service_api.app.error import NotWorkflowAppError +from controllers.service_api.app.workflow_events import WorkflowEventsApi +from models.enums import CreatorUserRole +from models.model import AppMode +from tests.unit_tests.controllers.service_api.conftest import _unwrap + + +def _mock_repo_for_run(monkeypatch: pytest.MonkeyPatch, workflow_run): + workflow_events_module = sys.modules["controllers.service_api.app.workflow_events"] + repo = SimpleNamespace(get_workflow_run_by_id_and_tenant_id=lambda **_kwargs: workflow_run) + monkeypatch.setattr( + workflow_events_module.DifyAPIRepositoryFactory, + "create_api_workflow_run_repository", + lambda *_args, **_kwargs: repo, + ) + monkeypatch.setattr(workflow_events_module, "db", SimpleNamespace(engine=object())) + return workflow_events_module + + +class TestWorkflowEventsApi: + def test_wrong_app_mode(self, app) -> None: + api = WorkflowEventsApi() + handler = _unwrap(api.get) + app_model = SimpleNamespace(mode=AppMode.CHAT.value) + end_user = SimpleNamespace(id="end-user-1") + + with app.test_request_context("/workflow/run-1/events?user=u1", method="GET"): + with pytest.raises(NotWorkflowAppError): + handler(api, app_model=app_model, end_user=end_user, task_id="run-1") + + def test_workflow_run_not_found(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + _mock_repo_for_run(monkeypatch, workflow_run=None) + api = WorkflowEventsApi() + handler = _unwrap(api.get) + app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode=AppMode.WORKFLOW.value) + end_user = SimpleNamespace(id="end-user-1") + + with app.test_request_context("/workflow/run-1/events?user=u1", method="GET"): + with pytest.raises(NotFound): + handler(api, app_model=app_model, end_user=end_user, task_id="run-1") + + def test_workflow_run_permission_denied(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + workflow_run = SimpleNamespace( + id="run-1", + app_id="app-1", + created_by_role=CreatorUserRole.ACCOUNT, + created_by="another-user", + finished_at=None, + ) + _mock_repo_for_run(monkeypatch, workflow_run=workflow_run) + api = WorkflowEventsApi() + handler = _unwrap(api.get) + app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode=AppMode.WORKFLOW.value) + end_user = SimpleNamespace(id="end-user-1") + + with app.test_request_context("/workflow/run-1/events?user=u1", method="GET"): + with pytest.raises(NotFound): + handler(api, app_model=app_model, end_user=end_user, task_id="run-1") + + def test_finished_run_returns_sse(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + workflow_run = SimpleNamespace( + id="run-1", + app_id="app-1", + created_by_role=CreatorUserRole.END_USER, + created_by="end-user-1", + finished_at=datetime(2099, 1, 1, tzinfo=UTC), + ) + workflow_events_module = _mock_repo_for_run(monkeypatch, workflow_run=workflow_run) + monkeypatch.setattr( + workflow_events_module.WorkflowResponseConverter, + "workflow_run_result_to_finish_response", + lambda **_kwargs: SimpleNamespace( + model_dump=lambda mode="json": {"task_id": "run-1", "status": "succeeded"}, + event=SimpleNamespace(value="workflow_finished"), + ), + ) + + api = WorkflowEventsApi() + handler = _unwrap(api.get) + app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode=AppMode.WORKFLOW.value) + end_user = SimpleNamespace(id="end-user-1") + + with app.test_request_context("/workflow/run-1/events?user=u1", method="GET"): + response = handler(api, app_model=app_model, end_user=end_user, task_id="run-1") + + assert response.mimetype == "text/event-stream" + body = response.get_data(as_text=True).strip() + assert body.startswith("data: ") + payload = json.loads(body[len("data: ") :]) + assert payload["task_id"] == "run-1" + assert payload["event"] == "workflow_finished" + + def test_running_run_streams_events(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + workflow_run = SimpleNamespace( + id="run-1", + app_id="app-1", + created_by_role=CreatorUserRole.END_USER, + created_by="end-user-1", + finished_at=None, + ) + workflow_events_module = _mock_repo_for_run(monkeypatch, workflow_run=workflow_run) + msg_generator = Mock() + msg_generator.retrieve_events.return_value = ["raw-event"] + workflow_generator = Mock() + workflow_generator.convert_to_event_stream.return_value = iter(["data: streamed\n\n"]) + monkeypatch.setattr(workflow_events_module, "MessageGenerator", lambda: msg_generator) + monkeypatch.setattr(workflow_events_module, "WorkflowAppGenerator", lambda: workflow_generator) + + api = WorkflowEventsApi() + handler = _unwrap(api.get) + app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode=AppMode.WORKFLOW.value) + end_user = SimpleNamespace(id="end-user-1") + + with app.test_request_context("/workflow/run-1/events?user=u1", method="GET"): + response = handler(api, app_model=app_model, end_user=end_user, task_id="run-1") + + assert response.get_data(as_text=True) == "data: streamed\n\n" + msg_generator.retrieve_events.assert_called_once_with(AppMode.WORKFLOW, "run-1") + workflow_generator.convert_to_event_stream.assert_called_once_with(["raw-event"]) + + def test_running_run_with_snapshot(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + workflow_run = SimpleNamespace( + id="run-1", + app_id="app-1", + created_by_role=CreatorUserRole.END_USER, + created_by="end-user-1", + finished_at=None, + ) + workflow_events_module = _mock_repo_for_run(monkeypatch, workflow_run=workflow_run) + msg_generator = Mock() + workflow_generator = Mock() + workflow_generator.convert_to_event_stream.return_value = iter(["data: snapshot\n\n"]) + snapshot_builder = Mock(return_value=["snapshot-events"]) + monkeypatch.setattr(workflow_events_module, "MessageGenerator", lambda: msg_generator) + monkeypatch.setattr(workflow_events_module, "WorkflowAppGenerator", lambda: workflow_generator) + monkeypatch.setattr(workflow_events_module, "build_workflow_event_stream", snapshot_builder) + + api = WorkflowEventsApi() + handler = _unwrap(api.get) + app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode=AppMode.WORKFLOW.value) + end_user = SimpleNamespace(id="end-user-1") + + with app.test_request_context("/workflow/run-1/events?user=u1&include_state_snapshot=true", method="GET"): + response = handler(api, app_model=app_model, end_user=end_user, task_id="run-1") + + assert response.get_data(as_text=True) == "data: snapshot\n\n" + msg_generator.retrieve_events.assert_not_called() + snapshot_builder.assert_called_once() + workflow_generator.convert_to_event_stream.assert_called_once_with(["snapshot-events"]) diff --git a/web/app/components/develop/template/template_advanced_chat.en.mdx b/web/app/components/develop/template/template_advanced_chat.en.mdx index bdfe7a41c1..85cc82fc57 100644 --- a/web/app/components/develop/template/template_advanced_chat.en.mdx +++ b/web/app/components/develop/template/template_advanced_chat.en.mdx @@ -191,6 +191,24 @@ Chat applications support session persistence, allowing previous chat history to - `total_price` (decimal) optional Total cost - `currency` (string) optional e.g. `USD` / `RMB` - `created_at` (timestamp) timestamp of start, e.g., 1705395332 + - `event: human_input_required` Workflow paused and requires Human-in-the-Loop input + - `task_id` (string) Task ID, used for request tracking + - `workflow_run_id` (string) Unique ID of workflow execution + - `event` (string) fixed to `human_input_required` + - `data` (object) detail + - `form_id` (string) Human input form ID + - `node_id` (string) Human input node ID + - `node_title` (string) Human input node title + - `form_content` (string) Rendered form content + - `inputs` (array[object]) Input field definitions + - `actions` (array[object]) User action buttons + - `id` (string) Action ID + - `title` (string) Button text + - `button_style` (string) Button style + - `display_in_ui` (bool) Whether this form should be shown in UI + - `form_token` (string) Token used by `/form/human_input/:form_token` APIs + - `resolved_default_values` (object) Runtime-resolved default values + - `expiration_time` (timestamp) Form expiration time (Unix seconds) - `event: workflow_finished` workflow execution ends, success or failure in different states in the same event - `task_id` (string) Task ID, used for request tracking and the below Stop Generate API - `workflow_run_id` (string) Unique ID of workflow execution @@ -578,6 +596,166 @@ Chat applications support session persistence, allowing previous chat history to --- + + + + Retrieve a pending Human-in-the-Loop form by `form_token`. + + Use this endpoint when streaming returns `human_input_required` with a `form_token`. + + ### Path + - `form_token` (string) Required, token returned by the pause event. + + ### Response + - `form_content` (string) Rendered form content (markdown/plain text) + - `inputs` (array[object]) Form input definitions + - `resolved_default_values` (object) Default values resolved to strings + - `user_actions` (array[object]) Action buttons + - `expiration_time` (timestamp) Form expiration time (Unix seconds) + + ### Errors + - 404, form not found or does not belong to current app + - 412, `human_input_form_submitted`, form already submitted + - 412, `human_input_form_expired`, form expired + + + + + + ```json {{ title: 'Response' }} + { + "form_content": "Please confirm the final answer: {{#$output.answer#}}", + "inputs": [ + { + "label": "Answer", + "type": "text-input", + "required": true, + "output_variable_name": "answer" + } + ], + "resolved_default_values": { + "answer": "Initial value" + }, + "user_actions": [ + { "id": "approve", "title": "Approve", "button_style": "primary" }, + { "id": "reject", "title": "Reject", "button_style": "warning" } + ], + "expiration_time": 1735689600 + } + ``` + + + + +--- + + + + + Submit a pending Human-in-the-Loop form. + + ### Path + - `form_token` (string) Required, token returned by the pause event. + + ### Request Body + - `inputs` (object) Required, key/value pairs for form fields. + - `action` (string) Required, selected action ID from `user_actions`. + - `user` (string) Required, end-user identifier. + + ### Response + Returns an empty object on success. + + ### Errors + - 400, `invalid_form_data`, submitted data does not match the form schema + - 404, form not found or does not belong to current app + - 412, `human_input_form_submitted`, form already submitted + - 412, `human_input_form_expired`, form expired + + + + + + ```json {{ title: 'Response' }} + {} + ``` + + + + +--- + + + + + Continue receiving workflow events after submitting a human input form. + + This endpoint returns `text/event-stream` and can be used to observe resumed execution until completion. + + ### Path + - `task_id` (string) Required, workflow run ID (`workflow_run_id`). + + ### Query + - `user` (string) Required, end-user identifier. + - `include_state_snapshot` (bool) Optional, set to `true` to replay from persisted state snapshot before continuing with live events. + + ### Response + Server-Sent Events stream (`text/event-stream`). + Typical events include `node_started`, `node_finished`, `human_input_form_filled`, `human_input_form_timeout`, and `workflow_finished`. + + + + + + ```streaming {{ title: 'Response' }} + data: {"event":"human_input_form_filled","task_id":"run-1","workflow_run_id":"run-1","data":{"node_id":"human_input_1","node_title":"Human Input","rendered_content":"Approved answer","action_id":"approve","action_text":"Approve"}} + data: {"event":"node_finished","task_id":"run-1","workflow_run_id":"run-1","data":{"id":"node-execution-id","node_id":"llm_1","node_type":"llm","title":"LLM","index":5,"status":"succeeded","created_at":1735689601}} + data: {"event":"workflow_finished","task_id":"run-1","workflow_run_id":"run-1","data":{"id":"run-1","workflow_id":"workflow-id","status":"succeeded","outputs":{"text":"Done"},"created_at":1735689590,"finished_at":1735689602,"elapsed_time":12.0,"total_tokens":1000,"total_steps":6}} + ``` + + + + +--- + + + + `form_token` から保留中の Human-in-the-Loop フォームを取得します。 + + ストリーミングイベントで `human_input_required`(`form_token` を含む)が返された際に使用します。 + + ### パス + - `form_token` (string) 必須、一時停止イベントで返されたフォームトークン + + ### 応答 + - `form_content` (string) レンダリング済みフォーム内容(markdown/plain text) + - `inputs` (array[object]) 入力項目定義 + - `resolved_default_values` (object) 解決済みデフォルト値(文字列) + - `user_actions` (array[object]) アクションボタン一覧 + - `expiration_time` (timestamp) フォーム有効期限(Unix 秒) + + ### エラー + - 404, フォームが存在しない、または現在のアプリに属していない + - 412, `human_input_form_submitted`, 既に送信済み + - 412, `human_input_form_expired`, 期限切れ + + + + + + ```json {{ title: '応答' }} + { + "form_content": "最終回答を確認してください: {{#$output.answer#}}", + "inputs": [ + { + "label": "回答", + "type": "text-input", + "required": true, + "output_variable_name": "answer" + } + ], + "resolved_default_values": { + "answer": "初期値" + }, + "user_actions": [ + { "id": "approve", "title": "承認", "button_style": "primary" }, + { "id": "reject", "title": "却下", "button_style": "warning" } + ], + "expiration_time": 1735689600 + } + ``` + + + + +--- + + + + + 保留中の Human-in-the-Loop フォームを送信します。 + + ### パス + - `form_token` (string) 必須、一時停止イベントで返されたフォームトークン + + ### リクエストボディ + - `inputs` (object) 必須、フォーム項目の key/value + - `action` (string) 必須、`user_actions` から選択したアクション ID + - `user` (string) 必須、エンドユーザー識別子 + + ### 応答 + 成功時は空オブジェクトを返します。 + + ### エラー + - 400, `invalid_form_data`, 送信データがフォームスキーマに一致しない + - 404, フォームが存在しない、または現在のアプリに属していない + - 412, `human_input_form_submitted`, 既に送信済み + - 412, `human_input_form_expired`, 期限切れ + + + + + + ```json {{ title: '応答' }} + {} + ``` + + + + +--- + + + + + Human Input フォーム送信後に、ワークフロー再開後のイベントを継続受信します。 + + このエンドポイントは `text/event-stream` を返し、完了までイベントを購読できます。 + + ### パス + - `task_id` (string) 必須、workflow 実行 ID(`workflow_run_id`) + + ### クエリ + - `user` (string) 必須、エンドユーザー識別子 + - `include_state_snapshot` (bool) 任意、`true` の場合は永続化済み状態スナップショットを先に再生してからリアルタイムイベントへ移行 + + ### 応答 + Server-Sent Events ストリーム(`text/event-stream`)。 + 主なイベントは `node_started`、`node_finished`、`human_input_form_filled`、`human_input_form_timeout`、`workflow_finished` です。 + + + + + + ```streaming {{ title: '応答' }} + data: {"event":"human_input_form_filled","task_id":"run-1","workflow_run_id":"run-1","data":{"node_id":"human_input_1","node_title":"Human Input","rendered_content":"承認済み回答","action_id":"approve","action_text":"承認"}} + data: {"event":"node_finished","task_id":"run-1","workflow_run_id":"run-1","data":{"id":"node-execution-id","node_id":"llm_1","node_type":"llm","title":"LLM","index":5,"status":"succeeded","created_at":1735689601}} + data: {"event":"workflow_finished","task_id":"run-1","workflow_run_id":"run-1","data":{"id":"run-1","workflow_id":"workflow-id","status":"succeeded","outputs":{"text":"Done"},"created_at":1735689590,"finished_at":1735689602,"elapsed_time":12.0,"total_tokens":1000,"total_steps":6}} + ``` + + + + +--- + --- + + + + 通过 `form_token` 获取待处理的 Human-in-the-Loop 表单。 + + 当流式事件返回 `human_input_required`(包含 `form_token`)时,可调用此接口拉取表单详情。 + + ### Path + - `form_token` (string) 必填,暂停事件返回的表单 token + + ### Response + - `form_content` (string) 已渲染的表单内容(markdown/plain text) + - `inputs` (array[object]) 表单输入项定义 + - `resolved_default_values` (object) 已解析的默认值(字符串) + - `user_actions` (array[object]) 操作按钮列表 + - `expiration_time` (timestamp) 表单过期时间(Unix 秒) + + ### Errors + - 404,表单不存在或不属于当前应用 + - 412,`human_input_form_submitted`,表单已被提交 + - 412,`human_input_form_expired`,表单已过期 + + + + + + ```json {{ title: 'Response' }} + { + "form_content": "请确认最终结果:{{#$output.answer#}}", + "inputs": [ + { + "label": "答案", + "type": "text-input", + "required": true, + "output_variable_name": "answer" + } + ], + "resolved_default_values": { + "answer": "初始值" + }, + "user_actions": [ + { "id": "approve", "title": "通过", "button_style": "primary" }, + { "id": "reject", "title": "拒绝", "button_style": "warning" } + ], + "expiration_time": 1735689600 + } + ``` + + + + +--- + + + + + 提交待处理的 Human-in-the-Loop 表单。 + + ### Path + - `form_token` (string) 必填,暂停事件返回的表单 token + + ### Request Body + - `inputs` (object) 必填,表单字段的 key/value + - `action` (string) 必填,从 `user_actions` 中选择的动作 ID + - `user` (string) 必填,终端用户标识 + + ### Response + 成功时返回空对象。 + + ### Errors + - 400,`invalid_form_data`,提交数据与表单 schema 不匹配 + - 404,表单不存在或不属于当前应用 + - 412,`human_input_form_submitted`,表单已被提交 + - 412,`human_input_form_expired`,表单已过期 + + + + + + ```json {{ title: 'Response' }} + {} + ``` + + + + +--- + + + + + 在提交人工输入表单后,继续订阅工作流后续执行事件。 + + 返回 `text/event-stream`,可持续接收直到工作流结束。 + + ### Path + - `task_id` (string) 必填,workflow 运行 ID(`workflow_run_id`) + + ### Query + - `user` (string) 必填,终端用户标识 + - `include_state_snapshot` (bool) 可选,设为 `true` 时会先回放持久化状态快照,再继续实时事件 + + ### Response + Server-Sent Events 流(`text/event-stream`)。 + 常见事件包括 `node_started`、`node_finished`、`human_input_form_filled`、`human_input_form_timeout`、`workflow_finished`。 + + + + + + ```streaming {{ title: 'Response' }} + data: {"event":"human_input_form_filled","task_id":"run-1","workflow_run_id":"run-1","data":{"node_id":"human_input_1","node_title":"Human Input","rendered_content":"已确认答案","action_id":"approve","action_text":"通过"}} + data: {"event":"node_finished","task_id":"run-1","workflow_run_id":"run-1","data":{"id":"node-execution-id","node_id":"llm_1","node_type":"llm","title":"LLM","index":5,"status":"succeeded","created_at":1735689601}} + data: {"event":"workflow_finished","task_id":"run-1","workflow_run_id":"run-1","data":{"id":"run-1","workflow_id":"workflow-id","status":"succeeded","outputs":{"text":"Done"},"created_at":1735689590,"finished_at":1735689602,"elapsed_time":12.0,"total_tokens":1000,"total_steps":6}} + ``` + + + + +--- + + + + Retrieve a pending Human-in-the-Loop form by `form_token`. + + Use this endpoint when a workflow pauses with `human_input_required` and returns a `form_token`. + + ### Path + - `form_token` (string) Required, token returned by the pause event. + + ### Response + - `form_content` (string) Rendered form content (markdown/plain text) + - `inputs` (array[object]) Form input definitions + - `resolved_default_values` (object) Default values resolved to strings + - `user_actions` (array[object]) Action buttons + - `expiration_time` (timestamp) Form expiration time (Unix seconds) + + ### Errors + - 404, form not found or does not belong to current app + - 412, `human_input_form_submitted`, form already submitted + - 412, `human_input_form_expired`, form expired + + + + + + ```json {{ title: 'Response' }} + { + "form_content": "Please confirm the final answer: {{#$output.answer#}}", + "inputs": [ + { + "label": "Answer", + "type": "text-input", + "required": true, + "output_variable_name": "answer" + } + ], + "resolved_default_values": { + "answer": "Initial value" + }, + "user_actions": [ + { "id": "approve", "title": "Approve", "button_style": "primary" }, + { "id": "reject", "title": "Reject", "button_style": "warning" } + ], + "expiration_time": 1735689600 + } + ``` + + + + +--- + + + + + Submit a pending Human-in-the-Loop form. + + ### Path + - `form_token` (string) Required, token returned by the pause event. + + ### Request Body + - `inputs` (object) Required, key/value pairs for form fields. + - `action` (string) Required, selected action ID from `user_actions`. + - `user` (string) Required, end-user identifier. + + ### Response + Returns an empty object on success. + + ### Errors + - 400, `invalid_form_data`, submitted data does not match the form schema + - 404, form not found or does not belong to current app + - 412, `human_input_form_submitted`, form already submitted + - 412, `human_input_form_expired`, form expired + + + + + + ```json {{ title: 'Response' }} + {} + ``` + + + + +--- + + + + + Continue receiving workflow events after submitting a human input form. + + This endpoint returns `text/event-stream` and can be used to observe the resumed run until completion. + + ### Path + - `task_id` (string) Required, workflow run ID (`workflow_run_id`). + + ### Query + - `user` (string) Required, end-user identifier. + - `include_state_snapshot` (bool) Optional, set to `true` to replay from persisted state snapshot before continuing with live events. + + ### Response + Server-Sent Events stream (`text/event-stream`). + Typical events include `node_started`, `node_finished`, `human_input_form_filled`, `human_input_form_timeout`, and `workflow_finished`. + + + + + + ```streaming {{ title: 'Response' }} + data: {"event":"human_input_form_filled","task_id":"run-1","workflow_run_id":"run-1","data":{"node_id":"human_input_1","node_title":"Human Input","rendered_content":"Approved answer","action_id":"approve","action_text":"Approve"}} + data: {"event":"node_finished","task_id":"run-1","workflow_run_id":"run-1","data":{"id":"node-execution-id","node_id":"llm_1","node_type":"llm","title":"LLM","index":5,"status":"succeeded","created_at":1735689601}} + data: {"event":"workflow_finished","task_id":"run-1","workflow_run_id":"run-1","data":{"id":"run-1","workflow_id":"workflow-id","status":"succeeded","outputs":{"text":"Done"},"created_at":1735689590,"finished_at":1735689602,"elapsed_time":12.0,"total_tokens":1000,"total_steps":6}} + ``` + + + + +--- + + + + `form_token` から保留中の Human-in-the-Loop フォームを取得します。 + + Workflow が `human_input_required`(`form_token` を含む)で一時停止した際に使用します。 + + ### パス + - `form_token` (string) 必須、一時停止イベントで返されたフォームトークン + + ### 応答 + - `form_content` (string) レンダリング済みフォーム内容(markdown/plain text) + - `inputs` (array[object]) 入力項目定義 + - `resolved_default_values` (object) 解決済みデフォルト値(文字列) + - `user_actions` (array[object]) アクションボタン一覧 + - `expiration_time` (timestamp) フォーム有効期限(Unix 秒) + + ### エラー + - 404, フォームが存在しない、または現在のアプリに属していない + - 412, `human_input_form_submitted`, 既に送信済み + - 412, `human_input_form_expired`, 期限切れ + + + + + + ```json {{ title: '応答' }} + { + "form_content": "最終回答を確認してください: {{#$output.answer#}}", + "inputs": [ + { + "label": "回答", + "type": "text-input", + "required": true, + "output_variable_name": "answer" + } + ], + "resolved_default_values": { + "answer": "初期値" + }, + "user_actions": [ + { "id": "approve", "title": "承認", "button_style": "primary" }, + { "id": "reject", "title": "却下", "button_style": "warning" } + ], + "expiration_time": 1735689600 + } + ``` + + + + +--- + + + + + 保留中の Human-in-the-Loop フォームを送信します。 + + ### パス + - `form_token` (string) 必須、一時停止イベントで返されたフォームトークン + + ### リクエストボディ + - `inputs` (object) 必須、フォーム項目の key/value + - `action` (string) 必須、`user_actions` から選択したアクション ID + - `user` (string) 必須、エンドユーザー識別子 + + ### 応答 + 成功時は空オブジェクトを返します。 + + ### エラー + - 400, `invalid_form_data`, 送信データがフォームスキーマに一致しない + - 404, フォームが存在しない、または現在のアプリに属していない + - 412, `human_input_form_submitted`, 既に送信済み + - 412, `human_input_form_expired`, 期限切れ + + + + + + ```json {{ title: '応答' }} + {} + ``` + + + + +--- + + + + + Human Input フォーム送信後に、ワークフロー再開後のイベントを継続受信します。 + + このエンドポイントは `text/event-stream` を返し、完了までイベントを購読できます。 + + ### パス + - `task_id` (string) 必須、workflow 実行 ID(`workflow_run_id`) + + ### クエリ + - `user` (string) 必須、エンドユーザー識別子 + - `include_state_snapshot` (bool) 任意、`true` の場合は永続化済み状態スナップショットを先に再生してからリアルタイムイベントへ移行 + + ### 応答 + Server-Sent Events ストリーム(`text/event-stream`)。 + 主なイベントは `node_started`、`node_finished`、`human_input_form_filled`、`human_input_form_timeout`、`workflow_finished` です。 + + + + + + ```streaming {{ title: '応答' }} + data: {"event":"human_input_form_filled","task_id":"run-1","workflow_run_id":"run-1","data":{"node_id":"human_input_1","node_title":"Human Input","rendered_content":"承認済み回答","action_id":"approve","action_text":"承認"}} + data: {"event":"node_finished","task_id":"run-1","workflow_run_id":"run-1","data":{"id":"node-execution-id","node_id":"llm_1","node_type":"llm","title":"LLM","index":5,"status":"succeeded","created_at":1735689601}} + data: {"event":"workflow_finished","task_id":"run-1","workflow_run_id":"run-1","data":{"id":"run-1","workflow_id":"workflow-id","status":"succeeded","outputs":{"text":"Done"},"created_at":1735689590,"finished_at":1735689602,"elapsed_time":12.0,"total_tokens":1000,"total_steps":6}} + ``` + + + + +--- + + + + 通过 `form_token` 获取待处理的 Human-in-the-Loop 表单。 + + 当 Workflow 在流式事件中返回 `human_input_required`(包含 `form_token`)时,可调用此接口拉取表单详情。 + + ### Path + - `form_token` (string) 必填,暂停事件返回的表单 token + + ### Response + - `form_content` (string) 已渲染的表单内容(markdown/plain text) + - `inputs` (array[object]) 表单输入项定义 + - `resolved_default_values` (object) 已解析的默认值(字符串) + - `user_actions` (array[object]) 操作按钮列表 + - `expiration_time` (timestamp) 表单过期时间(Unix 秒) + + ### Errors + - 404,表单不存在或不属于当前应用 + - 412,`human_input_form_submitted`,表单已被提交 + - 412,`human_input_form_expired`,表单已过期 + + + + + + ```json {{ title: 'Response' }} + { + "form_content": "请确认最终结果:{{#$output.answer#}}", + "inputs": [ + { + "label": "答案", + "type": "text-input", + "required": true, + "output_variable_name": "answer" + } + ], + "resolved_default_values": { + "answer": "初始值" + }, + "user_actions": [ + { "id": "approve", "title": "通过", "button_style": "primary" }, + { "id": "reject", "title": "拒绝", "button_style": "warning" } + ], + "expiration_time": 1735689600 + } + ``` + + + + +--- + + + + + 提交待处理的 Human-in-the-Loop 表单。 + + ### Path + - `form_token` (string) 必填,暂停事件返回的表单 token + + ### Request Body + - `inputs` (object) 必填,表单字段的 key/value + - `action` (string) 必填,从 `user_actions` 中选择的动作 ID + - `user` (string) 必填,终端用户标识 + + ### Response + 成功时返回空对象。 + + ### Errors + - 400,`invalid_form_data`,提交数据与表单 schema 不匹配 + - 404,表单不存在或不属于当前应用 + - 412,`human_input_form_submitted`,表单已被提交 + - 412,`human_input_form_expired`,表单已过期 + + + + + + ```json {{ title: 'Response' }} + {} + ``` + + + + +--- + + + + + 在提交人工输入表单后,继续订阅工作流后续执行事件。 + + 返回 `text/event-stream`,可持续接收直到工作流结束。 + + ### Path + - `task_id` (string) 必填,workflow 运行 ID(`workflow_run_id`) + + ### Query + - `user` (string) 必填,终端用户标识 + - `include_state_snapshot` (bool) 可选,设为 `true` 时会先回放持久化状态快照,再继续实时事件 + + ### Response + Server-Sent Events 流(`text/event-stream`)。 + 常见事件包括 `node_started`、`node_finished`、`human_input_form_filled`、`human_input_form_timeout`、`workflow_finished`。 + + + + + + ```streaming {{ title: 'Response' }} + data: {"event":"human_input_form_filled","task_id":"run-1","workflow_run_id":"run-1","data":{"node_id":"human_input_1","node_title":"Human Input","rendered_content":"已确认答案","action_id":"approve","action_text":"通过"}} + data: {"event":"node_finished","task_id":"run-1","workflow_run_id":"run-1","data":{"id":"node-execution-id","node_id":"llm_1","node_type":"llm","title":"LLM","index":5,"status":"succeeded","created_at":1735689601}} + data: {"event":"workflow_finished","task_id":"run-1","workflow_run_id":"run-1","data":{"id":"run-1","workflow_id":"workflow-id","status":"succeeded","outputs":{"text":"Done"},"created_at":1735689590,"finished_at":1735689602,"elapsed_time":12.0,"total_tokens":1000,"total_steps":6}} + ``` + + + + +--- +