mirror of
https://github.com/langgenius/dify.git
synced 2026-06-07 16:23:44 +08:00
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:
parent
44725dde74
commit
3c98f96ae8
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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",
|
||||
|
||||
212
api/controllers/web/human_input_file_upload.py
Normal file
212
api/controllers/web/human_input_file_upload.py
Normal 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
|
||||
@ -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):
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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():
|
||||
|
||||
@ -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):
|
||||
"""
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
*,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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")
|
||||
@ -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",
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
244
api/services/human_input_file_upload_service.py
Normal file
244
api/services/human_input_file_upload_service.py
Normal 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)
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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"]
|
||||
@ -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()
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
|
||||
@ -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"},
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
@ -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()
|
||||
@ -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",
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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"),
|
||||
[
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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"])
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -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():
|
||||
|
||||
@ -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"
|
||||
@ -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)
|
||||
|
||||
@ -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(
|
||||
{
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"}
|
||||
@ -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)
|
||||
@ -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()
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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()
|
||||
|
||||
184
docs/design/human-in-the-loop/hitl-form-file-upload-design.md
Normal file
184
docs/design/human-in-the-loop/hitl-form-file-upload-design.md
Normal 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.
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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
|
||||
*
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
/**
|
||||
|
||||
@ -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 = ({
|
||||
|
||||
@ -11,6 +11,7 @@ const mockUploadRemoteFileInfo = vi.fn()
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useParams: () => ({}),
|
||||
usePathname: () => '/',
|
||||
}))
|
||||
|
||||
vi.mock('@/service/common', () => ({
|
||||
|
||||
357
web/app/(humanInputLayout)/form/[token]/__tests__/form.spec.tsx
Normal file
357
web/app/(humanInputLayout)/form/[token]/__tests__/form.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
51
web/app/(humanInputLayout)/form/[token]/form-status-card.tsx
Normal file
51
web/app/(humanInputLayout)/form/[token]/form-status-card.tsx
Normal 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
|
||||
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
@ -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>
|
||||
)
|
||||
|
||||
33
web/app/(humanInputLayout)/form/[token]/use-form-submit.ts
Normal file
33
web/app/(humanInputLayout)/form/[token]/use-form-submit.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@ -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)')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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) => (
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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 })
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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' },
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
@ -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)}
|
||||
>
|
||||
|
||||
@ -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)
|
||||
@ -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
Loading…
Reference in New Issue
Block a user