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
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user