mirror of
https://github.com/langgenius/dify.git
synced 2026-04-21 11:17:38 +08:00
feat: Human Input Node (#32060)
The frontend and backend implementation for the human input node. Co-authored-by: twwu <twwu@dify.ai> Co-authored-by: JzoNg <jzongcode@gmail.com> Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com> Co-authored-by: zhsama <torvalds@linux.do>
This commit is contained in:
@ -1,4 +1,5 @@
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Callable, Generator, Mapping, Sequence
|
||||
@ -11,21 +12,34 @@ from configs import dify_config
|
||||
from core.app.app_config.entities import VariableEntityType
|
||||
from core.app.apps.advanced_chat.app_config_manager import AdvancedChatAppConfigManager
|
||||
from core.app.apps.workflow.app_config_manager import WorkflowAppConfigManager
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from core.file import File
|
||||
from core.repositories import DifyCoreRepositoryFactory
|
||||
from core.repositories.human_input_repository import HumanInputFormRepositoryImpl
|
||||
from core.variables import VariableBase
|
||||
from core.variables.variables import Variable
|
||||
from core.workflow.entities import WorkflowNodeExecution
|
||||
from core.workflow.entities import GraphInitParams, WorkflowNodeExecution
|
||||
from core.workflow.entities.pause_reason import HumanInputRequired
|
||||
from core.workflow.enums import ErrorStrategy, WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus
|
||||
from core.workflow.errors import WorkflowNodeRunFailedError
|
||||
from core.workflow.graph_events import GraphNodeEventBase, NodeRunFailedEvent, NodeRunSucceededEvent
|
||||
from core.workflow.node_events import NodeRunResult
|
||||
from core.workflow.nodes import NodeType
|
||||
from core.workflow.nodes.base.node import Node
|
||||
from core.workflow.nodes.human_input.entities import (
|
||||
DeliveryChannelConfig,
|
||||
HumanInputNodeData,
|
||||
apply_debug_email_recipient,
|
||||
validate_human_input_submission,
|
||||
)
|
||||
from core.workflow.nodes.human_input.enums import HumanInputFormKind
|
||||
from core.workflow.nodes.human_input.human_input_node import HumanInputNode
|
||||
from core.workflow.nodes.node_mapping import LATEST_VERSION, NODE_TYPE_CLASSES_MAPPING
|
||||
from core.workflow.nodes.start.entities import StartNodeData
|
||||
from core.workflow.runtime import VariablePool
|
||||
from core.workflow.repositories.human_input_form_repository import FormCreateParams
|
||||
from core.workflow.runtime import GraphRuntimeState, VariablePool
|
||||
from core.workflow.system_variable import SystemVariable
|
||||
from core.workflow.variable_loader import load_into_variable_pool
|
||||
from core.workflow.workflow_entry import WorkflowEntry
|
||||
from enums.cloud_plan import CloudPlan
|
||||
from events.app_event import app_draft_workflow_was_synced, app_published_workflow_was_updated
|
||||
@ -34,6 +48,8 @@ from extensions.ext_storage import storage
|
||||
from factories.file_factory import build_from_mapping, build_from_mappings
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from models import Account
|
||||
from models.enums import UserFrom
|
||||
from models.human_input import HumanInputFormRecipient, RecipientType
|
||||
from models.model import App, AppMode
|
||||
from models.tools import WorkflowToolProvider
|
||||
from models.workflow import Workflow, WorkflowNodeExecutionModel, WorkflowNodeExecutionTriggeredFrom, WorkflowType
|
||||
@ -44,6 +60,13 @@ from services.errors.app import IsDraftWorkflowError, TriggerNodeLimitExceededEr
|
||||
from services.workflow.workflow_converter import WorkflowConverter
|
||||
|
||||
from .errors.workflow_service import DraftWorkflowDeletionError, WorkflowInUseError
|
||||
from .human_input_delivery_test_service import (
|
||||
DeliveryTestContext,
|
||||
DeliveryTestEmailRecipient,
|
||||
DeliveryTestError,
|
||||
DeliveryTestUnsupportedError,
|
||||
HumanInputDeliveryTestService,
|
||||
)
|
||||
from .workflow_draft_variable_service import DraftVariableSaver, DraftVarLoader, WorkflowDraftVariableService
|
||||
|
||||
|
||||
@ -744,6 +767,344 @@ class WorkflowService:
|
||||
|
||||
return workflow_node_execution
|
||||
|
||||
def get_human_input_form_preview(
|
||||
self,
|
||||
*,
|
||||
app_model: App,
|
||||
account: Account,
|
||||
node_id: str,
|
||||
inputs: Mapping[str, Any] | None = None,
|
||||
) -> Mapping[str, Any]:
|
||||
"""
|
||||
Build a human input form preview for a draft workflow.
|
||||
|
||||
Args:
|
||||
app_model: Target application model.
|
||||
account: Current account.
|
||||
node_id: Human input node ID.
|
||||
inputs: Values used to fill missing upstream variables referenced in form_content.
|
||||
"""
|
||||
draft_workflow = self.get_draft_workflow(app_model=app_model)
|
||||
if not draft_workflow:
|
||||
raise ValueError("Workflow not initialized")
|
||||
|
||||
node_config = draft_workflow.get_node_config_by_id(node_id)
|
||||
node_type = Workflow.get_node_type_from_node_config(node_config)
|
||||
if node_type is not NodeType.HUMAN_INPUT:
|
||||
raise ValueError("Node type must be human-input.")
|
||||
|
||||
# inputs: values used to fill missing upstream variables referenced in form_content.
|
||||
variable_pool = self._build_human_input_variable_pool(
|
||||
app_model=app_model,
|
||||
workflow=draft_workflow,
|
||||
node_config=node_config,
|
||||
manual_inputs=inputs or {},
|
||||
)
|
||||
node = self._build_human_input_node(
|
||||
workflow=draft_workflow,
|
||||
account=account,
|
||||
node_config=node_config,
|
||||
variable_pool=variable_pool,
|
||||
)
|
||||
|
||||
rendered_content = node.render_form_content_before_submission()
|
||||
resolved_default_values = node.resolve_default_values()
|
||||
node_data = node.node_data
|
||||
human_input_required = HumanInputRequired(
|
||||
form_id=node_id,
|
||||
form_content=rendered_content,
|
||||
inputs=node_data.inputs,
|
||||
actions=node_data.user_actions,
|
||||
node_id=node_id,
|
||||
node_title=node.title,
|
||||
resolved_default_values=resolved_default_values,
|
||||
form_token=None,
|
||||
)
|
||||
return human_input_required.model_dump(mode="json")
|
||||
|
||||
def submit_human_input_form_preview(
|
||||
self,
|
||||
*,
|
||||
app_model: App,
|
||||
account: Account,
|
||||
node_id: str,
|
||||
form_inputs: Mapping[str, Any],
|
||||
inputs: Mapping[str, Any] | None = None,
|
||||
action: str,
|
||||
) -> Mapping[str, Any]:
|
||||
"""
|
||||
Submit a human input form preview for a draft workflow.
|
||||
|
||||
Args:
|
||||
app_model: Target application model.
|
||||
account: Current account.
|
||||
node_id: Human input node ID.
|
||||
form_inputs: Values the user provides for the form's own fields.
|
||||
inputs: Values used to fill missing upstream variables referenced in form_content.
|
||||
action: Selected action ID.
|
||||
"""
|
||||
draft_workflow = self.get_draft_workflow(app_model=app_model)
|
||||
if not draft_workflow:
|
||||
raise ValueError("Workflow not initialized")
|
||||
|
||||
node_config = draft_workflow.get_node_config_by_id(node_id)
|
||||
node_type = Workflow.get_node_type_from_node_config(node_config)
|
||||
if node_type is not NodeType.HUMAN_INPUT:
|
||||
raise ValueError("Node type must be human-input.")
|
||||
|
||||
# inputs: values used to fill missing upstream variables referenced in form_content.
|
||||
# form_inputs: values the user provides for the form's own fields.
|
||||
variable_pool = self._build_human_input_variable_pool(
|
||||
app_model=app_model,
|
||||
workflow=draft_workflow,
|
||||
node_config=node_config,
|
||||
manual_inputs=inputs or {},
|
||||
)
|
||||
node = self._build_human_input_node(
|
||||
workflow=draft_workflow,
|
||||
account=account,
|
||||
node_config=node_config,
|
||||
variable_pool=variable_pool,
|
||||
)
|
||||
node_data = node.node_data
|
||||
|
||||
validate_human_input_submission(
|
||||
inputs=node_data.inputs,
|
||||
user_actions=node_data.user_actions,
|
||||
selected_action_id=action,
|
||||
form_data=form_inputs,
|
||||
)
|
||||
|
||||
rendered_content = node.render_form_content_before_submission()
|
||||
outputs: dict[str, Any] = dict(form_inputs)
|
||||
outputs["__action_id"] = action
|
||||
outputs["__rendered_content"] = node.render_form_content_with_outputs(
|
||||
rendered_content, outputs, node_data.outputs_field_names()
|
||||
)
|
||||
|
||||
enclosing_node_type_and_id = draft_workflow.get_enclosing_node_type_and_id(node_config)
|
||||
enclosing_node_id = enclosing_node_type_and_id[1] if enclosing_node_type_and_id else None
|
||||
with Session(bind=db.engine) as session, session.begin():
|
||||
draft_var_saver = DraftVariableSaver(
|
||||
session=session,
|
||||
app_id=app_model.id,
|
||||
node_id=node_id,
|
||||
node_type=NodeType.HUMAN_INPUT,
|
||||
node_execution_id=str(uuid.uuid4()),
|
||||
user=account,
|
||||
enclosing_node_id=enclosing_node_id,
|
||||
)
|
||||
draft_var_saver.save(outputs=outputs, process_data={})
|
||||
session.commit()
|
||||
|
||||
return outputs
|
||||
|
||||
def test_human_input_delivery(
|
||||
self,
|
||||
*,
|
||||
app_model: App,
|
||||
account: Account,
|
||||
node_id: str,
|
||||
delivery_method_id: str,
|
||||
inputs: Mapping[str, Any] | None = None,
|
||||
) -> None:
|
||||
draft_workflow = self.get_draft_workflow(app_model=app_model)
|
||||
if not draft_workflow:
|
||||
raise ValueError("Workflow not initialized")
|
||||
|
||||
node_config = draft_workflow.get_node_config_by_id(node_id)
|
||||
node_type = Workflow.get_node_type_from_node_config(node_config)
|
||||
if node_type is not NodeType.HUMAN_INPUT:
|
||||
raise ValueError("Node type must be human-input.")
|
||||
|
||||
node_data = HumanInputNodeData.model_validate(node_config.get("data", {}))
|
||||
delivery_method = self._resolve_human_input_delivery_method(
|
||||
node_data=node_data,
|
||||
delivery_method_id=delivery_method_id,
|
||||
)
|
||||
if delivery_method is None:
|
||||
raise ValueError("Delivery method not found.")
|
||||
delivery_method = apply_debug_email_recipient(
|
||||
delivery_method,
|
||||
enabled=True,
|
||||
user_id=account.id or "",
|
||||
)
|
||||
|
||||
variable_pool = self._build_human_input_variable_pool(
|
||||
app_model=app_model,
|
||||
workflow=draft_workflow,
|
||||
node_config=node_config,
|
||||
manual_inputs=inputs or {},
|
||||
)
|
||||
node = self._build_human_input_node(
|
||||
workflow=draft_workflow,
|
||||
account=account,
|
||||
node_config=node_config,
|
||||
variable_pool=variable_pool,
|
||||
)
|
||||
rendered_content = node.render_form_content_before_submission()
|
||||
resolved_default_values = node.resolve_default_values()
|
||||
form_id, recipients = self._create_human_input_delivery_test_form(
|
||||
app_model=app_model,
|
||||
node_id=node_id,
|
||||
node_data=node_data,
|
||||
delivery_method=delivery_method,
|
||||
rendered_content=rendered_content,
|
||||
resolved_default_values=resolved_default_values,
|
||||
)
|
||||
test_service = HumanInputDeliveryTestService()
|
||||
context = DeliveryTestContext(
|
||||
tenant_id=app_model.tenant_id,
|
||||
app_id=app_model.id,
|
||||
node_id=node_id,
|
||||
node_title=node_data.title,
|
||||
rendered_content=rendered_content,
|
||||
template_vars={"form_id": form_id},
|
||||
recipients=recipients,
|
||||
variable_pool=variable_pool,
|
||||
)
|
||||
try:
|
||||
test_service.send_test(context=context, method=delivery_method)
|
||||
except DeliveryTestUnsupportedError as exc:
|
||||
raise ValueError("Delivery method does not support test send.") from exc
|
||||
except DeliveryTestError as exc:
|
||||
raise ValueError(str(exc)) from exc
|
||||
|
||||
@staticmethod
|
||||
def _resolve_human_input_delivery_method(
|
||||
*,
|
||||
node_data: HumanInputNodeData,
|
||||
delivery_method_id: str,
|
||||
) -> DeliveryChannelConfig | None:
|
||||
for method in node_data.delivery_methods:
|
||||
if str(method.id) == delivery_method_id:
|
||||
return method
|
||||
return None
|
||||
|
||||
def _create_human_input_delivery_test_form(
|
||||
self,
|
||||
*,
|
||||
app_model: App,
|
||||
node_id: str,
|
||||
node_data: HumanInputNodeData,
|
||||
delivery_method: DeliveryChannelConfig,
|
||||
rendered_content: str,
|
||||
resolved_default_values: Mapping[str, Any],
|
||||
) -> tuple[str, list[DeliveryTestEmailRecipient]]:
|
||||
repo = HumanInputFormRepositoryImpl(session_factory=db.engine, tenant_id=app_model.tenant_id)
|
||||
params = FormCreateParams(
|
||||
app_id=app_model.id,
|
||||
workflow_execution_id=None,
|
||||
node_id=node_id,
|
||||
form_config=node_data,
|
||||
rendered_content=rendered_content,
|
||||
delivery_methods=[delivery_method],
|
||||
display_in_ui=False,
|
||||
resolved_default_values=resolved_default_values,
|
||||
form_kind=HumanInputFormKind.DELIVERY_TEST,
|
||||
)
|
||||
form_entity = repo.create_form(params)
|
||||
return form_entity.id, self._load_email_recipients(form_entity.id)
|
||||
|
||||
@staticmethod
|
||||
def _load_email_recipients(form_id: str) -> list[DeliveryTestEmailRecipient]:
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
with Session(bind=db.engine) as session:
|
||||
recipients = session.scalars(
|
||||
select(HumanInputFormRecipient).where(HumanInputFormRecipient.form_id == form_id)
|
||||
).all()
|
||||
recipients_data: list[DeliveryTestEmailRecipient] = []
|
||||
for recipient in recipients:
|
||||
if recipient.recipient_type not in {RecipientType.EMAIL_MEMBER, RecipientType.EMAIL_EXTERNAL}:
|
||||
continue
|
||||
if not recipient.access_token:
|
||||
continue
|
||||
try:
|
||||
payload = json.loads(recipient.recipient_payload)
|
||||
except Exception:
|
||||
logger.exception("Failed to parse human input recipient payload for delivery test.")
|
||||
continue
|
||||
email = payload.get("email")
|
||||
if email:
|
||||
recipients_data.append(DeliveryTestEmailRecipient(email=email, form_token=recipient.access_token))
|
||||
return recipients_data
|
||||
|
||||
def _build_human_input_node(
|
||||
self,
|
||||
*,
|
||||
workflow: Workflow,
|
||||
account: Account,
|
||||
node_config: Mapping[str, Any],
|
||||
variable_pool: VariablePool,
|
||||
) -> HumanInputNode:
|
||||
graph_init_params = GraphInitParams(
|
||||
tenant_id=workflow.tenant_id,
|
||||
app_id=workflow.app_id,
|
||||
workflow_id=workflow.id,
|
||||
graph_config=workflow.graph_dict,
|
||||
user_id=account.id,
|
||||
user_from=UserFrom.ACCOUNT.value,
|
||||
invoke_from=InvokeFrom.DEBUGGER.value,
|
||||
call_depth=0,
|
||||
)
|
||||
graph_runtime_state = GraphRuntimeState(
|
||||
variable_pool=variable_pool,
|
||||
start_at=time.perf_counter(),
|
||||
)
|
||||
node = HumanInputNode(
|
||||
id=node_config.get("id", str(uuid.uuid4())),
|
||||
config=node_config,
|
||||
graph_init_params=graph_init_params,
|
||||
graph_runtime_state=graph_runtime_state,
|
||||
)
|
||||
return node
|
||||
|
||||
def _build_human_input_variable_pool(
|
||||
self,
|
||||
*,
|
||||
app_model: App,
|
||||
workflow: Workflow,
|
||||
node_config: Mapping[str, Any],
|
||||
manual_inputs: Mapping[str, Any],
|
||||
) -> VariablePool:
|
||||
with Session(bind=db.engine, expire_on_commit=False) as session, session.begin():
|
||||
draft_var_srv = WorkflowDraftVariableService(session)
|
||||
draft_var_srv.prefill_conversation_variable_default_values(workflow)
|
||||
|
||||
variable_pool = VariablePool(
|
||||
system_variables=SystemVariable.default(),
|
||||
user_inputs={},
|
||||
environment_variables=workflow.environment_variables,
|
||||
conversation_variables=[],
|
||||
)
|
||||
|
||||
variable_loader = DraftVarLoader(
|
||||
engine=db.engine,
|
||||
app_id=app_model.id,
|
||||
tenant_id=app_model.tenant_id,
|
||||
)
|
||||
variable_mapping = HumanInputNode.extract_variable_selector_to_variable_mapping(
|
||||
graph_config=workflow.graph_dict,
|
||||
config=node_config,
|
||||
)
|
||||
normalized_user_inputs: dict[str, Any] = dict(manual_inputs)
|
||||
|
||||
load_into_variable_pool(
|
||||
variable_loader=variable_loader,
|
||||
variable_pool=variable_pool,
|
||||
variable_mapping=variable_mapping,
|
||||
user_inputs=normalized_user_inputs,
|
||||
)
|
||||
WorkflowEntry.mapping_user_inputs_to_variable_pool(
|
||||
variable_mapping=variable_mapping,
|
||||
user_inputs=normalized_user_inputs,
|
||||
variable_pool=variable_pool,
|
||||
tenant_id=app_model.tenant_id,
|
||||
)
|
||||
|
||||
return variable_pool
|
||||
|
||||
def run_free_workflow_node(
|
||||
self, node_data: dict, tenant_id: str, user_id: str, node_id: str, user_inputs: dict[str, Any]
|
||||
) -> WorkflowNodeExecution:
|
||||
@ -945,6 +1306,13 @@ class WorkflowService:
|
||||
if any(nt.is_trigger_node for nt in node_types):
|
||||
raise ValueError("Start node and trigger nodes cannot coexist in the same workflow")
|
||||
|
||||
for node in node_configs:
|
||||
node_data = node.get("data", {})
|
||||
node_type = node_data.get("type")
|
||||
|
||||
if node_type == NodeType.HUMAN_INPUT:
|
||||
self._validate_human_input_node_data(node_data)
|
||||
|
||||
def validate_features_structure(self, app_model: App, features: dict):
|
||||
if app_model.mode == AppMode.ADVANCED_CHAT:
|
||||
return AdvancedChatAppConfigManager.config_validate(
|
||||
@ -957,6 +1325,23 @@ class WorkflowService:
|
||||
else:
|
||||
raise ValueError(f"Invalid app mode: {app_model.mode}")
|
||||
|
||||
def _validate_human_input_node_data(self, node_data: dict) -> None:
|
||||
"""
|
||||
Validate HumanInput node data format.
|
||||
|
||||
Args:
|
||||
node_data: The node data dictionary
|
||||
|
||||
Raises:
|
||||
ValueError: If the node data format is invalid
|
||||
"""
|
||||
from core.workflow.nodes.human_input.entities import HumanInputNodeData
|
||||
|
||||
try:
|
||||
HumanInputNodeData.model_validate(node_data)
|
||||
except Exception as e:
|
||||
raise ValueError(f"Invalid HumanInput node data: {str(e)}")
|
||||
|
||||
def update_workflow(
|
||||
self, *, session: Session, workflow_id: str, tenant_id: str, account_id: str, data: dict
|
||||
) -> Workflow | None:
|
||||
|
||||
Reference in New Issue
Block a user