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