mirror of
https://github.com/langgenius/dify.git
synced 2026-09-24 23:22:26 +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:
co-authored by
JzoNg
GPT 5.4
autofix-ci[bot]
-LAN-
parent
44725dde74
commit
3c98f96ae8
@@ -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",
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user