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:
QuantumGhost
2026-02-09 14:57:23 +08:00
committed by GitHub
parent 56e3a55023
commit a1fc280102
474 changed files with 32667 additions and 2050 deletions

View File

@@ -34,6 +34,8 @@ from .enums import (
WorkflowRunTriggeredFrom,
WorkflowTriggerStatus,
)
from .execution_extra_content import ExecutionExtraContent, HumanInputContent
from .human_input import HumanInputForm
from .model import (
AccountTrialAppRecord,
ApiRequest,
@@ -155,9 +157,12 @@ __all__ = [
"DocumentSegment",
"Embedding",
"EndUser",
"ExecutionExtraContent",
"ExporleBanner",
"ExternalKnowledgeApis",
"ExternalKnowledgeBindings",
"HumanInputContent",
"HumanInputForm",
"IconType",
"InstalledApp",
"InvitationCode",

View File

@@ -1,4 +1,5 @@
from datetime import datetime
from uuid import uuid4
from sqlalchemy import DateTime, func
from sqlalchemy.orm import DeclarativeBase, Mapped, MappedAsDataclass, mapped_column
@@ -41,7 +42,7 @@ class DefaultFieldsMixin:
)
updated_at: Mapped[datetime] = mapped_column(
__name_pos=DateTime,
DateTime,
nullable=False,
default=naive_utc_now,
server_default=func.current_timestamp(),
@@ -50,3 +51,16 @@ class DefaultFieldsMixin:
def __repr__(self) -> str:
return f"<{self.__class__.__name__}(id={self.id})>"
def gen_uuidv4_string() -> str:
"""gen_uuidv4_string generate a UUIDv4 string.
NOTE: This function exists only for historical reasons. New models should use uuidv7 for primary key generation.
"""
return str(uuid4())
def gen_uuidv7_string() -> str:
"""gen_uuidv4_string generate a UUIDv4 string."""
return str(uuidv7())

View File

@@ -36,6 +36,7 @@ class MessageStatus(StrEnum):
"""
NORMAL = "normal"
PAUSED = "paused"
ERROR = "error"

View File

@@ -0,0 +1,78 @@
from enum import StrEnum, auto
from typing import TYPE_CHECKING
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .base import Base, DefaultFieldsMixin
from .types import EnumText, StringUUID
if TYPE_CHECKING:
from .human_input import HumanInputForm
class ExecutionContentType(StrEnum):
HUMAN_INPUT = auto()
class ExecutionExtraContent(DefaultFieldsMixin, Base):
"""ExecutionExtraContent stores extra contents produced during workflow / chatflow execution."""
# The `ExecutionExtraContent` uses single table inheritance to model different
# kinds of contents produced during message generation.
#
# See: https://docs.sqlalchemy.org/en/20/orm/inheritance.html#single-table-inheritance
__tablename__ = "execution_extra_contents"
__mapper_args__ = {
"polymorphic_abstract": True,
"polymorphic_on": "type",
"with_polymorphic": "*",
}
# type records the type of the content. It serves as the `discriminator` for the
# single table inheritance.
type: Mapped[ExecutionContentType] = mapped_column(
EnumText(ExecutionContentType, length=30),
nullable=False,
)
# `workflow_run_id` records the workflow execution which generates this content, correspond to
# `WorkflowRun.id`.
workflow_run_id: Mapped[str] = mapped_column(StringUUID, nullable=False, index=True)
# `message_id` records the messages generated by the execution associated with this `ExecutionExtraContent`.
# It references to `Message.id`.
#
# For workflow execution, this field is `None`.
#
# For chatflow execution, `message_id`` is not None, and the following condition holds:
#
# The message referenced by `message_id` has `message.workflow_run_id == execution_extra_content.workflow_run_id`
#
message_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True, index=True)
class HumanInputContent(ExecutionExtraContent):
"""HumanInputContent is a concrete class that represents human input content.
It should only be initialized with the `new` class method."""
__mapper_args__ = {
"polymorphic_identity": ExecutionContentType.HUMAN_INPUT,
}
# A relation to HumanInputForm table.
#
# While the form_id column is nullable in database (due to the nature of single table inheritance),
# the form_id field should not be null for a given `HumanInputContent` instance.
form_id: Mapped[str] = mapped_column(StringUUID, nullable=True)
@classmethod
def new(cls, form_id: str, message_id: str | None) -> "HumanInputContent":
return cls(form_id=form_id, message_id=message_id)
form: Mapped["HumanInputForm"] = relationship(
"HumanInputForm",
foreign_keys=[form_id],
uselist=False,
lazy="raise",
primaryjoin="foreign(HumanInputContent.form_id) == HumanInputForm.id",
)

237
api/models/human_input.py Normal file
View File

@@ -0,0 +1,237 @@
from datetime import datetime
from enum import StrEnum
from typing import Annotated, Literal, Self, final
import sqlalchemy as sa
from pydantic import BaseModel, Field
from sqlalchemy.orm import Mapped, mapped_column, relationship
from core.workflow.nodes.human_input.enums import (
DeliveryMethodType,
HumanInputFormKind,
HumanInputFormStatus,
)
from libs.helper import generate_string
from .base import Base, DefaultFieldsMixin
from .types import EnumText, StringUUID
_token_length = 22
# A 32-character string can store a base64-encoded value with 192 bits of entropy
# or a base62-encoded value with over 180 bits of entropy, providing sufficient
# uniqueness for most use cases.
_token_field_length = 32
_email_field_length = 330
def _generate_token() -> str:
return generate_string(_token_length)
class HumanInputForm(DefaultFieldsMixin, Base):
__tablename__ = "human_input_forms"
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
app_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
workflow_run_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
form_kind: Mapped[HumanInputFormKind] = mapped_column(
EnumText(HumanInputFormKind),
nullable=False,
default=HumanInputFormKind.RUNTIME,
)
# The human input node the current form corresponds to.
node_id: Mapped[str] = mapped_column(sa.String(60), nullable=False)
form_definition: Mapped[str] = mapped_column(sa.Text, nullable=False)
rendered_content: Mapped[str] = mapped_column(sa.Text, nullable=False)
status: Mapped[HumanInputFormStatus] = mapped_column(
EnumText(HumanInputFormStatus),
nullable=False,
default=HumanInputFormStatus.WAITING,
)
expiration_time: Mapped[datetime] = mapped_column(
sa.DateTime,
nullable=False,
)
# Submission-related fields (nullable until a submission happens).
selected_action_id: Mapped[str | None] = mapped_column(sa.String(200), nullable=True)
submitted_data: Mapped[str | None] = mapped_column(sa.Text, nullable=True)
submitted_at: Mapped[datetime | None] = mapped_column(sa.DateTime, nullable=True)
submission_user_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
submission_end_user_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
completed_by_recipient_id: Mapped[str | None] = mapped_column(
StringUUID,
nullable=True,
)
deliveries: Mapped[list["HumanInputDelivery"]] = relationship(
"HumanInputDelivery",
primaryjoin="HumanInputForm.id == foreign(HumanInputDelivery.form_id)",
uselist=True,
back_populates="form",
lazy="raise",
)
completed_by_recipient: Mapped["HumanInputFormRecipient | None"] = relationship(
"HumanInputFormRecipient",
primaryjoin="HumanInputForm.completed_by_recipient_id == foreign(HumanInputFormRecipient.id)",
lazy="raise",
viewonly=True,
)
class HumanInputDelivery(DefaultFieldsMixin, Base):
__tablename__ = "human_input_form_deliveries"
form_id: Mapped[str] = mapped_column(
StringUUID,
nullable=False,
)
delivery_method_type: Mapped[DeliveryMethodType] = mapped_column(
EnumText(DeliveryMethodType),
nullable=False,
)
delivery_config_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
channel_payload: Mapped[str] = mapped_column(sa.Text, nullable=False)
form: Mapped[HumanInputForm] = relationship(
"HumanInputForm",
uselist=False,
foreign_keys=[form_id],
primaryjoin="HumanInputDelivery.form_id == HumanInputForm.id",
back_populates="deliveries",
lazy="raise",
)
recipients: Mapped[list["HumanInputFormRecipient"]] = relationship(
"HumanInputFormRecipient",
primaryjoin="HumanInputDelivery.id == foreign(HumanInputFormRecipient.delivery_id)",
uselist=True,
back_populates="delivery",
# Require explicit preloading
lazy="raise",
)
class RecipientType(StrEnum):
# EMAIL_MEMBER member means that the
EMAIL_MEMBER = "email_member"
EMAIL_EXTERNAL = "email_external"
# STANDALONE_WEB_APP is used by the standalone web app.
#
# It's not used while running workflows / chatflows containing HumanInput
# node inside console.
STANDALONE_WEB_APP = "standalone_web_app"
# CONSOLE is used while running workflows / chatflows containing HumanInput
# node inside console. (E.G. running installed apps or debugging workflows / chatflows)
CONSOLE = "console"
# BACKSTAGE is used for backstage input inside console.
BACKSTAGE = "backstage"
@final
class EmailMemberRecipientPayload(BaseModel):
TYPE: Literal[RecipientType.EMAIL_MEMBER] = RecipientType.EMAIL_MEMBER
user_id: str
# The `email` field here is only used for mail sending.
email: str
@final
class EmailExternalRecipientPayload(BaseModel):
TYPE: Literal[RecipientType.EMAIL_EXTERNAL] = RecipientType.EMAIL_EXTERNAL
email: str
@final
class StandaloneWebAppRecipientPayload(BaseModel):
TYPE: Literal[RecipientType.STANDALONE_WEB_APP] = RecipientType.STANDALONE_WEB_APP
@final
class ConsoleRecipientPayload(BaseModel):
TYPE: Literal[RecipientType.CONSOLE] = RecipientType.CONSOLE
account_id: str | None = None
@final
class BackstageRecipientPayload(BaseModel):
TYPE: Literal[RecipientType.BACKSTAGE] = RecipientType.BACKSTAGE
account_id: str | None = None
@final
class ConsoleDeliveryPayload(BaseModel):
type: Literal["console"] = "console"
internal: bool = True
RecipientPayload = Annotated[
EmailMemberRecipientPayload
| EmailExternalRecipientPayload
| StandaloneWebAppRecipientPayload
| ConsoleRecipientPayload
| BackstageRecipientPayload,
Field(discriminator="TYPE"),
]
class HumanInputFormRecipient(DefaultFieldsMixin, Base):
__tablename__ = "human_input_form_recipients"
form_id: Mapped[str] = mapped_column(
StringUUID,
nullable=False,
)
delivery_id: Mapped[str] = mapped_column(
StringUUID,
nullable=False,
)
recipient_type: Mapped["RecipientType"] = mapped_column(EnumText(RecipientType), nullable=False)
recipient_payload: Mapped[str] = mapped_column(sa.Text, nullable=False)
# Token primarily used for authenticated resume links (email, etc.).
access_token: Mapped[str | None] = mapped_column(
sa.VARCHAR(_token_field_length),
nullable=False,
default=_generate_token,
unique=True,
)
delivery: Mapped[HumanInputDelivery] = relationship(
"HumanInputDelivery",
uselist=False,
foreign_keys=[delivery_id],
back_populates="recipients",
primaryjoin="HumanInputFormRecipient.delivery_id == HumanInputDelivery.id",
# Require explicit preloading
lazy="raise",
)
form: Mapped[HumanInputForm] = relationship(
"HumanInputForm",
uselist=False,
foreign_keys=[form_id],
primaryjoin="HumanInputFormRecipient.form_id == HumanInputForm.id",
# Require explicit preloading
lazy="raise",
)
@classmethod
def new(
cls,
form_id: str,
delivery_id: str,
payload: RecipientPayload,
) -> Self:
recipient_model = cls(
form_id=form_id,
delivery_id=delivery_id,
recipient_type=payload.TYPE,
recipient_payload=payload.model_dump_json(),
access_token=_generate_token(),
)
return recipient_model

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
import json
import re
import uuid
from collections.abc import Mapping
from collections.abc import Mapping, Sequence
from datetime import datetime
from decimal import Decimal
from enum import StrEnum, auto
@@ -26,7 +26,7 @@ from libs.helper import generate_string # type: ignore[import-not-found]
from libs.uuid_utils import uuidv7
from .account import Account, Tenant
from .base import Base, TypeBase
from .base import Base, TypeBase, gen_uuidv4_string
from .engine import db
from .enums import CreatorUserRole
from .provider_ids import GenericProviderID
@@ -620,7 +620,7 @@ class TrialApp(Base):
sa.UniqueConstraint("app_id", name="unique_trail_app_id"),
)
id = mapped_column(StringUUID, server_default=sa.text("uuid_generate_v4()"))
id = mapped_column(StringUUID, default=gen_uuidv4_string)
app_id = mapped_column(StringUUID, nullable=False)
tenant_id = mapped_column(StringUUID, nullable=False)
created_at = mapped_column(sa.DateTime, nullable=False, server_default=func.current_timestamp())
@@ -640,7 +640,7 @@ class AccountTrialAppRecord(Base):
sa.Index("account_trial_app_record_app_id_idx", "app_id"),
sa.UniqueConstraint("account_id", "app_id", name="unique_account_trial_app_record"),
)
id = mapped_column(StringUUID, server_default=sa.text("uuid_generate_v4()"))
id = mapped_column(StringUUID, default=gen_uuidv4_string)
account_id = mapped_column(StringUUID, nullable=False)
app_id = mapped_column(StringUUID, nullable=False)
count = mapped_column(sa.Integer, nullable=False, default=0)
@@ -660,7 +660,7 @@ class AccountTrialAppRecord(Base):
class ExporleBanner(TypeBase):
__tablename__ = "exporle_banners"
__table_args__ = (sa.PrimaryKeyConstraint("id", name="exporler_banner_pkey"),)
id: Mapped[str] = mapped_column(StringUUID, server_default=sa.text("uuid_generate_v4()"), init=False)
id: Mapped[str] = mapped_column(StringUUID, default=gen_uuidv4_string, init=False)
content: Mapped[dict[str, Any]] = mapped_column(sa.JSON, nullable=False)
link: Mapped[str] = mapped_column(String(255), nullable=False)
sort: Mapped[int] = mapped_column(sa.Integer, nullable=False)
@@ -943,6 +943,7 @@ class Conversation(Base):
WorkflowExecutionStatus.FAILED: 0,
WorkflowExecutionStatus.STOPPED: 0,
WorkflowExecutionStatus.PARTIAL_SUCCEEDED: 0,
WorkflowExecutionStatus.PAUSED: 0,
}
for message in messages:
@@ -963,6 +964,7 @@ class Conversation(Base):
"success": status_counts[WorkflowExecutionStatus.SUCCEEDED],
"failed": status_counts[WorkflowExecutionStatus.FAILED],
"partial_success": status_counts[WorkflowExecutionStatus.PARTIAL_SUCCEEDED],
"paused": status_counts[WorkflowExecutionStatus.PAUSED],
}
@property
@@ -1345,6 +1347,14 @@ class Message(Base):
db.session.commit()
return result
# TODO(QuantumGhost): dirty hacks, fix this later.
def set_extra_contents(self, contents: Sequence[dict[str, Any]]) -> None:
self._extra_contents = list(contents)
@property
def extra_contents(self) -> list[dict[str, Any]]:
return getattr(self, "_extra_contents", [])
@property
def workflow_run(self):
if self.workflow_run_id:

View File

@@ -20,6 +20,7 @@ from sqlalchemy import (
select,
)
from sqlalchemy.orm import Mapped, declared_attr, mapped_column
from typing_extensions import deprecated
from core.file.constants import maybe_file_object
from core.file.models import File
@@ -31,7 +32,7 @@ from core.workflow.constants import (
)
from core.workflow.entities.graph_config import NodeConfigDict, NodeConfigDictAdapter
from core.workflow.entities.pause_reason import HumanInputRequired, PauseReason, PauseReasonType, SchedulingPause
from core.workflow.enums import NodeType
from core.workflow.enums import NodeType, WorkflowExecutionStatus
from extensions.ext_storage import Storage
from factories.variable_factory import TypeMismatchError, build_segment_with_type
from libs.datetime_utils import naive_utc_now
@@ -405,6 +406,11 @@ class Workflow(Base): # bug
return helper.generate_text_hash(json.dumps(entity, sort_keys=True))
@property
@deprecated(
"This property is not accurate for determining if a workflow is published as a tool."
"It only checks if there's a WorkflowToolProvider for the app, "
"not if this specific workflow version is the one being used by the tool."
)
def tool_published(self) -> bool:
"""
DEPRECATED: This property is not accurate for determining if a workflow is published as a tool.
@@ -607,13 +613,16 @@ class WorkflowRun(Base):
version: Mapped[str] = mapped_column(String(255))
graph: Mapped[str | None] = mapped_column(LongText)
inputs: Mapped[str | None] = mapped_column(LongText)
status: Mapped[str] = mapped_column(String(255)) # running, succeeded, failed, stopped, partial-succeeded
status: Mapped[WorkflowExecutionStatus] = mapped_column(
EnumText(WorkflowExecutionStatus, length=255),
nullable=False,
)
outputs: Mapped[str | None] = mapped_column(LongText, default="{}")
error: Mapped[str | None] = mapped_column(LongText)
elapsed_time: Mapped[float] = mapped_column(sa.Float, nullable=False, server_default=sa.text("0"))
total_tokens: Mapped[int] = mapped_column(sa.BigInteger, server_default=sa.text("0"))
total_steps: Mapped[int] = mapped_column(sa.Integer, server_default=sa.text("0"), nullable=True)
created_by_role: Mapped[str] = mapped_column(String(255)) # account, end_user
created_by_role: Mapped[CreatorUserRole] = mapped_column(EnumText(CreatorUserRole, length=255)) # account, end_user
created_by: Mapped[str] = mapped_column(StringUUID, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.current_timestamp())
finished_at: Mapped[datetime | None] = mapped_column(DateTime)
@@ -629,11 +638,13 @@ class WorkflowRun(Base):
)
@property
@deprecated("This method is retained for historical reasons; avoid using it if possible.")
def created_by_account(self):
created_by_role = CreatorUserRole(self.created_by_role)
return db.session.get(Account, self.created_by) if created_by_role == CreatorUserRole.ACCOUNT else None
@property
@deprecated("This method is retained for historical reasons; avoid using it if possible.")
def created_by_end_user(self):
from .model import EndUser
@@ -653,6 +664,7 @@ class WorkflowRun(Base):
return json.loads(self.outputs) if self.outputs else {}
@property
@deprecated("This method is retained for historical reasons; avoid using it if possible.")
def message(self):
from .model import Message
@@ -661,6 +673,7 @@ class WorkflowRun(Base):
)
@property
@deprecated("This method is retained for historical reasons; avoid using it if possible.")
def workflow(self):
return db.session.query(Workflow).where(Workflow.id == self.workflow_id).first()
@@ -1861,7 +1874,12 @@ class WorkflowPauseReason(DefaultFieldsMixin, Base):
def to_entity(self) -> PauseReason:
if self.type_ == PauseReasonType.HUMAN_INPUT_REQUIRED:
return HumanInputRequired(form_id=self.form_id, node_id=self.node_id)
return HumanInputRequired(
form_id=self.form_id,
form_content="",
node_id=self.node_id,
node_title="",
)
elif self.type_ == PauseReasonType.SCHEDULED_PAUSE:
return SchedulingPause(message=self.message)
else: