mirror of
https://github.com/langgenius/dify.git
synced 2026-11-10 13:26:58 +08:00
feat(agent): add output routes to Agent v2 (#42599)
Co-authored-by: zhsama <torvalds@linux.do>
This commit is contained in:
1 parent
a068c47ea9
commit
0f6a6227f5
114 files changed
+2888
-536
No files matched your search
@@ -0,0 +1,58 @@
|
||||
"""Parse frontend workflow markers shared by Agent tasks and route validation.
|
||||
|
||||
These helpers distinguish previous-node references from reserved workflow
|
||||
namespaces without depending on application services or resolving values.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from models.agent_config_entities import WorkflowPreviousNodeOutputRef
|
||||
|
||||
WORKFLOW_VARIABLE_PATTERN = re.compile(r"\{\{#([^{}#]+?\.[^{}#]+?)#\}\}")
|
||||
WORKFLOW_NODE_OUTPUT_RESERVED_PREFIXES = frozenset(
|
||||
{"sys", "env", "conversation", "rag", "current", "last_run", "error_message", "$output"}
|
||||
)
|
||||
|
||||
|
||||
def extract_workflow_variable_selectors(prompt: str) -> list[tuple[str, ...]]:
|
||||
"""Extract ``{{#node.output#}}``-style selectors from workflow prompts."""
|
||||
selectors: list[tuple[str, ...]] = []
|
||||
for match in WORKFLOW_VARIABLE_PATTERN.finditer(prompt or ""):
|
||||
parts = tuple(part.strip() for part in match.group(1).split(".") if part.strip())
|
||||
if len(parts) >= 2:
|
||||
selectors.append(parts)
|
||||
return selectors
|
||||
|
||||
|
||||
def extract_workflow_node_output_selectors(prompt: str) -> list[tuple[str, ...]]:
|
||||
"""Extract previous-node selectors from frontend workflow variable markers.
|
||||
|
||||
Reserved Dify namespaces such as ``sys`` are excluded because they are not
|
||||
previous nodes.
|
||||
"""
|
||||
selectors: list[tuple[str, ...]] = []
|
||||
seen: set[tuple[str, ...]] = set()
|
||||
for selector in extract_workflow_variable_selectors(prompt):
|
||||
if selector[0] in WORKFLOW_NODE_OUTPUT_RESERVED_PREFIXES:
|
||||
continue
|
||||
if selector in seen:
|
||||
continue
|
||||
selectors.append(selector)
|
||||
seen.add(selector)
|
||||
return selectors
|
||||
|
||||
|
||||
def workflow_previous_node_output_refs_from_selectors(
|
||||
selectors: list[tuple[str, ...]],
|
||||
) -> list[WorkflowPreviousNodeOutputRef]:
|
||||
"""Materialize persisted previous-node refs from parsed frontend selectors."""
|
||||
return [
|
||||
WorkflowPreviousNodeOutputRef(
|
||||
selector=list(selector),
|
||||
node_id=selector[0],
|
||||
output=selector[1],
|
||||
)
|
||||
for selector in selectors
|
||||
]
|
||||
@@ -29,11 +29,18 @@ from core.workflow.nodes.human_input.pause_reason import HumanInputRequired
|
||||
from core.workflow.nodes.human_input.session_binding import default_session_binding
|
||||
from core.workflow.system_variables import SystemVariableKey, get_system_text
|
||||
from graphon.entities.pause_reason import HitlRequired, SchedulingPause
|
||||
from graphon.enums import BuiltinNodeTypes, WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus
|
||||
from graphon.enums import (
|
||||
BuiltinNodeTypes,
|
||||
ErrorStrategy,
|
||||
NodeExecutionType,
|
||||
WorkflowNodeExecutionMetadataKey,
|
||||
WorkflowNodeExecutionStatus,
|
||||
)
|
||||
from graphon.graph_events import NodeRunPauseRequestedEvent
|
||||
from graphon.node_events import NodeEventBase, NodeRunResult, StreamCompletedEvent
|
||||
from graphon.nodes.base.node import Node
|
||||
from models.agent_config_entities import AgentSoulConfig, WorkflowNodeJobConfig
|
||||
from graphon.nodes.base.variable_template_parser import VariableTemplateParser
|
||||
from models.agent_config_entities import AgentSoulConfig, WorkflowNodeJobConfig, WorkflowOutputRoutes
|
||||
from services.agent.prompt_mentions import extract_workflow_node_output_selectors
|
||||
from services.agent.workspace_service import AgentWorkspaceNotFoundError
|
||||
|
||||
@@ -75,6 +82,14 @@ type _TerminalAgentBackendEvent = (
|
||||
|
||||
|
||||
class DifyAgentNode(Node[DifyAgentNodeData]):
|
||||
"""Execute a frozen node job and select one workflow exit on success.
|
||||
|
||||
Enabled routing selects a stable route ID through the required ``switch``
|
||||
output. Disabled routing remains executable so
|
||||
graph-owned default values can continue without a selected route; Graphon
|
||||
still promotes nodes that opt into its failure branch.
|
||||
"""
|
||||
|
||||
node_type = BuiltinNodeTypes.AGENT
|
||||
|
||||
def __init__(
|
||||
@@ -99,6 +114,11 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
|
||||
graph_init_params=graph_init_params,
|
||||
graph_runtime_state=graph_runtime_state,
|
||||
)
|
||||
# The base node parses the factory's serialized payload into node_data.
|
||||
if self.node_data.agent_output_routes.enabled and self.error_strategy == ErrorStrategy.DEFAULT_VALUE:
|
||||
raise ValueError("Output routes do not support the node-level default-value error strategy.")
|
||||
if self.node_data.agent_output_routes.enabled:
|
||||
self.execution_type = NodeExecutionType.BRANCH
|
||||
self._binding_resolver = binding_resolver
|
||||
self._runtime_request_builder = runtime_request_builder
|
||||
self._agent_backend_client = agent_backend_client
|
||||
@@ -229,6 +249,7 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
|
||||
)
|
||||
|
||||
node_job = WorkflowNodeJobConfig.model_validate(bundle.binding.node_job_config_dict)
|
||||
node_job.output_routes.validate_for_execution()
|
||||
custom_outputs = list(node_job.declared_outputs)
|
||||
outputs_by_name = {output.name: output for output in custom_outputs}
|
||||
|
||||
@@ -490,9 +511,27 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
|
||||
)
|
||||
return
|
||||
|
||||
# Select only after this attempt's custom outputs have passed. Failed
|
||||
# route output uses the node's normal retry / failure-branch handling.
|
||||
routes = node_job.output_routes
|
||||
edge_source_handle = "source"
|
||||
if routes.enabled:
|
||||
selected = success_event.output.get("switch") if isinstance(success_event.output, dict) else None
|
||||
if not isinstance(selected, str) or selected not in {route.id for route in routes.routes}:
|
||||
yield self._failure_event(
|
||||
inputs=inputs,
|
||||
process_data=process_data,
|
||||
metadata=metadata,
|
||||
error="Agent output switch must select one configured output route ID.",
|
||||
error_type="output_route_selection_failed",
|
||||
)
|
||||
return
|
||||
edge_source_handle = selected
|
||||
|
||||
yield StreamCompletedEvent(
|
||||
node_run_result=self._output_adapter.build_success_result(
|
||||
event=success_event,
|
||||
edge_source_handle=edge_source_handle,
|
||||
inputs=inputs,
|
||||
process_data=process_data,
|
||||
metadata=metadata,
|
||||
@@ -815,18 +854,23 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
|
||||
) -> Mapping[str, Sequence[str]]:
|
||||
"""Reuse frontend workflow-marker parsing for graph variable loading.
|
||||
|
||||
This follows the same marker parser used by publish sync and runtime
|
||||
request building, including reserved-prefix exclusion.
|
||||
Task mentions use the publish parser. Enabled route conditions use
|
||||
Graphon template selectors, including environment and system variables;
|
||||
disabled jobs do not load their saved conditions.
|
||||
"""
|
||||
del graph_config
|
||||
|
||||
agent_task = (
|
||||
node_data.get("agent_task") if isinstance(node_data, Mapping) else getattr(node_data, "agent_task", None)
|
||||
)
|
||||
if not isinstance(agent_task, str):
|
||||
return {}
|
||||
|
||||
return {
|
||||
f"{node_id}.{'.'.join(selector)}": list(selector)
|
||||
for selector in extract_workflow_node_output_selectors(agent_task)
|
||||
}
|
||||
if isinstance(node_data, Mapping):
|
||||
task = node_data.get("agent_task", "")
|
||||
routes = WorkflowOutputRoutes.model_validate(node_data.get("agent_output_routes", {}))
|
||||
else:
|
||||
task = node_data.agent_task
|
||||
routes = node_data.agent_output_routes
|
||||
selectors = list(extract_workflow_node_output_selectors(task))
|
||||
if routes.enabled:
|
||||
for route in routes.routes:
|
||||
selectors.extend(
|
||||
tuple(selector.value_selector)
|
||||
for selector in VariableTemplateParser(template=route.name).extract_variable_selectors()
|
||||
)
|
||||
return {f"{node_id}.{'.'.join(selector)}": list(selector) for selector in selectors}
|
||||
@@ -1,14 +1,17 @@
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import model_validator
|
||||
from pydantic import Field, model_validator
|
||||
|
||||
from graphon.entities.base_node_data import BaseNodeData
|
||||
from graphon.enums import BuiltinNodeTypes, NodeType
|
||||
from models.agent_config_entities import WorkflowOutputRoutes
|
||||
|
||||
|
||||
class DifyAgentNodeData(BaseNodeData):
|
||||
type: NodeType = BuiltinNodeTypes.AGENT
|
||||
agent_node_kind: Literal["dify_agent"]
|
||||
agent_task: str = ""
|
||||
agent_output_routes: WorkflowOutputRoutes = Field(default_factory=WorkflowOutputRoutes)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_version(self) -> "DifyAgentNodeData":
|
||||
|
||||
@@ -53,13 +53,15 @@ class WorkflowAgentOutputAdapter:
|
||||
metadata: dict[str, Any],
|
||||
declared_outputs: Sequence[DeclaredOutputConfig] | None = None,
|
||||
tenant_id: str | None = None,
|
||||
edge_source_handle: str = "source",
|
||||
) -> NodeRunResult:
|
||||
"""Build the successful node result from one backend terminal event.
|
||||
|
||||
For structured runs, ``declared_outputs`` contains the custom-only
|
||||
declarations used to scope field and file normalization. Plain runs
|
||||
omit custom declarations, and a free-form string is mapped to the
|
||||
system ``text`` output.
|
||||
system ``text`` output. The caller validates and selects the successful
|
||||
``edge_source_handle`` before invoking this adapter.
|
||||
|
||||
Canonical persisted-file mappings (``local_file`` / ``tool_file`` /
|
||||
``datasource_file``) also require ``metadata["tenant_id"]`` so the
|
||||
@@ -72,6 +74,7 @@ class WorkflowAgentOutputAdapter:
|
||||
resolved_tenant_id = tenant_id or (metadata_tenant_id if isinstance(metadata_tenant_id, str) else None)
|
||||
return NodeRunResult(
|
||||
status=WorkflowNodeExecutionStatus.SUCCEEDED,
|
||||
edge_source_handle=edge_source_handle,
|
||||
inputs=inputs,
|
||||
process_data=process_data,
|
||||
outputs=self._normalize_outputs(
|
||||
|
||||
@@ -53,6 +53,7 @@ from core.plugin.provider_identity import normalize_plugin_daemon_provider_ident
|
||||
from core.workflow.system_variables import SystemVariableKey, get_system_text, get_system_value
|
||||
from graphon.file import File, FileTransferMethod
|
||||
from graphon.variables.segments import Segment
|
||||
from graphon.variables.template_resolution import convert_template
|
||||
from models.agent import Agent, AgentConfigSnapshot, WorkflowAgentNodeBinding
|
||||
from models.agent_config_entities import (
|
||||
AgentKnowledgeMetadataFilteringConfig,
|
||||
@@ -65,6 +66,7 @@ from models.agent_config_entities import (
|
||||
DeclaredOutputConfig,
|
||||
DeclaredOutputType,
|
||||
WorkflowNodeJobConfig,
|
||||
WorkflowOutputRoutes,
|
||||
WorkflowPreviousNodeOutputRef,
|
||||
)
|
||||
from models.provider_ids import ModelProviderID
|
||||
@@ -169,6 +171,7 @@ class WorkflowAgentRuntimeRequestBuilder:
|
||||
def build(self, context: WorkflowAgentRuntimeBuildContext) -> WorkflowAgentRuntimeRequest:
|
||||
agent_soul = AgentSoulConfig.model_validate(context.snapshot.config_snapshot_dict)
|
||||
node_job = WorkflowNodeJobConfig.model_validate(context.binding.node_job_config_dict)
|
||||
node_job.output_routes.validate_for_execution()
|
||||
if agent_soul.model is None:
|
||||
raise WorkflowAgentRuntimeRequestBuildError(
|
||||
"agent_model_not_configured",
|
||||
@@ -263,7 +266,9 @@ class WorkflowAgentRuntimeRequestBuilder:
|
||||
agent_soul_prompt=soul_prompt or None,
|
||||
workflow_node_job_prompt=workflow_job_prompt,
|
||||
user_prompt=user_prompt,
|
||||
output=self._build_output_config(node_job.declared_outputs),
|
||||
output=self._build_output_config(
|
||||
node_job.declared_outputs, node_job.output_routes, context.variable_pool
|
||||
),
|
||||
tools=tool_layers.plugin_tools,
|
||||
core_tools=tool_layers.core_tools,
|
||||
knowledge=knowledge_config,
|
||||
@@ -516,17 +521,35 @@ class WorkflowAgentRuntimeRequestBuilder:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _build_output_config(declared_outputs: Sequence[DeclaredOutputConfig]) -> AgentBackendOutputConfig | None:
|
||||
def _build_output_config(
|
||||
declared_outputs: Sequence[DeclaredOutputConfig],
|
||||
output_routes: WorkflowOutputRoutes | None = None,
|
||||
variable_pool: VariablePoolReader | None = None,
|
||||
) -> AgentBackendOutputConfig | None:
|
||||
"""Build the structured-output layer config sent to Agent backend.
|
||||
|
||||
Plain-output jobs omit this layer. Structured jobs prepend the optional,
|
||||
system-owned ``text`` field to the persisted custom declarations.
|
||||
Enabled routing adds a required system ``switch``
|
||||
in the same Agent call. Omit the structured-output layer only when there
|
||||
are no custom outputs and routing is disabled.
|
||||
"""
|
||||
if not declared_outputs:
|
||||
if not declared_outputs and (output_routes is None or not output_routes.enabled):
|
||||
return None
|
||||
|
||||
properties: dict[str, Any] = {"text": {"type": "string"}}
|
||||
required: list[str] = []
|
||||
if output_routes is not None and output_routes.enabled:
|
||||
if variable_pool is None:
|
||||
raise ValueError("Output route selection requires a workflow variable pool.")
|
||||
descriptions = [
|
||||
f"{route.id} ({route.label or route.id}): {convert_template(variable_pool, route.name).text}"
|
||||
for route in output_routes.routes
|
||||
]
|
||||
properties["switch"] = {
|
||||
"type": "string",
|
||||
"enum": [route.id for route in output_routes.routes],
|
||||
"description": "Select exactly one route ID based on the task result:\n" + "\n".join(descriptions),
|
||||
}
|
||||
required.append("switch")
|
||||
for output in declared_outputs:
|
||||
properties[output.name] = WorkflowAgentRuntimeRequestBuilder._schema_for_declared_output(output)
|
||||
if output.required:
|
||||
|
||||
@@ -7,8 +7,12 @@ from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.agent.publish_visibility import workflow_callable_active_snapshot_filter
|
||||
from core.agent.workflow_references import (
|
||||
extract_workflow_node_output_selectors,
|
||||
workflow_previous_node_output_refs_from_selectors,
|
||||
)
|
||||
from core.workflow.graph_topology import WorkflowGraphTopology
|
||||
from graphon.enums import BuiltinNodeTypes
|
||||
from graphon.enums import BuiltinNodeTypes, ErrorStrategy
|
||||
from models.agent import (
|
||||
Agent,
|
||||
AgentConfigSnapshot,
|
||||
@@ -115,6 +119,14 @@ class WorkflowAgentNodeValidator:
|
||||
f"Workflow Agent node {node_id} requires a binding before publishing."
|
||||
)
|
||||
continue
|
||||
if (
|
||||
require_binding
|
||||
and node_data.get("error_strategy") == ErrorStrategy.DEFAULT_VALUE
|
||||
and WorkflowNodeJobConfig.model_validate(binding.node_job_config_dict).output_routes.enabled
|
||||
):
|
||||
raise WorkflowAgentNodeValidationError(
|
||||
"Output routes do not support the node-level default-value error strategy."
|
||||
)
|
||||
cls.validate_binding(
|
||||
session=session,
|
||||
binding=binding,
|
||||
@@ -191,6 +203,8 @@ class WorkflowAgentNodeValidator:
|
||||
cls._validate_agent_soul_tools(binding=binding, agent_soul=agent_soul)
|
||||
cls._validate_agent_soul_knowledge(session=session, binding=binding, agent_soul=agent_soul)
|
||||
node_job = WorkflowNodeJobConfig.model_validate(binding.node_job_config_dict)
|
||||
if require_agent_model:
|
||||
node_job.output_routes.validate_for_execution()
|
||||
cls.validate_node_job(session=session, binding=binding, node_job=node_job, topology=topology)
|
||||
|
||||
@classmethod
|
||||
@@ -221,7 +235,14 @@ class WorkflowAgentNodeValidator:
|
||||
ref_context=f"output {output.name} benchmark file",
|
||||
)
|
||||
|
||||
for ref in node_job.previous_node_output_refs:
|
||||
route_refs = (
|
||||
workflow_previous_node_output_refs_from_selectors(
|
||||
extract_workflow_node_output_selectors("\n".join(route.name for route in node_job.output_routes.routes))
|
||||
)
|
||||
if node_job.output_routes.enabled
|
||||
else []
|
||||
)
|
||||
for ref in [*node_job.previous_node_output_refs, *route_refs]:
|
||||
selector = cls.selector_from_ref(ref)
|
||||
if selector is None:
|
||||
raise WorkflowAgentNodeValidationError(
|
||||
|
||||
@@ -1072,16 +1072,59 @@ SYSTEM_DECLARED_OUTPUTS: Final[tuple[DeclaredOutputConfig, ...]] = (
|
||||
description="Free-form text answer.",
|
||||
),
|
||||
)
|
||||
# ``switch`` and ``_session`` are reserved for future system output contracts.
|
||||
# ``switch`` is derived when routing is enabled; ``_session`` remains reserved.
|
||||
RESERVED_DECLARED_OUTPUT_NAMES: Final[frozenset[str]] = frozenset({"text", "switch", "_session"})
|
||||
|
||||
|
||||
class WorkflowOutputRoute(BaseModel):
|
||||
"""Stable workflow exit identity and its model-visible selection condition."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
id: str = Field(min_length=1)
|
||||
name: str = ""
|
||||
label: str | None = None
|
||||
|
||||
@field_validator("id")
|
||||
@classmethod
|
||||
def _validate_id(cls, value: str) -> str:
|
||||
if not value.strip() or value in {"source", "target", "fail-branch"}:
|
||||
raise ValueError("output route id must be nonblank and must not use a system handle")
|
||||
return value
|
||||
|
||||
|
||||
class WorkflowOutputRoutes(BaseModel):
|
||||
"""Enabled routes require at least two exits; drafts may omit conditions."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
enabled: bool = False
|
||||
routes: list[WorkflowOutputRoute] = Field(default_factory=list)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_routes(self) -> Self:
|
||||
if self.enabled and len(self.routes) < 2:
|
||||
raise ValueError("Enabled output routes require at least two routes.")
|
||||
if len({route.id for route in self.routes}) != len(self.routes):
|
||||
raise ValueError("output route ids must be unique")
|
||||
return self
|
||||
|
||||
def validate_for_execution(self) -> None:
|
||||
if self.enabled and any(not route.name.strip() for route in self.routes):
|
||||
raise ValueError("Each enabled output route requires a selection condition.")
|
||||
|
||||
|
||||
def effective_declared_outputs(
|
||||
declared_outputs: list[DeclaredOutputConfig] | tuple[DeclaredOutputConfig, ...],
|
||||
output_routes: WorkflowOutputRoutes | None = None,
|
||||
) -> tuple[DeclaredOutputConfig, ...]:
|
||||
"""Project the system ``text`` output followed by custom declarations."""
|
||||
|
||||
return SYSTEM_DECLARED_OUTPUTS + tuple(declared_outputs)
|
||||
"""Project system outputs for this job before its custom declarations."""
|
||||
switch = (
|
||||
(DeclaredOutputConfig(name="switch", type=DeclaredOutputType.STRING, description="Selected output route ID."),)
|
||||
if output_routes is not None and output_routes.enabled
|
||||
else ()
|
||||
)
|
||||
return SYSTEM_DECLARED_OUTPUTS + switch + tuple(declared_outputs)
|
||||
|
||||
|
||||
class WorkflowNodeJobConfig(BaseModel):
|
||||
@@ -1092,6 +1135,7 @@ class WorkflowNodeJobConfig(BaseModel):
|
||||
workflow_prompt: str = ""
|
||||
previous_node_output_refs: list[WorkflowPreviousNodeOutputRef] = Field(default_factory=list)
|
||||
declared_outputs: list[DeclaredOutputConfig] = Field(default_factory=list)
|
||||
output_routes: WorkflowOutputRoutes = Field(default_factory=WorkflowOutputRoutes)
|
||||
human_contacts: list[AgentHumanContactConfig] = Field(default_factory=list)
|
||||
metadata: WorkflowNodeJobMetadata = Field(default_factory=WorkflowNodeJobMetadata)
|
||||
|
||||
|
||||
@@ -24770,6 +24770,7 @@ tenant's default model. The underlying generator never raises — an empty
|
||||
| human_contacts | [ [AgentHumanContactConfig](#agenthumancontactconfig) ] | | No |
|
||||
| metadata | [WorkflowNodeJobMetadata](#workflownodejobmetadata) | | No |
|
||||
| mode | [WorkflowNodeJobMode](#workflownodejobmode) | | No |
|
||||
| output_routes | [WorkflowOutputRoutes](#workflowoutputroutes) | | No |
|
||||
| previous_node_output_refs | [ [WorkflowPreviousNodeOutputRef](#workflowpreviousnodeoutputref) ] | | No |
|
||||
| schema_version | integer, <br>**Default:** 1 | | No |
|
||||
| workflow_prompt | string | | No |
|
||||
@@ -24814,6 +24815,25 @@ tenant's default model. The underlying generator never raises — an empty
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| data | [ [WorkflowOnlineUsersByApp](#workflowonlineusersbyapp) ] | | Yes |
|
||||
|
||||
#### WorkflowOutputRoute
|
||||
|
||||
Stable workflow exit identity and its model-visible selection condition.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| id | string | | Yes |
|
||||
| label | string | | No |
|
||||
| name | string | | No |
|
||||
|
||||
#### WorkflowOutputRoutes
|
||||
|
||||
Enabled routes require at least two exits; drafts may omit conditions.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| enabled | boolean | | No |
|
||||
| routes | [ [WorkflowOutputRoute](#workflowoutputroute) ] | | No |
|
||||
|
||||
#### WorkflowPaginationResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
|
||||
@@ -1263,7 +1263,7 @@ class AgentComposerService:
|
||||
node_job = cls._parse_node_job(binding)
|
||||
if node_job is None:
|
||||
return None
|
||||
return list(_effective_declared_outputs(node_job.declared_outputs))
|
||||
return list(_effective_declared_outputs(node_job.declared_outputs, node_job.output_routes))
|
||||
|
||||
@staticmethod
|
||||
def _draft_node_variables(*, session: Any, app_id: str, node_id: str, user_id: str) -> list[tuple[str, str | None]]:
|
||||
@@ -1376,7 +1376,6 @@ class AgentComposerService:
|
||||
binding: WorkflowAgentNodeBinding | None,
|
||||
payload: ComposerSavePayload,
|
||||
) -> WorkflowAgentNodeBinding:
|
||||
node_job = payload.node_job or WorkflowNodeJobConfig()
|
||||
if binding:
|
||||
if cls._is_start_from_scratch_request(binding=binding, payload=payload):
|
||||
return cls._switch_roster_binding_to_inline_agent(
|
||||
@@ -1389,7 +1388,8 @@ class AgentComposerService:
|
||||
binding=binding,
|
||||
payload=payload,
|
||||
)
|
||||
binding.node_job_config = node_job
|
||||
if payload.node_job is not None:
|
||||
binding.node_job_config = payload.node_job
|
||||
if payload.agent_soul is not None and binding.binding_type == WorkflowAgentBindingType.INLINE_AGENT:
|
||||
current_snapshot = cls._require_version(
|
||||
session=session,
|
||||
@@ -1449,7 +1449,7 @@ class AgentComposerService:
|
||||
binding_type=WorkflowAgentBindingType.INLINE_AGENT,
|
||||
agent_id=agent.id,
|
||||
current_snapshot_id=agent.active_config_snapshot_id,
|
||||
node_job_config=node_job,
|
||||
node_job_config=payload.node_job or WorkflowNodeJobConfig(),
|
||||
created_by=account_id,
|
||||
updated_by=account_id,
|
||||
)
|
||||
@@ -1630,7 +1630,6 @@ class AgentComposerService:
|
||||
target_snapshot_id=agent.active_config_snapshot_id,
|
||||
user_id=account_id,
|
||||
)
|
||||
node_job = payload.node_job or WorkflowNodeJobConfig()
|
||||
if not binding:
|
||||
binding = WorkflowAgentNodeBinding(
|
||||
tenant_id=tenant_id,
|
||||
@@ -1638,13 +1637,15 @@ class AgentComposerService:
|
||||
workflow_id=workflow_id,
|
||||
workflow_version=_DRAFT_WORKFLOW_VERSION,
|
||||
node_id=node_id,
|
||||
node_job_config=WorkflowNodeJobConfig(),
|
||||
created_by=account_id,
|
||||
)
|
||||
session.add(binding)
|
||||
binding.binding_type = WorkflowAgentBindingType.ROSTER_AGENT
|
||||
binding.agent_id = agent.id
|
||||
binding.current_snapshot_id = agent.active_config_snapshot_id
|
||||
binding.node_job_config = node_job
|
||||
if payload.node_job is not None:
|
||||
binding.node_job_config = payload.node_job
|
||||
binding.updated_by = account_id
|
||||
session.flush()
|
||||
return binding
|
||||
@@ -2222,20 +2223,12 @@ class AgentComposerService:
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _declared_outputs_from_binding(binding: WorkflowAgentNodeBinding) -> list[DeclaredOutputConfig]:
|
||||
"""Re-hydrate the binding's custom-only persisted output declarations.
|
||||
|
||||
node_job_config is stored as JSON / LongText; the typed view is needed
|
||||
before the later effective-output projection prepends the system
|
||||
``text`` output.
|
||||
"""
|
||||
node_job = WorkflowNodeJobConfig.model_validate(binding.node_job_config_dict)
|
||||
return list(node_job.declared_outputs)
|
||||
|
||||
@staticmethod
|
||||
def _serialize_effective_outputs(declared_outputs: list[DeclaredOutputConfig]) -> list[dict[str, Any]]:
|
||||
"""JSON-serialize system ``text`` followed by custom declarations."""
|
||||
return [output.model_dump(mode="json") for output in _effective_declared_outputs(declared_outputs)]
|
||||
def _serialize_effective_outputs(node_job: WorkflowNodeJobConfig) -> list[dict[str, Any]]:
|
||||
"""Serialize custom declarations and the system outputs enabled for this job."""
|
||||
return [
|
||||
output.model_dump(mode="json")
|
||||
for output in _effective_declared_outputs(node_job.declared_outputs, node_job.output_routes)
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def _empty_workflow_state(cls, *, app_id: str, workflow_id: str, node_id: str) -> dict[str, Any]:
|
||||
@@ -2248,7 +2241,7 @@ class AgentComposerService:
|
||||
"agent_soul": AgentSoulConfig().model_dump(mode="json"),
|
||||
"node_job": WorkflowNodeJobConfig().model_dump(mode="json"),
|
||||
# ``text`` is derived for the editor and is not stored in node_job.
|
||||
"effective_declared_outputs": cls._serialize_effective_outputs([]),
|
||||
"effective_declared_outputs": cls._serialize_effective_outputs(WorkflowNodeJobConfig()),
|
||||
"save_options": [ComposerSaveStrategy.NODE_JOB_ONLY.value, ComposerSaveStrategy.SAVE_TO_ROSTER.value],
|
||||
"impact_summary": None,
|
||||
"app_id": app_id,
|
||||
@@ -2313,8 +2306,9 @@ class AgentComposerService:
|
||||
if version
|
||||
else AgentSoulConfig().model_dump(mode="json"),
|
||||
"node_job": binding.node_job_config_dict,
|
||||
# Surface system ``text`` followed by the binding's custom outputs.
|
||||
"effective_declared_outputs": cls._serialize_effective_outputs(cls._declared_outputs_from_binding(binding)),
|
||||
"effective_declared_outputs": cls._serialize_effective_outputs(
|
||||
WorkflowNodeJobConfig.model_validate(binding.node_job_config_dict)
|
||||
),
|
||||
"save_options": save_options,
|
||||
"impact_summary": cls.calculate_impact(
|
||||
session=session, tenant_id=binding.tenant_id, current_snapshot_id=version.id
|
||||
|
||||
@@ -13,10 +13,11 @@ Workflow Agent tasks also carry frontend workflow variable markers:
|
||||
{{#<node-id>.<output>[.<child>...]#}}
|
||||
|
||||
Those frontend markers are a separate path from slash-reference expansion. They
|
||||
are parsed here only to derive ``previous_node_output_refs`` from the current
|
||||
task text. The markers remain literal in the workflow task prompt, while their
|
||||
resolved values appear under the workflow context prompt's ``Previous node
|
||||
outputs:`` section. Legacy ``[§node_output:...§]`` mention syntax is not part
|
||||
are parsed by ``core.agent.workflow_references``, re-exported here to derive
|
||||
``previous_node_output_refs`` from the current task text. The markers remain
|
||||
literal in the workflow task prompt, while their resolved values appear under
|
||||
the workflow context prompt's ``Previous node outputs:`` section. Legacy
|
||||
``[§node_output:...§]`` mention syntax is not part
|
||||
of that derivation path.
|
||||
|
||||
Frontend output blocks still accept a legacy bare ``§output:...§`` form during
|
||||
@@ -30,6 +31,12 @@ from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
|
||||
from core.agent.workflow_references import (
|
||||
WORKFLOW_NODE_OUTPUT_RESERVED_PREFIXES,
|
||||
extract_workflow_node_output_selectors,
|
||||
extract_workflow_variable_selectors,
|
||||
workflow_previous_node_output_refs_from_selectors,
|
||||
)
|
||||
from models.agent_config_entities import (
|
||||
AgentHumanContactConfig,
|
||||
AgentSoulConfig,
|
||||
@@ -64,7 +71,6 @@ MENTION_PATTERN = re.compile(
|
||||
# requirement keeps legacy ``{{#histories#}}`` / ``{{var}}`` template forms and
|
||||
# ordinary bracketed text out of scope.
|
||||
_RESIDUAL_MENTION_PATTERN = re.compile(r"\[§([A-Za-z_][A-Za-z0-9_]*:[^§]*?)§\]")
|
||||
WORKFLOW_VARIABLE_PATTERN = re.compile(r"\{\{#([^{}#]+?\.[^{}#]+?)#\}\}")
|
||||
|
||||
MAX_MENTIONS_PER_PROMPT = 200
|
||||
# Mention ids are bounded independently of their owning configuration schema.
|
||||
@@ -93,9 +99,6 @@ SOUL_PROMPT_ALLOWED_KINDS = frozenset(
|
||||
}
|
||||
)
|
||||
NODE_JOB_PROMPT_ALLOWED_KINDS = frozenset({MentionKind.NODE_OUTPUT, MentionKind.OUTPUT, MentionKind.HUMAN})
|
||||
WORKFLOW_NODE_OUTPUT_RESERVED_PREFIXES = frozenset(
|
||||
{"sys", "env", "conversation", "rag", "current", "last_run", "error_message", "$output"}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -182,48 +185,6 @@ def find_malformed_mention_markers(prompt: str) -> list[str]:
|
||||
return [match.group(0) for match in _RESIDUAL_MENTION_PATTERN.finditer(prompt) if match.span() not in parsed_spans]
|
||||
|
||||
|
||||
def extract_workflow_variable_selectors(prompt: str) -> list[tuple[str, ...]]:
|
||||
"""Extract ``{{#node.output#}}``-style selectors from workflow prompts."""
|
||||
selectors: list[tuple[str, ...]] = []
|
||||
for match in WORKFLOW_VARIABLE_PATTERN.finditer(prompt or ""):
|
||||
parts = tuple(part.strip() for part in match.group(1).split(".") if part.strip())
|
||||
if len(parts) >= 2:
|
||||
selectors.append(parts)
|
||||
return selectors
|
||||
|
||||
|
||||
def extract_workflow_node_output_selectors(prompt: str) -> list[tuple[str, ...]]:
|
||||
"""Extract previous-node selectors from frontend workflow variable markers.
|
||||
|
||||
Reserved Dify namespaces such as ``sys`` are excluded because they are not
|
||||
previous nodes.
|
||||
"""
|
||||
selectors: list[tuple[str, ...]] = []
|
||||
seen: set[tuple[str, ...]] = set()
|
||||
for selector in extract_workflow_variable_selectors(prompt):
|
||||
if selector[0] in WORKFLOW_NODE_OUTPUT_RESERVED_PREFIXES:
|
||||
continue
|
||||
if selector in seen:
|
||||
continue
|
||||
selectors.append(selector)
|
||||
seen.add(selector)
|
||||
return selectors
|
||||
|
||||
|
||||
def workflow_previous_node_output_refs_from_selectors(
|
||||
selectors: list[tuple[str, ...]],
|
||||
) -> list[WorkflowPreviousNodeOutputRef]:
|
||||
"""Materialize persisted previous-node refs from parsed frontend selectors."""
|
||||
return [
|
||||
WorkflowPreviousNodeOutputRef(
|
||||
selector=list(selector),
|
||||
node_id=selector[0],
|
||||
output=selector[1],
|
||||
)
|
||||
for selector in selectors
|
||||
]
|
||||
|
||||
|
||||
def scrub_mention_markers(text: str) -> str:
|
||||
"""Degrade any residual mention-shaped ``[§kind:…§]`` marker to readable text."""
|
||||
|
||||
@@ -301,7 +262,7 @@ def build_node_job_mention_resolver(node_job: WorkflowNodeJobConfig) -> MentionR
|
||||
if selector and f"{selector[0]}.{selector[1]}" == mention.ref_id:
|
||||
return ref.name or mention.label or mention.ref_id
|
||||
case MentionKind.OUTPUT:
|
||||
for output in effective_declared_outputs(node_job.declared_outputs):
|
||||
for output in effective_declared_outputs(node_job.declared_outputs, node_job.output_routes):
|
||||
if output.name == mention.ref_id:
|
||||
return _format_output_mention(output)
|
||||
case MentionKind.HUMAN:
|
||||
|
||||
@@ -21,6 +21,7 @@ from models.agent import (
|
||||
from models.agent_config_entities import (
|
||||
AgentSoulConfig,
|
||||
WorkflowNodeJobConfig,
|
||||
WorkflowOutputRoutes,
|
||||
WorkflowPreviousNodeOutputRef,
|
||||
)
|
||||
from models.workflow import Workflow
|
||||
@@ -56,6 +57,7 @@ class WorkflowAgentPublishService:
|
||||
_AGENT_BINDING_KEY = "agent_binding"
|
||||
_AGENT_TASK_KEY = "agent_task"
|
||||
_AGENT_DECLARED_OUTPUTS_KEY = "agent_declared_outputs"
|
||||
_AGENT_OUTPUT_ROUTES_KEY = "agent_output_routes"
|
||||
|
||||
@classmethod
|
||||
def project_draft_bindings_to_graph(cls, *, session: Session, draft_workflow: Workflow) -> dict[str, Any]:
|
||||
@@ -99,6 +101,7 @@ class WorkflowAgentPublishService:
|
||||
node_job = WorkflowNodeJobConfig.model_validate(binding.node_job_config_dict)
|
||||
if node_job.workflow_prompt is not None:
|
||||
node_data[cls._AGENT_TASK_KEY] = node_job.workflow_prompt
|
||||
node_data[cls._AGENT_OUTPUT_ROUTES_KEY] = node_job.output_routes.model_dump(mode="json")
|
||||
node_data[cls._AGENT_DECLARED_OUTPUTS_KEY] = [
|
||||
output.model_dump(mode="json") for output in node_job.declared_outputs
|
||||
]
|
||||
@@ -583,6 +586,7 @@ class WorkflowAgentPublishService:
|
||||
except ValidationError as exc:
|
||||
raise ValueError("Workflow Agent node has invalid agent_declared_outputs.") from exc
|
||||
|
||||
node_job.output_routes = WorkflowOutputRoutes.model_validate(node_data.get(cls._AGENT_OUTPUT_ROUTES_KEY, {}))
|
||||
return node_job
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -780,7 +780,7 @@ class NodeOutputInspectorService:
|
||||
"NodeOutputInspector: malformed node_job_config for binding %s", bundle.binding.id, exc_info=True
|
||||
)
|
||||
return None
|
||||
return list(effective_declared_outputs(node_job.declared_outputs))
|
||||
return list(effective_declared_outputs(node_job.declared_outputs, node_job.output_routes))
|
||||
|
||||
@staticmethod
|
||||
def _infer_outputs_from_payload(*, execution: WorkflowNodeExecutionModel | None) -> list[_ResolvedDeclaration]:
|
||||
|
||||
@@ -52,7 +52,13 @@ from core.workflow.nodes.agent_v2.session_store import (
|
||||
from core.workflow.nodes.human_input.pause_reason import HumanInputRequired
|
||||
from graphon.entities import GraphInitParams
|
||||
from graphon.entities.pause_reason import HitlRequired
|
||||
from graphon.enums import BuiltinNodeTypes, WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus
|
||||
from graphon.enums import (
|
||||
BuiltinNodeTypes,
|
||||
ErrorStrategy,
|
||||
NodeExecutionType,
|
||||
WorkflowNodeExecutionMetadataKey,
|
||||
WorkflowNodeExecutionStatus,
|
||||
)
|
||||
from graphon.file import File, FileTransferMethod, FileType
|
||||
from graphon.graph_events import NodeRunPauseRequestedEvent
|
||||
from graphon.node_events import StreamCompletedEvent
|
||||
@@ -404,6 +410,7 @@ def _node(
|
||||
agent_backend_client: FakeAgentBackendRunClient | None = None,
|
||||
binding_resolver: FakeBindingResolver | None = None,
|
||||
runtime_request_builder: WorkflowAgentRuntimeRequestBuilder | None = None,
|
||||
error_strategy: ErrorStrategy | None = None,
|
||||
) -> DifyAgentNode:
|
||||
graph_init_params = GraphInitParams(
|
||||
workflow_id="workflow-1",
|
||||
@@ -440,7 +447,16 @@ def _node(
|
||||
node = DifyAgentNode(
|
||||
node_id="agent-node",
|
||||
data=DifyAgentNodeData.model_validate(
|
||||
{"type": BuiltinNodeTypes.AGENT, "version": "2", "agent_node_kind": "dify_agent"}
|
||||
{
|
||||
"type": BuiltinNodeTypes.AGENT,
|
||||
"version": "2",
|
||||
"agent_node_kind": "dify_agent",
|
||||
"agent_output_routes": binding_resolver.binding.node_job_config.output_routes.model_dump(),
|
||||
"error_strategy": error_strategy,
|
||||
"default_value": [{"key": "text", "type": "string", "value": "fallback"}]
|
||||
if error_strategy == ErrorStrategy.DEFAULT_VALUE
|
||||
else [],
|
||||
}
|
||||
),
|
||||
graph_init_params=graph_init_params,
|
||||
graph_runtime_state=cast(
|
||||
@@ -1114,3 +1130,224 @@ def test_agent_node_records_stream_usage_metadata():
|
||||
assert agent_backend["last_stream_event_id"] == "1-1"
|
||||
assert agent_backend["last_stream_event_kind"] == "model_response"
|
||||
assert agent_backend["usage"] == {"prompt_tokens": 3, "completion_tokens": 4, "total_tokens": 7}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("enabled", "count", "output", "expected_handle"),
|
||||
[
|
||||
(False, 2, "hello", "source"),
|
||||
(True, 2, {"text": "hello", "switch": "route-1"}, "route-1"),
|
||||
],
|
||||
)
|
||||
def test_agent_node_selects_the_configured_success_exit(enabled, count, output, expected_handle):
|
||||
resolver = FakeBindingResolver()
|
||||
resolver.binding.node_job_config = WorkflowNodeJobConfig.model_validate(
|
||||
{
|
||||
"output_routes": {
|
||||
"enabled": enabled,
|
||||
"routes": [{"id": f"route-{i}", "name": "Condition"} for i in range(count)],
|
||||
}
|
||||
}
|
||||
)
|
||||
node = _node(binding_resolver=resolver, agent_backend_client=FileOutputBackendClient(output_payload=output))
|
||||
result = list(node._run())[-1].node_run_result
|
||||
assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED
|
||||
assert result.edge_source_handle == expected_handle
|
||||
assert result.outputs == (output if isinstance(output, dict) else {"text": output})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("enabled", "error_strategy", "expected_execution_type"),
|
||||
[
|
||||
(False, None, NodeExecutionType.EXECUTABLE),
|
||||
(True, None, NodeExecutionType.BRANCH),
|
||||
(False, ErrorStrategy.DEFAULT_VALUE, NodeExecutionType.EXECUTABLE),
|
||||
(True, ErrorStrategy.FAIL_BRANCH, NodeExecutionType.BRANCH),
|
||||
(True, ErrorStrategy.DEFAULT_VALUE, None),
|
||||
],
|
||||
)
|
||||
def test_agent_routes_initialize_through_workflow_node_factory(
|
||||
enabled, error_strategy, expected_execution_type, config_overrides
|
||||
):
|
||||
from core.workflow.node_factory import DifyNodeFactory
|
||||
|
||||
config_overrides(AGENT_BACKEND_USE_FAKE=True)
|
||||
template = _node()
|
||||
factory = DifyNodeFactory(template.graph_init_params, template.graph_runtime_state)
|
||||
node_data = template.node_data.model_dump(mode="python", by_alias=True)
|
||||
node_data.update(
|
||||
agent_output_routes={
|
||||
"enabled": enabled,
|
||||
"routes": [{"id": "a", "name": "A"}, {"id": "b", "name": "B"}],
|
||||
},
|
||||
error_strategy=error_strategy,
|
||||
)
|
||||
# Exercise the production factory, which serializes data before construction.
|
||||
node_config = {"id": "agent-node", "data": node_data}
|
||||
if expected_execution_type is None:
|
||||
with pytest.raises(ValueError, match="default-value"):
|
||||
factory.create_node(node_config)
|
||||
return
|
||||
|
||||
node = factory.create_node(node_config)
|
||||
assert isinstance(node, DifyAgentNode)
|
||||
assert node.execution_type == expected_execution_type
|
||||
assert node.node_data.agent_output_routes.enabled is enabled
|
||||
|
||||
|
||||
def test_agent_routes_use_the_successful_retry_to_fan_out_and_skip_other_branches():
|
||||
from graphon.enums import NodeState
|
||||
from graphon.graph.edge import Edge
|
||||
from graphon.graph.graph import Graph
|
||||
from graphon.graph_engine.graph_state_manager import GraphStateManager
|
||||
from graphon.graph_engine.graph_traversal.edge_processor import EdgeProcessor
|
||||
from graphon.graph_engine.graph_traversal.skip_propagator import SkipPropagator
|
||||
from graphon.graph_events.traversal import GraphEdgeSkippedEvent, GraphEdgeTakenEvent
|
||||
from graphon.nodes.end.end_node import EndNode
|
||||
from graphon.nodes.end.entities import EndNodeData
|
||||
|
||||
outputs = iter([{"score": "invalid", "switch": "a"}, {"score": 42, "switch": "b"}])
|
||||
|
||||
class RetryingBackendClient(FileOutputBackendClient):
|
||||
def _events(self, run_id: str):
|
||||
self.output_payload = next(outputs)
|
||||
return super()._events(run_id)
|
||||
|
||||
resolver = FakeBindingResolver()
|
||||
resolver.binding.node_job_config = WorkflowNodeJobConfig.model_validate(
|
||||
{
|
||||
"declared_outputs": [
|
||||
{
|
||||
"name": "score",
|
||||
"type": "number",
|
||||
"failure_strategy": {"retry": {"enabled": True, "max_retries": 1}},
|
||||
}
|
||||
],
|
||||
"output_routes": {
|
||||
"enabled": True,
|
||||
"routes": [{"id": "a", "name": "A"}, {"id": "b", "name": "B"}],
|
||||
},
|
||||
}
|
||||
)
|
||||
node = _node(binding_resolver=resolver, agent_backend_client=RetryingBackendClient(output_payload={}))
|
||||
result = list(node._run())[-1].node_run_result
|
||||
assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED
|
||||
assert result.outputs["score"] == 42
|
||||
|
||||
edges = {
|
||||
target: Edge(id=target, tail="agent-node", head=target, source_handle=handle)
|
||||
for target, handle in [("a-end", "a"), ("b-end-1", "b"), ("b-end-2", "b")]
|
||||
}
|
||||
leaves = {
|
||||
target: EndNode(
|
||||
node_id=target,
|
||||
data=EndNodeData(title=target, outputs=[]),
|
||||
graph_init_params=node.graph_init_params,
|
||||
graph_runtime_state=node.graph_runtime_state,
|
||||
)
|
||||
for target in edges
|
||||
}
|
||||
graph = Graph(
|
||||
root_node=node,
|
||||
nodes={"agent-node": node, **leaves},
|
||||
edges=edges,
|
||||
out_edges={"agent-node": list(edges)},
|
||||
in_edges={target: [target] for target in edges},
|
||||
)
|
||||
state = GraphStateManager(graph, node.graph_runtime_state, "root")
|
||||
ready, traversed = EdgeProcessor(graph, state, SkipPropagator(graph, state)).process_node_success(
|
||||
"agent-node", result.edge_source_handle
|
||||
)
|
||||
assert ready == ["b-end-1", "b-end-2"]
|
||||
assert [event.edge_id for event in traversed if isinstance(event, GraphEdgeTakenEvent)] == ["b-end-1", "b-end-2"]
|
||||
assert [event.edge_id for event in traversed if isinstance(event, GraphEdgeSkippedEvent)] == ["a-end"]
|
||||
assert leaves["a-end"].state == NodeState.SKIPPED
|
||||
|
||||
|
||||
@pytest.mark.parametrize("output", ["plain text", {}, {"switch": None}, {"switch": 1}, {"switch": "unknown"}])
|
||||
def test_agent_node_rejects_invalid_route_selection_without_custom_outputs(output):
|
||||
resolver = FakeBindingResolver()
|
||||
resolver.binding.node_job_config = WorkflowNodeJobConfig.model_validate(
|
||||
{
|
||||
"output_routes": {
|
||||
"enabled": True,
|
||||
"routes": [{"id": "a", "name": "A"}, {"id": "b", "name": "B"}],
|
||||
}
|
||||
}
|
||||
)
|
||||
node = _node(binding_resolver=resolver, agent_backend_client=FileOutputBackendClient(output_payload=output))
|
||||
result = list(node._run())[-1].node_run_result
|
||||
assert result.status == WorkflowNodeExecutionStatus.FAILED
|
||||
assert result.error_type == "output_route_selection_failed"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("enabled", "count", "expected"),
|
||||
[
|
||||
(False, 2, {}),
|
||||
(
|
||||
True,
|
||||
2,
|
||||
{
|
||||
"agent-node.previous.report": ["previous", "report"],
|
||||
"agent-node.env.threshold": ["env", "threshold"],
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_agent_route_conditions_load_variables_only_when_routing_is_enabled(enabled, count, expected):
|
||||
mapping = DifyAgentNode._extract_variable_selector_to_variable_mapping(
|
||||
graph_config={},
|
||||
node_id="agent-node",
|
||||
node_data={
|
||||
"agent_output_routes": {
|
||||
"enabled": enabled,
|
||||
"routes": [{"id": str(i), "name": "{{#previous.report#}} {{#env.threshold#}}"} for i in range(count)],
|
||||
}
|
||||
},
|
||||
)
|
||||
assert mapping == expected
|
||||
|
||||
|
||||
def test_disabled_routes_continue_through_graphon_default_values():
|
||||
from graphon.graph.edge import Edge
|
||||
from graphon.graph.graph import Graph
|
||||
from graphon.graph_engine.error_handler import ErrorHandler
|
||||
from graphon.graph_engine.graph_traversal.edge_processor import EdgeProcessor
|
||||
from graphon.graph_events.node import NodeRunExceptionEvent, NodeRunFailedEvent
|
||||
from graphon.node_events import NodeRunResult
|
||||
|
||||
node = _node(error_strategy=ErrorStrategy.DEFAULT_VALUE)
|
||||
edges = {target: Edge(id=target, tail="agent-node", head=target) for target in ["next-a", "next-b"]}
|
||||
graph = Graph(root_node=node, nodes={"agent-node": node}, edges=edges, out_edges={"agent-node": list(edges)})
|
||||
failure = NodeRunFailedEvent(
|
||||
id="execution",
|
||||
node_id="agent-node",
|
||||
node_type=BuiltinNodeTypes.AGENT,
|
||||
start_at=datetime.now(UTC),
|
||||
finished_at=datetime.now(UTC),
|
||||
error="backend failed",
|
||||
node_run_result=NodeRunResult(status=WorkflowNodeExecutionStatus.FAILED, error="backend failed"),
|
||||
)
|
||||
result = ErrorHandler(graph, MagicMock()).handle_node_failure(frame_id="root", event=failure)
|
||||
assert isinstance(result, NodeRunExceptionEvent)
|
||||
assert result.node_run_result.outputs["text"] == "fallback"
|
||||
ready, traversed = EdgeProcessor(graph, MagicMock(), MagicMock()).process_node_success(
|
||||
"agent-node", result.node_run_result.edge_source_handle
|
||||
)
|
||||
assert ready == ["next-a", "next-b"]
|
||||
assert [event.edge_id for event in traversed] == ["next-a", "next-b"]
|
||||
|
||||
|
||||
def test_enabled_routes_reject_default_value_before_execution():
|
||||
resolver = FakeBindingResolver()
|
||||
resolver.binding.node_job_config = WorkflowNodeJobConfig.model_validate(
|
||||
{
|
||||
"output_routes": {
|
||||
"enabled": True,
|
||||
"routes": [{"id": "accepted", "name": "Accept"}, {"id": "rejected", "name": "Reject"}],
|
||||
}
|
||||
}
|
||||
)
|
||||
with pytest.raises(ValueError, match="default-value"):
|
||||
_node(binding_resolver=resolver, error_strategy=ErrorStrategy.DEFAULT_VALUE)
|
||||
@@ -1701,3 +1701,27 @@ def test_feature_manifest_treats_empty_knowledge_sets_as_not_configured():
|
||||
|
||||
manifest = build_runtime_feature_manifest(soul)
|
||||
assert manifest["reserved_status"]["knowledge"] == "not_configured"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("custom", [False, True])
|
||||
def test_runtime_request_adds_required_route_selection_with_resolved_conditions(custom):
|
||||
context = _context()
|
||||
context.binding.node_job_config = WorkflowNodeJobConfig.model_validate(
|
||||
{
|
||||
"declared_outputs": [{"name": "summary", "type": "string"}] if custom else [],
|
||||
"output_routes": {
|
||||
"enabled": True,
|
||||
"routes": [
|
||||
{"id": "accept", "name": "Matches {{#sys.query#}}", "label": "Accepted"},
|
||||
{"id": "reject", "name": "Does not match"},
|
||||
],
|
||||
},
|
||||
}
|
||||
)
|
||||
result = WorkflowAgentRuntimeRequestBuilder(dify_tools_builder=CapturingPluginLayerBuilder()).build(context)
|
||||
schema = _request_layers(result)[DIFY_AGENT_OUTPUT_LAYER_ID]["config"]["json_schema"]
|
||||
assert schema["properties"]["switch"]["enum"] == ["accept", "reject"]
|
||||
assert "Accepted" in schema["properties"]["switch"]["description"]
|
||||
assert "Matches Summarize the report." in schema["properties"]["switch"]["description"]
|
||||
assert schema["required"] == (["switch", "summary"] if custom else ["switch"])
|
||||
assert schema["properties"]["text"] == {"type": "string"}
|
||||
@@ -734,3 +734,56 @@ def test_publish_validation_rejects_unauthorized_tool_node_agentic_config(unboun
|
||||
session=unbound_session,
|
||||
workflow=_workflow(_tool_graph({"agentic_mode": {"state": "agentic", "permission": {"allowed": False}}})),
|
||||
)
|
||||
|
||||
|
||||
def test_incomplete_route_conditions_can_be_saved_but_not_published(sqlite_session: Session):
|
||||
routes = [{"id": "a", "name": ""}, {"id": "b", "name": ""}]
|
||||
job = WorkflowNodeJobConfig.model_validate({"output_routes": {"enabled": True, "routes": routes}})
|
||||
_persist_validation_scope(sqlite_session, node_job=job)
|
||||
workflow = _workflow(_graph([{"source": "start", "target": "agent-node"}]))
|
||||
WorkflowAgentNodeValidator.validate_draft_workflow(session=sqlite_session, workflow=workflow)
|
||||
with pytest.raises(ValueError, match="route"):
|
||||
WorkflowAgentNodeValidator.validate_published_workflow(session=sqlite_session, workflow=workflow)
|
||||
|
||||
|
||||
def test_route_conditions_must_reference_upstream_nodes(sqlite_session: Session):
|
||||
job = WorkflowNodeJobConfig.model_validate(
|
||||
{
|
||||
"output_routes": {
|
||||
"enabled": True,
|
||||
"routes": [
|
||||
{"id": "a", "name": "{{#later-node.text#}}"},
|
||||
{"id": "b", "name": "Otherwise"},
|
||||
],
|
||||
}
|
||||
}
|
||||
)
|
||||
_persist_validation_scope(sqlite_session, node_job=job)
|
||||
with pytest.raises(WorkflowAgentNodeValidationError, match="non-upstream"):
|
||||
WorkflowAgentNodeValidator.validate_published_workflow(
|
||||
session=sqlite_session,
|
||||
workflow=_workflow(
|
||||
_graph(
|
||||
[
|
||||
{"source": "start", "target": "agent-node"},
|
||||
{"source": "agent-node", "target": "later-node"},
|
||||
]
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_publishing_routes_rejects_node_level_default_values(sqlite_session: Session):
|
||||
node_job = WorkflowNodeJobConfig.model_validate(
|
||||
{
|
||||
"output_routes": {
|
||||
"enabled": True,
|
||||
"routes": [{"id": "accepted", "name": "Accept"}, {"id": "rejected", "name": "Reject"}],
|
||||
}
|
||||
}
|
||||
)
|
||||
_persist_validation_scope(sqlite_session, node_job=node_job)
|
||||
graph = _graph([{"source": "start", "target": "agent-node"}])
|
||||
graph["nodes"][2]["data"]["error_strategy"] = "default-value"
|
||||
with pytest.raises(WorkflowAgentNodeValidationError, match="default-value"):
|
||||
WorkflowAgentNodeValidator.validate_published_workflow(session=sqlite_session, workflow=_workflow(graph))
|
||||
@@ -246,3 +246,33 @@ def test_effective_declared_outputs_prepends_optional_system_text() -> None:
|
||||
assert [output.name for output in effective] == ["text", "summary"]
|
||||
assert effective[0].type == DeclaredOutputType.STRING
|
||||
assert effective[0].required is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("enabled", "count", "expected"),
|
||||
[(False, 0, ["text"]), (False, 1, ["text"]), (False, 2, ["text"]), (True, 2, ["text", "switch"])],
|
||||
)
|
||||
def test_effective_outputs_follow_route_selection_contract(enabled: bool, count: int, expected: list[str]) -> None:
|
||||
job = WorkflowNodeJobConfig.model_validate(
|
||||
{
|
||||
"output_routes": {
|
||||
"enabled": enabled,
|
||||
"routes": [{"id": str(index), "name": "condition"} for index in range(count)],
|
||||
}
|
||||
}
|
||||
)
|
||||
assert [output.name for output in effective_declared_outputs([], job.output_routes)] == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("count", [0, 1])
|
||||
def test_enabled_output_routes_require_at_least_two_routes(count: int) -> None:
|
||||
with pytest.raises(ValueError, match="at least two routes"):
|
||||
WorkflowNodeJobConfig.model_validate(
|
||||
{"output_routes": {"enabled": True, "routes": [{"id": str(index)} for index in range(count)]}}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ids", [[""], ["source"], ["fail-branch"], ["target"], ["same", "same"]])
|
||||
def test_output_routes_reject_invalid_handle_identities(ids: list[str]) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
WorkflowNodeJobConfig.model_validate({"output_routes": {"routes": [{"id": id, "name": ""} for id in ids]}})
|
||||
@@ -2650,7 +2650,18 @@ def test_node_job_only_updates_inline_agent_soul(monkeypatch: pytest.MonkeyPatch
|
||||
binding_type=WorkflowAgentBindingType.INLINE_AGENT,
|
||||
agent_id="inline-agent-1",
|
||||
current_snapshot_id="inline-version-1",
|
||||
node_job_config=WorkflowNodeJobConfig.model_validate(
|
||||
{
|
||||
"workflow_prompt": "use prior output",
|
||||
"declared_outputs": [{"name": "summary", "type": "string"}],
|
||||
"output_routes": {
|
||||
"enabled": True,
|
||||
"routes": [{"id": "accepted", "name": "Accept"}, {"id": "rejected", "name": "Reject"}],
|
||||
},
|
||||
}
|
||||
),
|
||||
)
|
||||
original_node_job = binding.node_job_config_dict
|
||||
payload = ComposerSavePayload.model_validate(
|
||||
{
|
||||
"variant": ComposerVariant.WORKFLOW.value,
|
||||
@@ -2663,7 +2674,6 @@ def test_node_job_only_updates_inline_agent_soul(monkeypatch: pytest.MonkeyPatch
|
||||
},
|
||||
"prompt": {"system_prompt": "new"},
|
||||
},
|
||||
"node_job": {"workflow_prompt": "use prior output"},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -2679,7 +2689,7 @@ def test_node_job_only_updates_inline_agent_soul(monkeypatch: pytest.MonkeyPatch
|
||||
)
|
||||
|
||||
assert updated_binding.current_snapshot_id == "inline-version-2"
|
||||
assert updated_binding.node_job_config_dict["workflow_prompt"] == "use prior output"
|
||||
assert updated_binding.node_job_config_dict == original_node_job
|
||||
assert updated_binding.updated_by == "account-1"
|
||||
assert inline_agent.active_config_snapshot_id == "inline-version-2"
|
||||
assert inline_agent.active_config_has_model is True
|
||||
@@ -6993,3 +7003,40 @@ def test_resolve_workflow_node_agent_id_degrades_without_workflow_or_binding(
|
||||
AgentComposerService.resolve_workflow_node_agent_id(session=session, tenant_id="t", app_id="a", node_id="n")
|
||||
== "agent-7"
|
||||
)
|
||||
|
||||
|
||||
def test_save_as_new_agent_preserves_omitted_node_job(monkeypatch: pytest.MonkeyPatch):
|
||||
job = WorkflowNodeJobConfig.model_validate(
|
||||
{
|
||||
"workflow_prompt": "Keep this task",
|
||||
"output_routes": {
|
||||
"enabled": True,
|
||||
"routes": [{"id": "accepted", "name": "Accept"}, {"id": "rejected", "name": "Reject"}],
|
||||
},
|
||||
}
|
||||
)
|
||||
binding = WorkflowAgentNodeBinding(agent_id="old-agent", current_snapshot_id="old-snapshot", node_job_config=job)
|
||||
monkeypatch.setattr(
|
||||
AgentComposerService,
|
||||
"_create_roster_agent_for_composer",
|
||||
lambda **kwargs: Agent(id="new-agent", active_config_snapshot_id="new-snapshot"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"services.agent.composer_service.SkillManagementService.copy_agent_bindings", lambda self, **kwargs: None
|
||||
)
|
||||
result = AgentComposerService._save_as_new_agent(
|
||||
session=MagicMock(spec=Session),
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
workflow_id="workflow-1",
|
||||
node_id="node-1",
|
||||
account_id="account-1",
|
||||
binding=binding,
|
||||
payload=ComposerSavePayload(
|
||||
variant=ComposerVariant.WORKFLOW,
|
||||
save_strategy=ComposerSaveStrategy.SAVE_AS_NEW_AGENT,
|
||||
agent_soul=AgentSoulConfig(),
|
||||
),
|
||||
)
|
||||
assert result.agent_id == "new-agent"
|
||||
assert result.node_job_config_dict == job.model_dump(mode="json")
|
||||
@@ -524,3 +524,56 @@ def test_restore_clones_inline_binding_owned_by_published_workflow(
|
||||
assert restored is not None
|
||||
assert restored.agent_id == "draft-agent"
|
||||
assert restored.current_snapshot_id == "draft-snapshot"
|
||||
|
||||
|
||||
def test_output_routes_round_trip_through_sync_projection_and_publication(sqlite_session: Session):
|
||||
import json
|
||||
|
||||
from models.agent_config_entities import WorkflowNodeJobConfig
|
||||
|
||||
agent = _inline_agent(agent_id="route-agent", workflow_id="workflow-1", node_id="route-node")
|
||||
snapshot = _snapshot(snapshot_id="route-snapshot", agent_id=agent.id)
|
||||
sqlite_session.add_all([agent, snapshot])
|
||||
sqlite_session.flush()
|
||||
routes = {
|
||||
"enabled": True,
|
||||
"routes": [
|
||||
{"id": "accept", "name": "Accepted", "label": "Continue"},
|
||||
{"id": "reject", "name": "Rejected", "label": None},
|
||||
],
|
||||
}
|
||||
node_data = {
|
||||
"type": "agent",
|
||||
"version": "2",
|
||||
"agent_node_kind": "dify_agent",
|
||||
"agent_output_routes": routes,
|
||||
"agent_binding": {"binding_type": "inline_agent", "agent_id": agent.id, "current_snapshot_id": snapshot.id},
|
||||
}
|
||||
workflow = _workflow()
|
||||
workflow.graph = json.dumps({"nodes": [{"id": "route-node", "data": node_data}], "edges": []})
|
||||
WorkflowAgentPublishService.sync_agent_bindings_for_draft(
|
||||
session=sqlite_session, draft_workflow=workflow, account_id="account-1"
|
||||
)
|
||||
projected = WorkflowAgentPublishService.project_draft_bindings_to_graph(
|
||||
session=sqlite_session, draft_workflow=workflow
|
||||
)
|
||||
assert projected["nodes"][0]["data"]["agent_output_routes"] == routes
|
||||
published = _workflow(workflow_id="published-routes", version="published-routes")
|
||||
WorkflowAgentPublishService.copy_agent_node_bindings_to_published(
|
||||
session=sqlite_session, draft_workflow=workflow, published_workflow=published
|
||||
)
|
||||
sqlite_session.flush()
|
||||
frozen = sqlite_session.scalar(
|
||||
select(WorkflowAgentNodeBinding).where(WorkflowAgentNodeBinding.workflow_id == published.id)
|
||||
)
|
||||
assert WorkflowNodeJobConfig.model_validate(frozen.node_job_config_dict).output_routes.model_dump() == routes
|
||||
node_data["agent_output_routes"] = {"enabled": False, "routes": routes["routes"]}
|
||||
workflow.graph = json.dumps({"nodes": [{"id": "route-node", "data": node_data}], "edges": []})
|
||||
WorkflowAgentPublishService.sync_agent_bindings_for_draft(
|
||||
session=sqlite_session, draft_workflow=workflow, account_id="account-1"
|
||||
)
|
||||
projected = WorkflowAgentPublishService.project_draft_bindings_to_graph(
|
||||
session=sqlite_session, draft_workflow=workflow
|
||||
)
|
||||
assert projected["nodes"][0]["data"]["agent_output_routes"]["enabled"] is False
|
||||
assert WorkflowNodeJobConfig.model_validate(frozen.node_job_config_dict).output_routes.model_dump() == routes
|
||||
@@ -4,6 +4,10 @@
|
||||
E2E_BASE_URL=http://127.0.0.1:3000
|
||||
E2E_API_URL=http://127.0.0.1:5001
|
||||
|
||||
# Optional: an existing E2E workflow in the Cloud template catalog for
|
||||
# @cloud-catalog-runtime. See e2e/AGENTS.md for the required published graph.
|
||||
# E2E_AGENT_OUTPUT_ROUTES_PREVIEW_APP_ID=
|
||||
|
||||
E2E_STABLE_MODEL_PROVIDER=openai
|
||||
E2E_STABLE_MODEL_NAME=gpt-5-nano
|
||||
E2E_STABLE_MODEL_TYPE=llm
|
||||
|
||||
@@ -45,6 +45,7 @@ An uninitialized instance is installed and authenticated lazily; an initialized
|
||||
- `@external-model` and `@external-tool` identify scenarios that call real external runtimes. Deterministic commands exclude these tags; external commands are opt-in.
|
||||
- `@microphone` uses the checked-in fake audio fixture and an isolated Chromium context.
|
||||
- `@browser-smoke` runs focused keyboard and navigation coverage in Chromium and WebKit CI lanes.
|
||||
- `@cloud-catalog-runtime` requires an initialized Cloud runtime and an existing E2E workflow in its template catalog. It is excluded from default functional runs. Set `E2E_AGENT_OUTPUT_ROUTES_PREVIEW_APP_ID` to a published workflow with labeled Agent output routes, connections for every route, and a connected failure branch, then run `pnpm -C e2e e2e -- --tags @cloud-catalog-runtime`. Do not use `--full`: Cloud disables local setup. The scenario validates the fixture through the generated catalog and trial-app APIs and never modifies or deletes it. Catalog administrators own fixture preparation; for a local database catalog, prepare and publish the app under `COMMUNITY` before switching the API to `CLOUD` with `HOSTED_FETCH_APP_TEMPLATES_MODE=db`.
|
||||
- `@skip` temporarily excludes a scenario from every runner profile. Remove it as soon as the covered product behavior is available again; do not use it for permanent or environment-dependent suppression.
|
||||
- Feature-owned services use their own tags. Agent v2 runtime scenarios use `@agent-backend-runtime` and require the explicit runtime-availability step. Set `E2E_START_AGENT_BACKEND=1` to start it locally, or provide `E2E_AGENT_BACKEND_URL` / `AGENT_BACKEND_BASE_URL`.
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import './scripts/env-register.ts'
|
||||
|
||||
const hasCliTags = process.argv.some((arg) => arg === '--tags' || arg.startsWith('--tags='))
|
||||
const defaultNonExternalTags =
|
||||
'not @axe and not @prepared and not @external-model and not @external-tool'
|
||||
'not @axe and not @prepared and not @external-model and not @external-tool and not @cloud-catalog-runtime'
|
||||
const selectedTags =
|
||||
process.env.E2E_CUCUMBER_TAGS || (hasCliTags ? undefined : defaultNonExternalTags)
|
||||
const tags = selectedTags ? `(${selectedTags}) and not @skip` : 'not @skip'
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
@agent-v2 @authenticated @core @output-routes
|
||||
Feature: Agent v2 output routes across workflow surfaces
|
||||
@output-route-sort
|
||||
Scenario: Dragging output routes preserves their saved order
|
||||
Given I am signed in as the default E2E admin
|
||||
And a workflow with two Agent v2 output routes and a failure branch has been created via API
|
||||
When I open the app from the app list
|
||||
And I open the Agent v2 workflow node panel
|
||||
And I drag the Rejected Agent output route above Accepted
|
||||
Then the Agent output route order should be saved as Rejected then Accepted
|
||||
When I refresh the current page
|
||||
And I open the Agent v2 workflow node panel
|
||||
Then the Agent output route editor should show Rejected before Accepted
|
||||
|
||||
@output-route-snippet
|
||||
Scenario: Inserting a routed Agent snippet leaves its route destinations for the user to choose
|
||||
Given I am signed in as the default E2E admin
|
||||
And a published snippet ending in an Agent v2 with output routes has been created via API
|
||||
And a "workflow" app has been created via API
|
||||
And a minimal runnable workflow draft has been synced
|
||||
When I open the app from the app list
|
||||
And I insert the routed Agent snippet between Start and End
|
||||
Then the inserted Agent should receive the Start connection and leave its output routes unconnected
|
||||
|
||||
@output-route-undo
|
||||
Scenario: One undo restores output routes and their connections after disabling them
|
||||
Given I am signed in as the default E2E admin
|
||||
And a workflow with two Agent v2 output routes and a failure branch has been created via API
|
||||
When I open the app from the app list
|
||||
And I open the Agent v2 workflow node panel
|
||||
And I disable the Agent output routes and undo the saved change once
|
||||
And I open the Agent v2 workflow node panel
|
||||
Then the Agent output routes and both successful connections should be restored
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { SyncDraftWorkflowPayload } from '@dify/contracts/api/console/apps/types.gen'
|
||||
|
||||
export const agentOutputRoutes = [
|
||||
{ id: 'accepted', label: 'Accepted', name: 'The request is accepted.' },
|
||||
{ id: 'rejected', label: 'Rejected', name: 'The request is rejected.' },
|
||||
]
|
||||
|
||||
export function createRoutedAgentNode(agentId: string) {
|
||||
return {
|
||||
id: 'agent-v2',
|
||||
type: 'custom',
|
||||
position: { x: 400, y: 180 },
|
||||
data: {
|
||||
id: 'agent-v2',
|
||||
type: 'agent',
|
||||
title: 'Agent',
|
||||
desc: '',
|
||||
agent_binding: { binding_type: 'roster_agent', agent_id: agentId },
|
||||
agent_node_kind: 'dify_agent',
|
||||
version: '2',
|
||||
agent_task: 'Decide whether the request is accepted or rejected.',
|
||||
agent_output_routes: { enabled: true, routes: agentOutputRoutes },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function createRoutedAgentWorkflowDraft(agentId: string): SyncDraftWorkflowPayload {
|
||||
const agent = createRoutedAgentNode(agentId)
|
||||
const destinations = [
|
||||
...agentOutputRoutes.map((route) => ({ id: route.id, title: route.label })),
|
||||
{ id: 'fail-branch', title: 'Failure' },
|
||||
]
|
||||
|
||||
return {
|
||||
graph: {
|
||||
nodes: [
|
||||
{
|
||||
id: 'start',
|
||||
type: 'custom',
|
||||
position: { x: 50, y: 180 },
|
||||
data: { id: 'start', type: 'start', title: 'Start', variables: [] },
|
||||
},
|
||||
{ ...agent, data: { ...agent.data, error_strategy: 'fail-branch' } },
|
||||
...destinations.map((destination, index) => ({
|
||||
id: `${destination.id}-end`,
|
||||
type: 'custom',
|
||||
position: { x: 800, y: 50 + index * 190 },
|
||||
data: {
|
||||
id: `${destination.id}-end`,
|
||||
type: 'end',
|
||||
title: `${destination.title} result`,
|
||||
outputs: [],
|
||||
},
|
||||
})),
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
id: 'start-agent',
|
||||
type: 'custom',
|
||||
source: 'start',
|
||||
target: agent.id,
|
||||
sourceHandle: 'source',
|
||||
targetHandle: 'target',
|
||||
data: { sourceType: 'start', targetType: 'agent' },
|
||||
},
|
||||
...destinations.map((destination) => ({
|
||||
id: `agent-${destination.id}`,
|
||||
type: 'custom',
|
||||
source: agent.id,
|
||||
target: `${destination.id}-end`,
|
||||
sourceHandle: destination.id,
|
||||
targetHandle: 'target',
|
||||
data: { sourceType: 'agent', targetType: 'end' },
|
||||
})),
|
||||
],
|
||||
viewport: { x: 40, y: 40, zoom: 0.7 },
|
||||
},
|
||||
features: {},
|
||||
conversation_variables: [],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
@workflow-preview @agent-v2 @authenticated @cloud-catalog-runtime
|
||||
Feature: Workflow template orchestration preview
|
||||
Scenario: A routed Agent displays its successful and failure connections in the template preview
|
||||
Given I am signed in as the default E2E admin
|
||||
And the Cloud catalog contains a published workflow with Agent output routes and a failure branch
|
||||
When I open the routed workflow template orchestration details
|
||||
Then the template preview should show each Agent output route and its connection alongside the failure branch
|
||||
@@ -0,0 +1,214 @@
|
||||
import type { DifyWorld } from '../../support/world.ts'
|
||||
import { Given, Then, When } from '@cucumber/cucumber'
|
||||
import { zWorkflowOutputRoutes } from '@dify/contracts/api/console/apps/zod.gen'
|
||||
import { expect } from '@playwright/test'
|
||||
import * as z from 'zod'
|
||||
import { createTestApp } from '../../../support/api/apps.ts'
|
||||
import { createE2EResourceName } from '../../../support/naming.ts'
|
||||
import {
|
||||
createConfiguredTestAgent,
|
||||
publishAgentWithPublishableDraft,
|
||||
} from '../../agent-v2/support/agent.ts'
|
||||
import {
|
||||
createRoutedAgentNode,
|
||||
createRoutedAgentWorkflowDraft,
|
||||
} from '../../agent-v2/support/output-routes.ts'
|
||||
import { getAgentV2WorkflowNodeData } from '../../agent-v2/support/workflow.ts'
|
||||
|
||||
const zSnippetInsertionGraph = z.object({
|
||||
nodes: z.array(z.object({ id: z.string(), data: z.object({ title: z.string() }) })),
|
||||
edges: z.array(z.object({ source: z.string(), target: z.string() })),
|
||||
})
|
||||
|
||||
Given(
|
||||
'a workflow with two Agent v2 output routes and a failure branch has been created via API',
|
||||
async function (this: DifyWorld) {
|
||||
const client = this.getConsoleClient()
|
||||
const agent = await createConfiguredTestAgent(client)
|
||||
this.createdAgentIds.push(agent.id)
|
||||
await publishAgentWithPublishableDraft(client, agent.id)
|
||||
|
||||
const app = await createTestApp(
|
||||
client,
|
||||
createE2EResourceName('App', 'agent-output-routes'),
|
||||
'workflow',
|
||||
)
|
||||
this.createdAppIds.push(app.id)
|
||||
this.lastCreatedAppName = app.name
|
||||
await client.apps.byAppId.workflows.draft.post({
|
||||
body: createRoutedAgentWorkflowDraft(agent.id),
|
||||
params: { app_id: app.id },
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
Given(
|
||||
'a published snippet ending in an Agent v2 with output routes has been created via API',
|
||||
async function (this: DifyWorld) {
|
||||
const client = this.getConsoleClient()
|
||||
const agent = await createConfiguredTestAgent(client)
|
||||
this.createdAgentIds.push(agent.id)
|
||||
await publishAgentWithPublishableDraft(client, agent.id)
|
||||
|
||||
const name = createE2EResourceName('Snippet', 'agent-output-routes')
|
||||
const imported = await client.workspaces.current.customizedSnippets.imports.post({
|
||||
body: {
|
||||
mode: 'yaml-content',
|
||||
yaml_content: JSON.stringify({
|
||||
version: '0.2.0',
|
||||
kind: 'snippet',
|
||||
snippet: { name, description: '', type: 'node' },
|
||||
workflow: {
|
||||
graph: {
|
||||
nodes: [createRoutedAgentNode(agent.id)],
|
||||
edges: [],
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
})
|
||||
if (!imported.snippet_id) throw new Error(`Snippet fixture import failed: ${imported.error}`)
|
||||
this.createdSnippetIds.push(imported.snippet_id)
|
||||
expect(imported.status).toBe('completed')
|
||||
this.agentBuilder.workflow.outputRouteSnippet = { id: imported.snippet_id, name }
|
||||
await client.snippets.bySnippetId.workflows.publish.post({
|
||||
body: {},
|
||||
params: { snippet_id: imported.snippet_id },
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
When('I drag the Rejected Agent output route above Accepted', async function (this: DifyWorld) {
|
||||
const page = this.getPage()
|
||||
const rejected = page.getByRole('button', { name: 'Rejected', exact: true })
|
||||
const accepted = page.getByRole('button', { name: 'Accepted', exact: true })
|
||||
|
||||
await rejected.dragTo(accepted)
|
||||
await expect(page.getByRole('button', { name: /^(Rejected|Accepted)$/ })).toHaveText([
|
||||
'Rejected',
|
||||
'Accepted',
|
||||
])
|
||||
})
|
||||
|
||||
Then(
|
||||
'the Agent output route order should be saved as Rejected then Accepted',
|
||||
async function (this: DifyWorld) {
|
||||
const appId = this.createdAppIds.at(-1)
|
||||
if (!appId) throw new Error('Create an Agent output routes workflow first.')
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const data = await getAgentV2WorkflowNodeData(this.getConsoleClient(), appId)
|
||||
return zWorkflowOutputRoutes
|
||||
.parse(data.agent_output_routes)
|
||||
.routes?.map((route) => route.id)
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toEqual(['rejected', 'accepted'])
|
||||
},
|
||||
)
|
||||
|
||||
When('I insert the routed Agent snippet between Start and End', async function (this: DifyWorld) {
|
||||
const page = this.getPage()
|
||||
const snippet = this.agentBuilder.workflow.outputRouteSnippet
|
||||
if (!snippet) throw new Error('Create an Agent output routes snippet first.')
|
||||
|
||||
const connection = page.getByRole('button', { name: 'Connection from Start to End', exact: true })
|
||||
await expect(connection).toBeVisible({ timeout: 30_000 })
|
||||
const bounds = await connection.boundingBox()
|
||||
if (!bounds) throw new Error('The Start to End connection has no visible bounds.')
|
||||
// The Add Node control overlays the middle of the connection when it is hovered.
|
||||
await connection.hover({ position: { x: bounds.width / 4, y: bounds.height / 2 } })
|
||||
await page
|
||||
.locator('.react-flow__edgelabel-renderer')
|
||||
.getByRole('button', { name: 'Add Node', exact: true })
|
||||
.click()
|
||||
await page.getByRole('tab', { name: 'Snippets', exact: true }).click()
|
||||
await page.getByRole('searchbox', { name: 'Search snippets...' }).fill(snippet.name)
|
||||
await page.getByRole('button', { name: snippet.name, exact: true }).click()
|
||||
await expect(page.getByTitle('Agent', { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
Then(
|
||||
'the inserted Agent should receive the Start connection and leave its output routes unconnected',
|
||||
async function (this: DifyWorld) {
|
||||
const appId = this.createdAppIds.at(-1)
|
||||
if (!appId) throw new Error('Create the destination workflow first.')
|
||||
|
||||
await expect(
|
||||
this.getPage().getByLabel('Connection from Start to Agent', { exact: true }),
|
||||
).toBeVisible()
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const draft = await this.getConsoleClient().apps.byAppId.workflows.draft.get({
|
||||
params: { app_id: appId },
|
||||
})
|
||||
const graph = zSnippetInsertionGraph.parse(draft.graph)
|
||||
const agent = graph.nodes.find((node) => node.data.title === 'Agent')
|
||||
return {
|
||||
inserted: !!agent,
|
||||
incoming: graph.edges.filter(
|
||||
(edge) => edge.source === 'start' && edge.target === agent?.id,
|
||||
).length,
|
||||
outgoing: graph.edges.filter((edge) => edge.source === agent?.id).length,
|
||||
}
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toEqual({ inserted: true, incoming: 1, outgoing: 0 })
|
||||
},
|
||||
)
|
||||
|
||||
Then(
|
||||
'the Agent output route editor should show Rejected before Accepted',
|
||||
async function (this: DifyWorld) {
|
||||
await expect(this.getPage().getByRole('button', { name: /^(Rejected|Accepted)$/ })).toHaveText([
|
||||
'Rejected',
|
||||
'Accepted',
|
||||
])
|
||||
},
|
||||
)
|
||||
|
||||
When(
|
||||
'I disable the Agent output routes and undo the saved change once',
|
||||
async function (this: DifyWorld) {
|
||||
const page = this.getPage()
|
||||
const appId = this.createdAppIds.at(-1)
|
||||
if (!appId) throw new Error('Create an Agent output routes workflow first.')
|
||||
|
||||
const outputRoutes = page.getByRole('switch', { name: 'Output Routes', exact: true })
|
||||
await expect(outputRoutes).toBeChecked()
|
||||
await outputRoutes.click()
|
||||
await expect(outputRoutes).not.toBeChecked()
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const data = await getAgentV2WorkflowNodeData(this.getConsoleClient(), appId)
|
||||
return zWorkflowOutputRoutes.parse(data.agent_output_routes).enabled
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBe(false)
|
||||
await page.getByRole('button', { name: 'Undo', exact: true }).click()
|
||||
},
|
||||
)
|
||||
|
||||
Then(
|
||||
'the Agent output routes and both successful connections should be restored',
|
||||
async function (this: DifyWorld) {
|
||||
const page = this.getPage()
|
||||
await expect(page.getByRole('switch', { name: 'Output Routes', exact: true })).toBeChecked()
|
||||
for (const route of ['Accepted', 'Rejected']) {
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: `Connection from Agent (${route}) to ${route} result`,
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible()
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { DifyWorld } from '../../support/world.ts'
|
||||
import { Given, Then, When } from '@cucumber/cucumber'
|
||||
import { zWorkflowOutputRoutes } from '@dify/contracts/api/console/apps/zod.gen'
|
||||
import { expect } from '@playwright/test'
|
||||
import * as z from 'zod'
|
||||
import { defaultLocale } from '../../../test-env.ts'
|
||||
|
||||
const fixtureAppIdEnv = 'E2E_AGENT_OUTPUT_ROUTES_PREVIEW_APP_ID'
|
||||
const zPreviewGraph = z.object({
|
||||
nodes: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
data: z.object({
|
||||
type: z.string(),
|
||||
agent_node_kind: z.string().optional(),
|
||||
version: z.string().optional(),
|
||||
agent_output_routes: zWorkflowOutputRoutes.optional(),
|
||||
error_strategy: z.string().optional(),
|
||||
}),
|
||||
}),
|
||||
),
|
||||
edges: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
source: z.string(),
|
||||
sourceHandle: z.string().nullish(),
|
||||
target: z.string(),
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
Given(
|
||||
'the Cloud catalog contains a published workflow with Agent output routes and a failure branch',
|
||||
async function (this: DifyWorld) {
|
||||
const appId = process.env[fixtureAppIdEnv]?.trim()
|
||||
if (!appId)
|
||||
throw new Error(`Set ${fixtureAppIdEnv} to the preseeded E2E workflow template app ID.`)
|
||||
|
||||
const client = this.getConsoleClient()
|
||||
const systemFeatures = await client.systemFeatures.get()
|
||||
expect(systemFeatures.deployment_edition, 'Template preview requires a Cloud runtime.').toBe(
|
||||
'CLOUD',
|
||||
)
|
||||
|
||||
const [catalog, app, workflow] = await Promise.all([
|
||||
client.explore.apps.get({ query: { language: defaultLocale } }),
|
||||
client.trialApps.byAppId.get({ params: { app_id: appId } }),
|
||||
client.trialApps.byAppId.workflows.get({ params: { app_id: appId } }),
|
||||
])
|
||||
expect(app.name, 'The template fixture must be an E2E-owned resource.').toMatch(/^E2E /)
|
||||
expect(app.mode).toBe('workflow')
|
||||
expect(
|
||||
catalog.recommended_apps
|
||||
.filter((item) => item.app?.name === app.name)
|
||||
.map((item) => item.app_id),
|
||||
).toEqual([appId])
|
||||
expect(workflow.version).toBeTruthy()
|
||||
expect(workflow.version).not.toBe('draft')
|
||||
|
||||
const graph = zPreviewGraph.parse(workflow.graph)
|
||||
const agents = graph.nodes.filter(
|
||||
(node) =>
|
||||
node.data.agent_node_kind === 'dify_agent' &&
|
||||
node.data.version === '2' &&
|
||||
node.data.agent_output_routes?.enabled,
|
||||
)
|
||||
expect(agents, 'The template must contain exactly one routed Agent.').toHaveLength(1)
|
||||
const agent = agents[0]!
|
||||
expect(agent.data.error_strategy).toBe('fail-branch')
|
||||
const routes = agent.data.agent_output_routes?.routes ?? []
|
||||
expect(routes.length).toBeGreaterThanOrEqual(2)
|
||||
|
||||
const edgeForHandle = (handle: string) => {
|
||||
const edge = graph.edges.find(
|
||||
(item) => item.source === agent.id && item.sourceHandle === handle,
|
||||
)
|
||||
if (!edge || !graph.nodes.some((node) => node.id === edge.target))
|
||||
throw new Error(`Template fixture is missing a valid Agent connection for ${handle}.`)
|
||||
return edge.id
|
||||
}
|
||||
this.workflowPreview = {
|
||||
appName: app.name,
|
||||
agentNodeId: agent.id,
|
||||
failureEdgeId: edgeForHandle('fail-branch'),
|
||||
routes: routes.map((route) => {
|
||||
if (!route.label?.trim()) throw new Error(`Template route ${route.id} must have a label.`)
|
||||
return { edgeId: edgeForHandle(route.id), label: route.label }
|
||||
}),
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
When('I open the routed workflow template orchestration details', async function (this: DifyWorld) {
|
||||
const fixture = this.workflowPreview
|
||||
if (!fixture) throw new Error('Resolve the Cloud workflow template fixture first.')
|
||||
|
||||
const page = this.getPage()
|
||||
await page.goto('/')
|
||||
await page.getByRole('button', { name: fixture.appName, exact: true }).click()
|
||||
await page
|
||||
.getByRole('dialog')
|
||||
.getByRole('tab', { name: 'Orchestration Details', exact: true })
|
||||
.click()
|
||||
})
|
||||
|
||||
Then(
|
||||
'the template preview should show each Agent output route and its connection alongside the failure branch',
|
||||
async function (this: DifyWorld) {
|
||||
const fixture = this.workflowPreview
|
||||
if (!fixture) throw new Error('Resolve the Cloud workflow template fixture first.')
|
||||
|
||||
const preview = this.getPage().getByRole('dialog')
|
||||
await expect(preview.getByTestId(`rf__edge-${fixture.failureEdgeId}`)).toBeVisible({
|
||||
timeout: 30_000,
|
||||
})
|
||||
const agent = preview.getByTestId(`rf__node-${fixture.agentNodeId}`)
|
||||
for (const route of fixture.routes) {
|
||||
await expect(preview.getByTestId(`rf__edge-${route.edgeId}`)).toBeVisible()
|
||||
await expect(agent.getByText(route.label, { exact: true })).toBeVisible()
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -176,6 +176,14 @@ After(
|
||||
await this.getConsoleClient().apps.byAppId.delete({ params: { app_id: id } })
|
||||
},
|
||||
})),
|
||||
...this.createdSnippetIds.toReversed().map((id) => ({
|
||||
label: `Delete snippet ${id}`,
|
||||
run: async () => {
|
||||
await this.getConsoleClient().workspaces.current.customizedSnippets.bySnippetId.delete({
|
||||
params: { snippet_id: id },
|
||||
})
|
||||
},
|
||||
})),
|
||||
...this.createdAgentIds.toReversed().map((id) => ({
|
||||
label: `Delete Agent ${id}`,
|
||||
run: async () => {
|
||||
|
||||
@@ -41,6 +41,12 @@ export type AgentBuilderSpeechToTextRequest = {
|
||||
path: string
|
||||
status: number
|
||||
}
|
||||
export type WorkflowPreviewFixture = {
|
||||
appName: string
|
||||
agentNodeId: string
|
||||
failureEdgeId: string
|
||||
routes: { edgeId: string; label: string }[]
|
||||
}
|
||||
|
||||
export const createAgentBuilderWorldState = () => ({
|
||||
fixtures: {
|
||||
@@ -67,6 +73,7 @@ export const createAgentBuilderWorldState = () => ({
|
||||
},
|
||||
workflow: {
|
||||
agentConsolePage: undefined as Page | undefined,
|
||||
outputRouteSnippet: undefined as { id: string; name: string } | undefined,
|
||||
outputVariables: [] as AgentV2WorkflowOutputVariable[],
|
||||
},
|
||||
})
|
||||
@@ -87,6 +94,7 @@ export class DifyWorld extends World {
|
||||
lastCreatedAgentName: string | undefined
|
||||
lastCreatedAgentRole: string | undefined
|
||||
createdAppIds: string[] = []
|
||||
createdSnippetIds: string[] = []
|
||||
createdAgentIds: string[] = []
|
||||
createdDatasetIds: string[] = []
|
||||
createdAgentConfigFiles: CreatedAgentConfigFile[] = []
|
||||
@@ -97,6 +105,7 @@ export class DifyWorld extends World {
|
||||
capturedDownloads: Download[] = []
|
||||
shareURL: string | undefined
|
||||
sharedAppPage: Page | undefined
|
||||
workflowPreview: WorkflowPreviewFixture | undefined
|
||||
|
||||
constructor(options: IWorldOptions) {
|
||||
super(options)
|
||||
@@ -104,6 +113,7 @@ export class DifyWorld extends World {
|
||||
}
|
||||
|
||||
resetScenarioState() {
|
||||
this.workflowPreview = undefined
|
||||
this.consoleErrors = []
|
||||
this.pageErrors = []
|
||||
this.lastCreatedAppName = undefined
|
||||
@@ -111,6 +121,7 @@ export class DifyWorld extends World {
|
||||
this.lastCreatedAgentName = undefined
|
||||
this.lastCreatedAgentRole = undefined
|
||||
this.createdAppIds = []
|
||||
this.createdSnippetIds = []
|
||||
this.createdAgentIds = []
|
||||
this.createdDatasetIds = []
|
||||
this.createdAgentConfigFiles = []
|
||||
|
||||
@@ -79,6 +79,7 @@ export const validateE2eEnv = () =>
|
||||
E2E_AGENT_DECISION_MODEL_NAME: process.env.E2E_AGENT_DECISION_MODEL_NAME,
|
||||
E2E_AGENT_DECISION_MODEL_PROVIDER: process.env.E2E_AGENT_DECISION_MODEL_PROVIDER,
|
||||
E2E_AGENT_DECISION_MODEL_TYPE: process.env.E2E_AGENT_DECISION_MODEL_TYPE,
|
||||
E2E_AGENT_OUTPUT_ROUTES_PREVIEW_APP_ID: process.env.E2E_AGENT_OUTPUT_ROUTES_PREVIEW_APP_ID,
|
||||
E2E_API_URL: process.env.E2E_API_URL,
|
||||
E2E_BASE_URL: process.env.E2E_BASE_URL,
|
||||
E2E_BROWSER: process.env.E2E_BROWSER,
|
||||
@@ -112,6 +113,7 @@ export const validateE2eEnv = () =>
|
||||
E2E_AGENT_DECISION_MODEL_NAME: z.string().min(1).optional(),
|
||||
E2E_AGENT_DECISION_MODEL_PROVIDER: z.string().min(1).optional(),
|
||||
E2E_AGENT_DECISION_MODEL_TYPE: z.string().min(1).optional(),
|
||||
E2E_AGENT_OUTPUT_ROUTES_PREVIEW_APP_ID: z.uuid().optional(),
|
||||
E2E_API_URL: z.url().optional(),
|
||||
E2E_BASE_URL: z.url().optional(),
|
||||
E2E_BROWSER: z.enum(['chromium', 'webkit']).optional(),
|
||||
|
||||
@@ -16,7 +16,7 @@ const hasCustomTags = (forwardArgs: string[]) =>
|
||||
forwardArgs.some((arg) => arg === '--tags' || arg.startsWith('--tags='))
|
||||
|
||||
const fullNonExternalTags =
|
||||
'not @axe and not @prepared and not @external-model and not @external-tool'
|
||||
'not @axe and not @prepared and not @external-model and not @external-tool and not @cloud-catalog-runtime'
|
||||
const seedCeleryQueues = 'dataset,priority_dataset,workflow_based_app_execution'
|
||||
|
||||
const readLogTail = async (logFilePath: string) => {
|
||||
|
||||
@@ -3585,12 +3585,7 @@
|
||||
},
|
||||
"web/app/components/workflow/nodes/question-classifier/__tests__/integration.spec.tsx": {
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 15
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/question-classifier/components/class-item.tsx": {
|
||||
"jsx-a11y/no-autofocus": {
|
||||
"count": 1
|
||||
"count": 14
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/question-classifier/use-single-run-form-params.ts": {
|
||||
|
||||
@@ -639,6 +639,7 @@ export type WorkflowNodeJobConfig = {
|
||||
human_contacts?: Array<AgentHumanContactConfig>
|
||||
metadata?: WorkflowNodeJobMetadata
|
||||
mode?: WorkflowNodeJobMode
|
||||
output_routes?: WorkflowOutputRoutes
|
||||
previous_node_output_refs?: Array<WorkflowPreviousNodeOutputRef>
|
||||
schema_version?: number
|
||||
workflow_prompt?: string
|
||||
@@ -1161,6 +1162,11 @@ export type WorkflowNodeJobMetadata = {
|
||||
|
||||
export type WorkflowNodeJobMode = 'let_agent_figure_it_out' | 'tell_agent_what_to_do'
|
||||
|
||||
export type WorkflowOutputRoutes = {
|
||||
enabled?: boolean
|
||||
routes?: Array<WorkflowOutputRoute>
|
||||
}
|
||||
|
||||
export type WorkflowPreviousNodeOutputRef = {
|
||||
key?: string | null
|
||||
name?: string | null
|
||||
@@ -1524,6 +1530,12 @@ export type AgentFileRefConfig = {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type WorkflowOutputRoute = {
|
||||
id: string
|
||||
label?: string | null
|
||||
name?: string
|
||||
}
|
||||
|
||||
export type AgentCliToolAuthorizationStatus =
|
||||
| 'allowed'
|
||||
| 'authorized'
|
||||
|
||||
@@ -1580,6 +1580,27 @@ export const zWorkflowNodeJobMetadata = z.object({
|
||||
file_refs: z.array(zAgentFileRefConfig).nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* WorkflowOutputRoute
|
||||
*
|
||||
* Stable workflow exit identity and its model-visible selection condition.
|
||||
*/
|
||||
export const zWorkflowOutputRoute = z.object({
|
||||
id: z.string().min(1),
|
||||
label: z.string().nullish(),
|
||||
name: z.string().optional().default(''),
|
||||
})
|
||||
|
||||
/**
|
||||
* WorkflowOutputRoutes
|
||||
*
|
||||
* Enabled routes require at least two exits; drafts may omit conditions.
|
||||
*/
|
||||
export const zWorkflowOutputRoutes = z.object({
|
||||
enabled: z.boolean().optional().default(false),
|
||||
routes: z.array(zWorkflowOutputRoute).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentCliToolAuthorizationStatus
|
||||
*
|
||||
@@ -2059,6 +2080,7 @@ export const zWorkflowNodeJobConfig = z.object({
|
||||
human_contacts: z.array(zAgentHumanContactConfig).optional(),
|
||||
metadata: zWorkflowNodeJobMetadata.optional(),
|
||||
mode: zWorkflowNodeJobMode.optional().default('tell_agent_what_to_do'),
|
||||
output_routes: zWorkflowOutputRoutes.optional(),
|
||||
previous_node_output_refs: z.array(zWorkflowPreviousNodeOutputRef).optional(),
|
||||
schema_version: z.int().optional().default(1),
|
||||
workflow_prompt: z.string().optional().default(''),
|
||||
|
||||
@@ -1959,6 +1959,7 @@ export type WorkflowNodeJobConfig = {
|
||||
human_contacts?: Array<AgentHumanContactConfig>
|
||||
metadata?: WorkflowNodeJobMetadata
|
||||
mode?: WorkflowNodeJobMode
|
||||
output_routes?: WorkflowOutputRoutes
|
||||
previous_node_output_refs?: Array<WorkflowPreviousNodeOutputRef>
|
||||
schema_version?: number
|
||||
workflow_prompt?: string
|
||||
@@ -2411,6 +2412,11 @@ export type WorkflowNodeJobMetadata = {
|
||||
|
||||
export type WorkflowNodeJobMode = 'let_agent_figure_it_out' | 'tell_agent_what_to_do'
|
||||
|
||||
export type WorkflowOutputRoutes = {
|
||||
enabled?: boolean
|
||||
routes?: Array<WorkflowOutputRoute>
|
||||
}
|
||||
|
||||
export type WorkflowPreviousNodeOutputRef = {
|
||||
key?: string | null
|
||||
name?: string | null
|
||||
@@ -2701,6 +2707,12 @@ export type DeclaredOutputRetryConfig = {
|
||||
retry_interval_ms?: number
|
||||
}
|
||||
|
||||
export type WorkflowOutputRoute = {
|
||||
id: string
|
||||
label?: string | null
|
||||
name?: string
|
||||
}
|
||||
|
||||
export type AgentCliToolAuthorizationStatus =
|
||||
| 'allowed'
|
||||
| 'authorized'
|
||||
|
||||
@@ -3146,6 +3146,27 @@ export const zDeclaredOutputFailureStrategy = z.object({
|
||||
retry: zDeclaredOutputRetryConfig.optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* WorkflowOutputRoute
|
||||
*
|
||||
* Stable workflow exit identity and its model-visible selection condition.
|
||||
*/
|
||||
export const zWorkflowOutputRoute = z.object({
|
||||
id: z.string().min(1),
|
||||
label: z.string().nullish(),
|
||||
name: z.string().optional().default(''),
|
||||
})
|
||||
|
||||
/**
|
||||
* WorkflowOutputRoutes
|
||||
*
|
||||
* Enabled routes require at least two exits; drafts may omit conditions.
|
||||
*/
|
||||
export const zWorkflowOutputRoutes = z.object({
|
||||
enabled: z.boolean().optional().default(false),
|
||||
routes: z.array(zWorkflowOutputRoute).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentCliToolAuthorizationStatus
|
||||
*
|
||||
@@ -3500,6 +3521,7 @@ export const zWorkflowNodeJobConfig = z.object({
|
||||
human_contacts: z.array(zAgentHumanContactConfig).optional(),
|
||||
metadata: zWorkflowNodeJobMetadata.optional(),
|
||||
mode: zWorkflowNodeJobMode.optional().default('tell_agent_what_to_do'),
|
||||
output_routes: zWorkflowOutputRoutes.optional(),
|
||||
previous_node_output_refs: z.array(zWorkflowPreviousNodeOutputRef).optional(),
|
||||
schema_version: z.int().optional().default(1),
|
||||
workflow_prompt: z.string().optional().default(''),
|
||||
|
||||
@@ -451,6 +451,7 @@ export type WorkflowNodeJobConfig = {
|
||||
human_contacts?: Array<AgentHumanContactConfig>
|
||||
metadata?: WorkflowNodeJobMetadata
|
||||
mode?: WorkflowNodeJobMode
|
||||
output_routes?: WorkflowOutputRoutes
|
||||
previous_node_output_refs?: Array<WorkflowPreviousNodeOutputRef>
|
||||
schema_version?: number
|
||||
workflow_prompt?: string
|
||||
@@ -701,6 +702,11 @@ export type WorkflowNodeJobMetadata = {
|
||||
|
||||
export type WorkflowNodeJobMode = 'let_agent_figure_it_out' | 'tell_agent_what_to_do'
|
||||
|
||||
export type WorkflowOutputRoutes = {
|
||||
enabled?: boolean
|
||||
routes?: Array<WorkflowOutputRoute>
|
||||
}
|
||||
|
||||
export type WorkflowPreviousNodeOutputRef = {
|
||||
key?: string | null
|
||||
name?: string | null
|
||||
@@ -944,6 +950,12 @@ export type DeclaredOutputRetryConfig = {
|
||||
retry_interval_ms?: number
|
||||
}
|
||||
|
||||
export type WorkflowOutputRoute = {
|
||||
id: string
|
||||
label?: string | null
|
||||
name?: string
|
||||
}
|
||||
|
||||
export type AgentCliToolAuthorizationStatus =
|
||||
| 'allowed'
|
||||
| 'authorized'
|
||||
|
||||
@@ -927,6 +927,27 @@ export const zDeclaredOutputFailureStrategy = z.object({
|
||||
retry: zDeclaredOutputRetryConfig.optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* WorkflowOutputRoute
|
||||
*
|
||||
* Stable workflow exit identity and its model-visible selection condition.
|
||||
*/
|
||||
export const zWorkflowOutputRoute = z.object({
|
||||
id: z.string().min(1),
|
||||
label: z.string().nullish(),
|
||||
name: z.string().optional().default(''),
|
||||
})
|
||||
|
||||
/**
|
||||
* WorkflowOutputRoutes
|
||||
*
|
||||
* Enabled routes require at least two exits; drafts may omit conditions.
|
||||
*/
|
||||
export const zWorkflowOutputRoutes = z.object({
|
||||
enabled: z.boolean().optional().default(false),
|
||||
routes: z.array(zWorkflowOutputRoute).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentCliToolAuthorizationStatus
|
||||
*
|
||||
@@ -1237,6 +1258,7 @@ export const zWorkflowNodeJobConfig = z.object({
|
||||
human_contacts: z.array(zAgentHumanContactConfig).optional(),
|
||||
metadata: zWorkflowNodeJobMetadata.optional(),
|
||||
mode: zWorkflowNodeJobMode.optional().default('tell_agent_what_to_do'),
|
||||
output_routes: zWorkflowOutputRoutes.optional(),
|
||||
previous_node_output_refs: z.array(zWorkflowPreviousNodeOutputRef).optional(),
|
||||
schema_version: z.int().optional().default(1),
|
||||
workflow_prompt: z.string().optional().default(''),
|
||||
|
||||
+76
@@ -560,6 +560,82 @@ describe('useInsertSnippet', () => {
|
||||
expect(insertedLoopStartNode.data.loop_id).toBe(insertedLoopNode.id)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ enabled: true, description: 'leaves routed outputs for the user to connect' },
|
||||
{ enabled: false, description: 'automatically connects the ordinary Agent output' },
|
||||
])('$description when inserting an Agent snippet into an edge', async ({ enabled }) => {
|
||||
mockGetNodes.mockReturnValue([
|
||||
{ id: 'start', position: { x: 0, y: 0 }, data: { type: 'start' } },
|
||||
{ id: 'end', position: { x: 600, y: 0 }, data: { type: 'end' } },
|
||||
])
|
||||
mockEdges = [
|
||||
{
|
||||
id: 'start-source-end-target',
|
||||
source: 'start',
|
||||
sourceHandle: 'source',
|
||||
target: 'end',
|
||||
targetHandle: 'target',
|
||||
},
|
||||
]
|
||||
seedPublishedWorkflow(queryClient, {
|
||||
graph: {
|
||||
nodes: [
|
||||
{
|
||||
id: 'snippet-agent',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
type: 'agent',
|
||||
agent_node_kind: 'dify_agent',
|
||||
version: '2',
|
||||
agent_output_routes: {
|
||||
enabled,
|
||||
routes: [
|
||||
{ id: 'accepted', name: 'Accepted', label: 'The request is accepted.' },
|
||||
{ id: 'rejected', name: 'Rejected', label: 'The request is rejected.' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
},
|
||||
})
|
||||
|
||||
const { result } = renderUseInsertSnippet()
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleInsertSnippet('snippet-1', {
|
||||
prevNodeId: 'start',
|
||||
prevNodeSourceHandle: 'source',
|
||||
nextNodeId: 'end',
|
||||
nextNodeTargetHandle: 'target',
|
||||
})
|
||||
})
|
||||
|
||||
const nextNodes = mockSetNodes.mock.calls[0]![0] as TestNode[]
|
||||
const insertedAgent = nextNodes.find((node) => node.data.type === 'agent')!
|
||||
const nextEdges = mockSetEdges.mock.calls[0]![0] as TestEdge[]
|
||||
const expectedEdges = [
|
||||
expect.objectContaining({
|
||||
source: 'start',
|
||||
sourceHandle: 'source',
|
||||
target: insertedAgent.id,
|
||||
targetHandle: 'target',
|
||||
}),
|
||||
]
|
||||
if (!enabled) {
|
||||
expectedEdges.push(
|
||||
expect.objectContaining({
|
||||
source: insertedAgent.id,
|
||||
sourceHandle: 'source',
|
||||
target: 'end',
|
||||
targetHandle: 'target',
|
||||
}),
|
||||
)
|
||||
}
|
||||
expect(nextEdges).toEqual(expectedEdges)
|
||||
})
|
||||
|
||||
it.each(['iteration', 'loop'] as const)(
|
||||
'should connect inserted snippet nodes inside the %s container',
|
||||
async (containerType) => {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { CUSTOM_EDGE, NESTED_ELEMENT_Z_INDEX, NODE_WIDTH_X_OFFSET, X_OFFSET } fr
|
||||
import { useNodesSyncDraft } from '../../hooks/use-nodes-sync-draft'
|
||||
import { useWorkflowHistory, WorkflowHistoryEvent } from '../../hooks/use-workflow-history'
|
||||
import { getNodeUsedVars, updateNodeVars } from '../../nodes/_base/components/variable/utils'
|
||||
import { hasAgentV2OutputRoutes } from '../../nodes/agent-v2/types'
|
||||
import { AssignerNodeInputType } from '../../nodes/assigner/types'
|
||||
import { BlockEnum } from '../../types'
|
||||
import { getNodesConnectedSourceOrTargetHandleIdsMap } from '../../utils'
|
||||
@@ -65,6 +66,7 @@ const canConnectFromSource = (node: Node) => {
|
||||
return (
|
||||
node.data.type !== BlockEnum.IfElse &&
|
||||
node.data.type !== BlockEnum.QuestionClassifier &&
|
||||
!hasAgentV2OutputRoutes(node.data) &&
|
||||
node.data.type !== BlockEnum.HumanInput &&
|
||||
node.data.type !== BlockEnum.LoopEnd
|
||||
)
|
||||
|
||||
@@ -410,7 +410,7 @@ export const useChecklist = (nodes: Node[], edges: Edge[], options?: { flowType?
|
||||
isReadyForCheckValid,
|
||||
}
|
||||
} else {
|
||||
usedVars = getNodeUsedVars(node!).filter((v) => v.length > 0)
|
||||
usedVars = getNodeUsedVars(node!, { forExecution: true }).filter((v) => v.length > 0)
|
||||
}
|
||||
|
||||
if (node!.data.type === BlockEnum.LLM) {
|
||||
@@ -802,7 +802,7 @@ export const useChecklistBeforePublish = () => {
|
||||
isReadyForCheckValid,
|
||||
}
|
||||
} else {
|
||||
usedVars = getNodeUsedVars(node!).filter((v) => v.length > 0)
|
||||
usedVars = getNodeUsedVars(node!, { forExecution: true }).filter((v) => v.length > 0)
|
||||
}
|
||||
|
||||
if (node!.data.type === BlockEnum.LLM) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { CommonNodeType } from '../types'
|
||||
import { useLayoutEffect, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useStore } from 'reactflow'
|
||||
import { hasAgentV2OutputRoutes } from '@/app/components/workflow/nodes/agent-v2/types'
|
||||
import { ErrorHandleTypeEnum } from '../nodes/_base/components/error-handle/types'
|
||||
import { BlockEnum } from '../types'
|
||||
|
||||
@@ -30,6 +31,14 @@ export const useEdgeAccessibleLabel = (
|
||||
branchName = branchName ? `${classLabel}: ${branchName}` : classLabel
|
||||
}
|
||||
}
|
||||
if (hasAgentV2OutputRoutes(sourceData)) {
|
||||
const routes = sourceData.agent_output_routes?.routes ?? []
|
||||
const index = routes.findIndex((route) => route.id === sourceHandleId)
|
||||
if (index >= 0)
|
||||
branchName =
|
||||
routes[index]?.label ||
|
||||
t(($) => $['nodes.agent.outputRoutes.route'], { ns: 'workflow', index: index + 1 })
|
||||
}
|
||||
if (sourceData.type === BlockEnum.HumanInput) {
|
||||
branchName =
|
||||
sourceHandleId === '__timeout'
|
||||
|
||||
@@ -70,16 +70,20 @@ export const useEdgesInteractions = () => {
|
||||
[getNodesReadOnly, store],
|
||||
)
|
||||
|
||||
const handleEdgeDeleteByDeleteBranch = useCallback(
|
||||
(nodeId: string, branchId: string) => {
|
||||
if (getNodesReadOnly()) return
|
||||
// The caller owns draft synchronization and history for the complete user action.
|
||||
const removeBranchEdges = useCallback(
|
||||
(nodeId: string, branchIds: string[]) => {
|
||||
if (getNodesReadOnly()) return false
|
||||
|
||||
const { nodes, setNodes, edges, setEdges } = collaborativeWorkflow.getState()
|
||||
const edgeWillBeDeleted = edges.filter(
|
||||
(edge) => edge.source === nodeId && edge.sourceHandle === branchId,
|
||||
(edge) =>
|
||||
edge.source === nodeId &&
|
||||
edge.sourceHandle != null &&
|
||||
branchIds.includes(edge.sourceHandle),
|
||||
)
|
||||
|
||||
if (!edgeWillBeDeleted.length) return
|
||||
if (!edgeWillBeDeleted.length) return false
|
||||
|
||||
const newNodes = applyConnectedHandleNodeData(
|
||||
nodes,
|
||||
@@ -98,16 +102,19 @@ export const useEdgesInteractions = () => {
|
||||
) {
|
||||
workflowStore.setState({ contextMenuTarget: undefined })
|
||||
}
|
||||
return true
|
||||
},
|
||||
[getNodesReadOnly, collaborativeWorkflow, workflowStore],
|
||||
)
|
||||
|
||||
const handleEdgeDeleteByDeleteBranch = useCallback(
|
||||
(nodeId: string, branchId: string) => {
|
||||
if (!removeBranchEdges(nodeId, [branchId])) return
|
||||
|
||||
handleSyncWorkflowDraft()
|
||||
saveStateToHistory(WorkflowHistoryEvent.EdgeDeleteByDeleteBranch)
|
||||
},
|
||||
[
|
||||
getNodesReadOnly,
|
||||
collaborativeWorkflow,
|
||||
workflowStore,
|
||||
handleSyncWorkflowDraft,
|
||||
saveStateToHistory,
|
||||
],
|
||||
[removeBranchEdges, handleSyncWorkflowDraft, saveStateToHistory],
|
||||
)
|
||||
|
||||
const handleEdgeDelete = useCallback(() => {
|
||||
@@ -220,6 +227,7 @@ export const useEdgesInteractions = () => {
|
||||
return {
|
||||
handleEdgeEnter,
|
||||
handleEdgeLeave,
|
||||
removeBranchEdges,
|
||||
handleEdgeDeleteByDeleteBranch,
|
||||
handleEdgeDelete,
|
||||
handleEdgeDeleteById,
|
||||
|
||||
@@ -32,7 +32,11 @@ import {
|
||||
} from '../constants'
|
||||
import { getNodeUsedVars } from '../nodes/_base/components/variable/utils'
|
||||
import { useCreateInlineAgentBinding } from '../nodes/agent-v2/hooks'
|
||||
import { isAgentV2NodeData, needsInlineAgentBindingCreation } from '../nodes/agent-v2/types'
|
||||
import {
|
||||
hasAgentV2OutputRoutes,
|
||||
isAgentV2NodeData,
|
||||
needsInlineAgentBindingCreation,
|
||||
} from '../nodes/agent-v2/types'
|
||||
import { CUSTOM_ITERATION_START_NODE } from '../nodes/iteration-start/constants'
|
||||
import { useNodeIterationInteractions } from '../nodes/iteration/use-interactions'
|
||||
import { CUSTOM_LOOP_START_NODE } from '../nodes/loop-start/constants'
|
||||
@@ -387,6 +391,7 @@ export const useNodesInteractions = () => {
|
||||
connectingNode.data.type === BlockEnum.VariableAggregator) &&
|
||||
node.data.type !== BlockEnum.IfElse &&
|
||||
node.data.type !== BlockEnum.QuestionClassifier &&
|
||||
!hasAgentV2OutputRoutes(node.data) &&
|
||||
node.data.type !== BlockEnum.HumanInput
|
||||
) {
|
||||
n.data._isEntering = true
|
||||
@@ -1017,6 +1022,7 @@ export const useNodesInteractions = () => {
|
||||
if (
|
||||
nodeType !== BlockEnum.IfElse &&
|
||||
nodeType !== BlockEnum.QuestionClassifier &&
|
||||
!hasAgentV2OutputRoutes(newNode.data) &&
|
||||
nodeType !== BlockEnum.HumanInput
|
||||
) {
|
||||
newNode.data._connectedSourceHandleIds = [sourceHandle]
|
||||
@@ -1051,6 +1057,7 @@ export const useNodesInteractions = () => {
|
||||
if (
|
||||
nodeType !== BlockEnum.IfElse &&
|
||||
nodeType !== BlockEnum.QuestionClassifier &&
|
||||
!hasAgentV2OutputRoutes(newNode.data) &&
|
||||
nodeType !== BlockEnum.HumanInput &&
|
||||
nodeType !== BlockEnum.LoopEnd
|
||||
) {
|
||||
@@ -1212,6 +1219,7 @@ export const useNodesInteractions = () => {
|
||||
if (
|
||||
nodeType !== BlockEnum.IfElse &&
|
||||
nodeType !== BlockEnum.QuestionClassifier &&
|
||||
!hasAgentV2OutputRoutes(newNode.data) &&
|
||||
nodeType !== BlockEnum.HumanInput &&
|
||||
nodeType !== BlockEnum.LoopEnd
|
||||
) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { produce } from 'immer'
|
||||
import { useCallback } from 'react'
|
||||
import { useStoreApi } from 'reactflow'
|
||||
import { ErrorHandleTypeEnum } from '@/app/components/workflow/nodes/_base/components/error-handle/types'
|
||||
import { hasAgentV2OutputRoutes } from '@/app/components/workflow/nodes/agent-v2/types'
|
||||
import { useWorkflowStore } from '@/app/components/workflow/store'
|
||||
import { BlockEnum, NodeRunningStatus } from '@/app/components/workflow/types'
|
||||
|
||||
@@ -40,6 +41,11 @@ export const useWorkflowNodeFinished = () => {
|
||||
|
||||
if (data.node_type === BlockEnum.QuestionClassifier)
|
||||
currentNode.data._runningBranchId = data?.outputs?.class_id
|
||||
if (
|
||||
hasAgentV2OutputRoutes(currentNode.data) &&
|
||||
data.status === NodeRunningStatus.Succeeded
|
||||
)
|
||||
currentNode.data._runningBranchId = data.outputs?.switch
|
||||
if (data.node_type === BlockEnum.HumanInput)
|
||||
currentNode.data._runningBranchId = data?.outputs?.__action_id
|
||||
}
|
||||
|
||||
+24
-9
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { Topic } from '../types'
|
||||
import type { Topic } from './types'
|
||||
import type { ValueSelector, Var } from '@/app/components/workflow/types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import * as React from 'react'
|
||||
@@ -19,9 +19,12 @@ type Props = Readonly<{
|
||||
payload: Topic
|
||||
onChange: (payload: Topic) => void
|
||||
onRemove: () => void
|
||||
showRemove?: boolean
|
||||
index: number
|
||||
readonly?: boolean
|
||||
filterVar: (payload: Var, valueSelector: ValueSelector) => boolean
|
||||
placeholder?: string
|
||||
defaultLabel?: string
|
||||
onLabelEditStart?: () => void
|
||||
}>
|
||||
|
||||
@@ -32,21 +35,28 @@ const ClassItem: FC<Props> = ({
|
||||
payload,
|
||||
onChange,
|
||||
onRemove,
|
||||
showRemove = true,
|
||||
index,
|
||||
readonly,
|
||||
filterVar,
|
||||
onLabelEditStart,
|
||||
placeholder,
|
||||
defaultLabel,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const reactId = useId()
|
||||
const [isEditingLabel, setIsEditingLabel] = useState(false)
|
||||
const [draftLabel, setDraftLabel] = useState('')
|
||||
const labelInputRef = useRef<HTMLInputElement>(null)
|
||||
const displayLabel = getDisplayClassLabel(payload.label, index, t)
|
||||
const displayLabel =
|
||||
payload.label?.trim() || defaultLabel || getDisplayClassLabel(payload.label, index, t)
|
||||
const instanceId = `${nodeId}-${reactId}`
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditingLabel) labelInputRef.current?.select()
|
||||
if (isEditingLabel) {
|
||||
labelInputRef.current?.focus()
|
||||
labelInputRef.current?.select()
|
||||
}
|
||||
}, [isEditingLabel])
|
||||
|
||||
const handleNameChange = useCallback(
|
||||
@@ -58,7 +68,9 @@ const ClassItem: FC<Props> = ({
|
||||
|
||||
const handleLabelSave = useCallback(
|
||||
(nextValue: string) => {
|
||||
const normalizedLabel = getCanonicalClassLabel(nextValue, index, t)
|
||||
const normalizedLabel = defaultLabel
|
||||
? nextValue.trim() || defaultLabel
|
||||
: getCanonicalClassLabel(nextValue, index, t)
|
||||
setIsEditingLabel(false)
|
||||
setDraftLabel(normalizedLabel)
|
||||
const shouldPersistLabel =
|
||||
@@ -66,7 +78,7 @@ const ClassItem: FC<Props> = ({
|
||||
(payload.label !== undefined && payload.label !== normalizedLabel)
|
||||
if (shouldPersistLabel) onChange({ ...payload, label: normalizedLabel })
|
||||
},
|
||||
[displayLabel, index, onChange, payload, t],
|
||||
[displayLabel, index, onChange, payload, t, defaultLabel],
|
||||
)
|
||||
|
||||
const handleLabelCancel = useCallback(() => {
|
||||
@@ -93,7 +105,9 @@ const ClassItem: FC<Props> = ({
|
||||
<input
|
||||
ref={labelInputRef}
|
||||
value={draftLabel}
|
||||
aria-label={t(($) => $[`${i18nPrefix}.labelEditorAriaLabel`], { ns: 'workflow' })}
|
||||
aria-label={
|
||||
defaultLabel ?? t(($) => $[`${i18nPrefix}.labelEditorAriaLabel`], { ns: 'workflow' })
|
||||
}
|
||||
className={cn(
|
||||
'h-6 w-full rounded-md border border-divider-regular bg-components-input-bg-normal px-2 text-xs font-semibold text-text-secondary ring-0 outline-none',
|
||||
'focus:border-components-input-border-active',
|
||||
@@ -113,7 +127,6 @@ const ClassItem: FC<Props> = ({
|
||||
handleLabelCancel()
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
) : readonly ? (
|
||||
<div className="-ml-1 px-1 py-0.5 text-left text-xs/4 font-semibold text-text-secondary">
|
||||
@@ -143,10 +156,12 @@ const ClassItem: FC<Props> = ({
|
||||
className={className}
|
||||
headerClassName={headerClassName}
|
||||
title={title}
|
||||
placeholder={t(($) => $[`${i18nPrefix}.topicPlaceholder`], { ns: 'workflow' })!}
|
||||
placeholder={
|
||||
placeholder ?? t(($) => $[`${i18nPrefix}.topicPlaceholder`], { ns: 'workflow' })!
|
||||
}
|
||||
value={payload.name}
|
||||
onChange={handleNameChange}
|
||||
showRemove
|
||||
showRemove={showRemove}
|
||||
onRemove={onRemove}
|
||||
nodesOutputVars={availableVars}
|
||||
availableNodes={availableNodesWithParent}
|
||||
+15
-3
@@ -31,12 +31,20 @@ const normalizeClassLabel = (label?: string | null) => label?.trim() ?? ''
|
||||
export const getDefaultClassLabel = (_t: TFunction, index: number) =>
|
||||
getCanonicalDefaultClassLabel(index)
|
||||
|
||||
export const getDisplayClassLabel = (label: string | undefined, index: number, t: TFunction) =>
|
||||
export const getDisplayClassLabel = (
|
||||
label: string | null | undefined,
|
||||
index: number,
|
||||
t: TFunction,
|
||||
) =>
|
||||
normalizeClassLabel(label) ||
|
||||
getTranslatedDefaultClassLabel(t, index) ||
|
||||
getCanonicalDefaultClassLabel(index)
|
||||
|
||||
export const isDefaultClassLabel = (label: string | undefined, index: number, t: TFunction) => {
|
||||
export const isDefaultClassLabel = (
|
||||
label: string | null | undefined,
|
||||
index: number,
|
||||
t: TFunction,
|
||||
) => {
|
||||
const normalizedLabel = normalizeClassLabel(label)
|
||||
if (!normalizedLabel) return true
|
||||
|
||||
@@ -46,7 +54,11 @@ export const isDefaultClassLabel = (label: string | undefined, index: number, t:
|
||||
)
|
||||
}
|
||||
|
||||
export const getCanonicalClassLabel = (label: string | undefined, index: number, t: TFunction) => {
|
||||
export const getCanonicalClassLabel = (
|
||||
label: string | null | undefined,
|
||||
index: number,
|
||||
t: TFunction,
|
||||
) => {
|
||||
const normalizedLabel = normalizeClassLabel(label)
|
||||
if (!normalizedLabel) return getCanonicalDefaultClassLabel(index)
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
'use client'
|
||||
import type { FC, ReactNode } from 'react'
|
||||
import type { Topic } from './types'
|
||||
import type { ValueSelector, Var } from '@/app/components/workflow/types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { IconButton } from '@langgenius/dify-ui/icon-button'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { produce } from 'immer'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ReactSortable } from 'react-sortablejs'
|
||||
import { useKeyboardSortable } from '@/app/components/base/keyboard-sortable/use-keyboard-sortable'
|
||||
import { useEdgesInteractions } from '../../../../hooks/use-edges-interactions'
|
||||
import AddButton from '../add-button'
|
||||
import {
|
||||
Collapse,
|
||||
CollapseActions,
|
||||
CollapseContent,
|
||||
CollapseHeader,
|
||||
CollapseIndicator,
|
||||
CollapseTitle,
|
||||
CollapseTrigger,
|
||||
} from '../collapse'
|
||||
import Item from './class-item'
|
||||
import { getDefaultClassLabel, isDefaultClassLabel } from './class-label-utils'
|
||||
import { useInlineLabelHintDismissed } from './storage'
|
||||
|
||||
const i18nPrefix = 'nodes.questionClassifiers'
|
||||
type Props = Readonly<{
|
||||
nodeId: string
|
||||
list: Topic[]
|
||||
minItems?: number
|
||||
enabled?: boolean
|
||||
actions?: ReactNode
|
||||
onChange: (list: Topic[]) => void
|
||||
/** Owns both the list update and edge removal instead of the default removal action. */
|
||||
onRemove?: (id: string) => void
|
||||
readonly?: boolean
|
||||
filterVar: (payload: Var, valueSelector: ValueSelector) => boolean
|
||||
labels?: {
|
||||
title: string
|
||||
add: string
|
||||
placeholder: string
|
||||
renameHint: string
|
||||
defaultLabel: (index: number) => string
|
||||
}
|
||||
handleSortTopic?: (newTopics: (Topic & { id: string })[]) => void
|
||||
}>
|
||||
|
||||
const ClassList: FC<Props> = ({
|
||||
nodeId,
|
||||
list,
|
||||
minItems = 0,
|
||||
enabled = true,
|
||||
actions,
|
||||
onChange,
|
||||
onRemove,
|
||||
readonly,
|
||||
filterVar,
|
||||
labels,
|
||||
handleSortTopic = noop,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { handleEdgeDeleteByDeleteBranch } = useEdgesInteractions()
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const [storedRenameHintDismissed, setIsRenameHintDismissed] = useInlineLabelHintDismissed()
|
||||
const isRenameHintDismissed = storedRenameHintDismissed ?? false
|
||||
|
||||
const handleClassChange = useCallback(
|
||||
(index: number) => {
|
||||
return (value: Topic) => {
|
||||
const newList = produce(list, (draft) => {
|
||||
draft[index] = value
|
||||
})
|
||||
onChange(newList)
|
||||
}
|
||||
},
|
||||
[list, onChange],
|
||||
)
|
||||
|
||||
const handleAddClass = useCallback(() => {
|
||||
const newList = produce(list, (draft) => {
|
||||
draft.push({
|
||||
id: crypto.randomUUID(),
|
||||
name: '',
|
||||
label: labels?.defaultLabel(draft.length + 1) ?? getDefaultClassLabel(t, draft.length + 1),
|
||||
})
|
||||
})
|
||||
onChange(newList)
|
||||
}, [list, onChange, t, labels])
|
||||
|
||||
const handleRemoveClass = useCallback(
|
||||
(index: number) => {
|
||||
return () => {
|
||||
if (list.length <= minItems) return
|
||||
if (onRemove) {
|
||||
onRemove(list[index]!.id)
|
||||
return
|
||||
}
|
||||
const newList = produce(list, (draft) => {
|
||||
draft.splice(index, 1)
|
||||
})
|
||||
onChange(newList)
|
||||
handleEdgeDeleteByDeleteBranch(nodeId, list[index]!.id)
|
||||
}
|
||||
},
|
||||
[list, minItems, onChange, onRemove, handleEdgeDeleteByDeleteBranch, nodeId],
|
||||
)
|
||||
|
||||
const keyboardSort = useKeyboardSortable({
|
||||
items: list,
|
||||
onChange: handleSortTopic,
|
||||
disabled: readonly,
|
||||
getItemLabel: (item) => item.label || item.name,
|
||||
})
|
||||
const sortableTopics = useMemo(
|
||||
() => keyboardSort.items.map((item) => ({ ...item })),
|
||||
[keyboardSort.items],
|
||||
)
|
||||
|
||||
const topicCount = list.length
|
||||
|
||||
const dismissRenameHint = useCallback(() => {
|
||||
if (isRenameHintDismissed) return
|
||||
|
||||
setIsRenameHintDismissed(true)
|
||||
}, [isRenameHintDismissed, setIsRenameHintDismissed])
|
||||
|
||||
const shouldShowRenameHint =
|
||||
!readonly &&
|
||||
!isRenameHintDismissed &&
|
||||
list.some((item, index) => {
|
||||
return labels
|
||||
? item.label === labels.defaultLabel(index + 1)
|
||||
: isDefaultClassLabel(item.label, index + 1, t)
|
||||
})
|
||||
|
||||
return (
|
||||
<Collapse collapsed={!enabled || collapsed} onCollapse={setCollapsed} disabled={!enabled}>
|
||||
{keyboardSort.announcement}
|
||||
<CollapseHeader>
|
||||
<CollapseTrigger className="ml-0">
|
||||
<CollapseTitle>
|
||||
{labels?.title ?? t(($) => $[`${i18nPrefix}.class`], { ns: 'workflow' })}{' '}
|
||||
{enabled && <span className="text-text-destructive">*</span>}
|
||||
</CollapseTitle>
|
||||
{enabled && list.length > 0 && <CollapseIndicator />}
|
||||
</CollapseTrigger>
|
||||
{actions != null && <CollapseActions>{actions}</CollapseActions>}
|
||||
</CollapseHeader>
|
||||
{enabled && (
|
||||
<CollapseContent>
|
||||
<div className="pt-2">
|
||||
{shouldShowRenameHint && (
|
||||
<div className="mb-2 rounded-lg border border-divider-subtle bg-components-panel-bg px-3 py-2 text-xs text-text-tertiary">
|
||||
{labels?.renameHint ?? t(($) => $[`${i18nPrefix}.renameHint`], { ns: 'workflow' })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-y-visible pl-3">
|
||||
<ReactSortable
|
||||
list={sortableTopics}
|
||||
setList={(items) => {
|
||||
if (
|
||||
!keyboardSort.isSorting &&
|
||||
items.some((item, index) => item.id !== sortableTopics[index]?.id)
|
||||
)
|
||||
handleSortTopic(items)
|
||||
}}
|
||||
handle=".handle"
|
||||
ghostClass="bg-components-panel-bg"
|
||||
animation={150}
|
||||
disabled={readonly || keyboardSort.isSorting}
|
||||
className="space-y-2"
|
||||
>
|
||||
{keyboardSort.items.map((item, index) => {
|
||||
const canDrag = !readonly && topicCount >= 2
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className={cn(
|
||||
'group relative -ml-3 min-h-10 rounded-[10px] bg-components-panel-bg px-0 py-0',
|
||||
)}
|
||||
style={{
|
||||
// Performance hint for browser
|
||||
contain: 'layout style paint',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
{canDrag && (
|
||||
<IconButton
|
||||
{...keyboardSort.getHandleProps(index)}
|
||||
className="handle pointer-events-none absolute top-1.5 left-0.5 z-10 opacity-0 group-hover:pointer-events-auto group-hover:opacity-100 focus:pointer-events-auto focus:opacity-100 aria-pressed:pointer-events-auto aria-pressed:opacity-100"
|
||||
>
|
||||
<span aria-hidden="true" className="i-ri-draggable size-3" />
|
||||
</IconButton>
|
||||
)}
|
||||
<Item
|
||||
className={cn(canDrag && 'handle')}
|
||||
headerClassName={cn(
|
||||
canDrag && 'cursor-grab group-focus-within:pl-5 group-hover:pl-5',
|
||||
)}
|
||||
nodeId={nodeId}
|
||||
key={item.id}
|
||||
payload={item}
|
||||
onChange={handleClassChange(keyboardSort.getItemKey(index))}
|
||||
onRemove={handleRemoveClass(keyboardSort.getItemKey(index))}
|
||||
showRemove={list.length > minItems}
|
||||
index={index + 1}
|
||||
readonly={readonly}
|
||||
filterVar={filterVar}
|
||||
onLabelEditStart={dismissRenameHint}
|
||||
placeholder={labels?.placeholder}
|
||||
defaultLabel={labels?.defaultLabel(index + 1)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</ReactSortable>
|
||||
</div>
|
||||
{!readonly && (
|
||||
<div className="mt-2">
|
||||
<AddButton
|
||||
onClick={handleAddClass}
|
||||
text={labels?.add ?? t(($) => $[`${i18nPrefix}.addClass`], { ns: 'workflow' })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapseContent>
|
||||
)}
|
||||
</Collapse>
|
||||
)
|
||||
}
|
||||
export default React.memo(ClassList)
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { TFunction } from 'i18next'
|
||||
import type { FC } from 'react'
|
||||
import type { Topic } from './types'
|
||||
import type { NodeProps } from '@/app/components/workflow/types'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { NodeSourceHandle } from '../node-handle'
|
||||
import ReadonlyInputWithSelectVar from '../readonly-input-with-select-var'
|
||||
import { getDisplayClassLabel } from './class-label-utils'
|
||||
|
||||
const MAX_CLASS_TEXT_LENGTH = 50
|
||||
|
||||
type TruncatedClassItemProps = {
|
||||
topic: { id: string; name: string; label?: string | null }
|
||||
index: number
|
||||
nodeId: string
|
||||
t: TFunction
|
||||
defaultLabel?: string
|
||||
}
|
||||
|
||||
const TruncatedClassItem: FC<TruncatedClassItemProps> = ({
|
||||
topic,
|
||||
index,
|
||||
nodeId,
|
||||
t,
|
||||
defaultLabel,
|
||||
}) => {
|
||||
const displayLabel =
|
||||
topic.label?.trim() || defaultLabel || getDisplayClassLabel(topic.label, index + 1, t)
|
||||
const truncatedText =
|
||||
topic.name.length > MAX_CLASS_TEXT_LENGTH
|
||||
? `${topic.name.slice(0, MAX_CLASS_TEXT_LENGTH)}...`
|
||||
: topic.name
|
||||
|
||||
const shouldShowTooltip = topic.name.length > MAX_CLASS_TEXT_LENGTH
|
||||
|
||||
const content = (
|
||||
<div className="truncate system-xs-regular text-text-tertiary">
|
||||
<ReadonlyInputWithSelectVar value={truncatedText} nodeId={nodeId} className="truncate" />
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-0.5 rounded-md bg-workflow-block-parma-bg px-1.25 py-0.75">
|
||||
<div className="text-xs/4 font-semibold text-text-secondary">{displayLabel}</div>
|
||||
{shouldShowTooltip ? (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
openOnHover
|
||||
aria-label={topic.name}
|
||||
className="w-full border-0 bg-transparent p-0 text-left"
|
||||
>
|
||||
{content}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="max-w-75 px-3 py-2 system-xs-regular wrap-break-word text-text-tertiary">
|
||||
<ReadonlyInputWithSelectVar value={topic.name} nodeId={nodeId} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : (
|
||||
content
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function NodeBranches({
|
||||
node,
|
||||
branches,
|
||||
defaultLabel,
|
||||
}: {
|
||||
node: NodeProps
|
||||
branches: Topic[]
|
||||
defaultLabel?: (index: number) => string
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className="mt-2 space-y-0.5">
|
||||
{branches.map((topic, index) => (
|
||||
<div key={topic.id} className="relative">
|
||||
<TruncatedClassItem
|
||||
topic={topic}
|
||||
index={index}
|
||||
nodeId={node.id}
|
||||
t={t}
|
||||
defaultLabel={defaultLabel?.(index + 1)}
|
||||
/>
|
||||
<NodeSourceHandle
|
||||
{...node}
|
||||
handleId={topic.id}
|
||||
handleClassName="top-1/2! -translate-y-1/2! -right-[21px]!"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File renamed without changes.
@@ -0,0 +1,5 @@
|
||||
export type Topic = {
|
||||
id: string
|
||||
name: string
|
||||
label?: string | null
|
||||
}
|
||||
+16
@@ -179,6 +179,22 @@ describe('error-handle path', () => {
|
||||
expect(onSelected.mock.calls[0]?.[0]).toBe(ErrorHandleTypeEnum.defaultValue)
|
||||
})
|
||||
|
||||
it('offers only stop and failure-branch strategies when default values are unsupported', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<ErrorHandleTypeSelector
|
||||
value={ErrorHandleTypeEnum.none}
|
||||
onSelected={vi.fn()}
|
||||
allowDefaultValue={false}
|
||||
/>,
|
||||
)
|
||||
await user.click(screen.getByRole('button'))
|
||||
expect(screen.getAllByRole('menuitemradio').map((item) => item.textContent)).toEqual([
|
||||
expect.stringContaining('workflow.nodes.common.errorHandle.none.title'),
|
||||
expect.stringContaining('workflow.nodes.common.errorHandle.failBranch.title'),
|
||||
])
|
||||
})
|
||||
|
||||
it('should render the error tip only when a strategy exists', () => {
|
||||
const { rerender, container } = render(<ErrorHandleTip />)
|
||||
|
||||
|
||||
+2
@@ -2,6 +2,7 @@ import type { DefaultValueForm } from './types'
|
||||
import type { CommonNodeType, Node } from '@/app/components/workflow/types'
|
||||
import { Infotip, InfotipContent, InfotipTrigger } from '@langgenius/dify-ui/infotip'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { hasAgentV2OutputRoutes } from '@/app/components/workflow/nodes/agent-v2/types'
|
||||
import {
|
||||
Collapse,
|
||||
CollapseActions,
|
||||
@@ -58,6 +59,7 @@ const ErrorHandle = ({ id, data }: ErrorHandleProps) => {
|
||||
<ErrorHandleTypeSelector
|
||||
value={error_strategy || ErrorHandleTypeEnum.none}
|
||||
onSelected={handleTypeChange}
|
||||
allowDefaultValue={!hasAgentV2OutputRoutes(data)}
|
||||
/>
|
||||
</div>
|
||||
</CollapseActions>
|
||||
|
||||
+10
-2
@@ -12,9 +12,14 @@ import { ErrorHandleTypeEnum } from './types'
|
||||
|
||||
type ErrorHandleTypeSelectorProps = {
|
||||
value: ErrorHandleTypeEnum
|
||||
allowDefaultValue?: boolean
|
||||
onSelected: (value: ErrorHandleTypeEnum) => void
|
||||
}
|
||||
const ErrorHandleTypeSelector = ({ value, onSelected }: ErrorHandleTypeSelectorProps) => {
|
||||
const ErrorHandleTypeSelector = ({
|
||||
value,
|
||||
onSelected,
|
||||
allowDefaultValue = true,
|
||||
}: ErrorHandleTypeSelectorProps) => {
|
||||
const { t } = useTranslation()
|
||||
const options = [
|
||||
{
|
||||
@@ -33,6 +38,9 @@ const ErrorHandleTypeSelector = ({ value, onSelected }: ErrorHandleTypeSelectorP
|
||||
description: t(($) => $['nodes.common.errorHandle.failBranch.desc'], { ns: 'workflow' }),
|
||||
},
|
||||
]
|
||||
const availableOptions = options.filter(
|
||||
(option) => allowDefaultValue || option.value !== ErrorHandleTypeEnum.defaultValue,
|
||||
)
|
||||
const selectedOption = options.find((option) => option.value === value)
|
||||
|
||||
return (
|
||||
@@ -56,7 +64,7 @@ const ErrorHandleTypeSelector = ({ value, onSelected }: ErrorHandleTypeSelectorP
|
||||
className="w-70 rounded-xl border-[0.5px] bg-components-panel-bg-blur p-1"
|
||||
>
|
||||
<DropdownMenuRadioGroup value={value} onValueChange={onSelected}>
|
||||
{options.map((option) => (
|
||||
{availableOptions.map((option) => (
|
||||
<DropdownMenuRadioItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { memo, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getConnectedEdges, getOutgoers, useStore } from 'reactflow'
|
||||
import { ErrorHandleTypeEnum } from '@/app/components/workflow/nodes/_base/components/error-handle/types'
|
||||
import { hasAgentV2OutputRoutes } from '@/app/components/workflow/nodes/agent-v2/types'
|
||||
import { hasErrorHandleNode } from '@/app/components/workflow/utils'
|
||||
import BlockIcon from '../../../../block-icon'
|
||||
import { useToolIcon } from '../../../../hooks/use-tool-icon'
|
||||
@@ -65,7 +66,13 @@ const NextStep = ({ selectedNode }: NextStepProps) => {
|
||||
name:
|
||||
data.type === BlockEnum.QuestionClassifier
|
||||
? `${t(($) => $['nodes.questionClassifiers.class'], { ns: 'workflow' })} ${index + 1}`
|
||||
: branch.name,
|
||||
: hasAgentV2OutputRoutes(data)
|
||||
? data.agent_output_routes?.routes?.[index]?.label ||
|
||||
t(($) => $['nodes.agent.outputRoutes.route'], {
|
||||
ns: 'workflow',
|
||||
index: index + 1,
|
||||
})
|
||||
: branch.name,
|
||||
},
|
||||
nextNodes,
|
||||
}
|
||||
@@ -83,25 +90,25 @@ const NextStep = ({ selectedNode }: NextStepProps) => {
|
||||
nextNodes,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
if (data.error_strategy === ErrorHandleTypeEnum.failBranch && hasErrorHandleNode(data.type)) {
|
||||
const connected = connectedEdges.filter(
|
||||
(edge) => edge.sourceHandle === ErrorHandleTypeEnum.failBranch,
|
||||
)
|
||||
const nextNodes = resolveNextNodes(connected)
|
||||
if (data.error_strategy === ErrorHandleTypeEnum.failBranch && hasErrorHandleNode(data.type)) {
|
||||
const connected = connectedEdges.filter(
|
||||
(edge) => edge.sourceHandle === ErrorHandleTypeEnum.failBranch,
|
||||
)
|
||||
const nextNodes = resolveNextNodes(connected)
|
||||
|
||||
items.push({
|
||||
branch: {
|
||||
id: ErrorHandleTypeEnum.failBranch,
|
||||
name: t(($) => $['common.onFailure'], { ns: 'workflow' }),
|
||||
},
|
||||
nextNodes,
|
||||
})
|
||||
}
|
||||
items.push({
|
||||
branch: {
|
||||
id: ErrorHandleTypeEnum.failBranch,
|
||||
name: t(($) => $['common.onFailure'], { ns: 'workflow' }),
|
||||
},
|
||||
nextNodes,
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
}, [branches, connectedEdges, data.error_strategy, data.type, outgoers, t])
|
||||
}, [branches, connectedEdges, data, outgoers, t])
|
||||
|
||||
return (
|
||||
<div className="flex py-1">
|
||||
|
||||
@@ -6,6 +6,7 @@ import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Handle, Position } from 'reactflow'
|
||||
import { hasAgentV2OutputRoutes } from '@/app/components/workflow/nodes/agent-v2/types'
|
||||
import BlockSelector from '../../../block-selector'
|
||||
import { useAvailableBlocks } from '../../../hooks/use-available-blocks'
|
||||
import { useNodesInteractions } from '../../../hooks/use-nodes-interactions'
|
||||
@@ -177,6 +178,7 @@ export const NodeSourceHandle = memo(
|
||||
const hasMultipleSourceHandles =
|
||||
data.type === BlockEnum.IfElse ||
|
||||
data.type === BlockEnum.QuestionClassifier ||
|
||||
hasAgentV2OutputRoutes(data) ||
|
||||
data.type === BlockEnum.HumanInput ||
|
||||
data.error_strategy === ErrorHandleTypeEnum.failBranch
|
||||
const handleOpenChange = useCallback<NonNullable<BlockSelectorProps['onOpenChange']>>(
|
||||
|
||||
+46
-1
@@ -16,7 +16,7 @@ import {
|
||||
VarType,
|
||||
} from '@/app/components/workflow/types'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { getNodeUsedVars, toNodeAvailableVars, updateNodeVars } from '../utils'
|
||||
import { findUsedVarNodes, getNodeUsedVars, toNodeAvailableVars, updateNodeVars } from '../utils'
|
||||
|
||||
const createNode = <T>(data: Node<T>['data']): Node<T> => ({
|
||||
id: 'node-1',
|
||||
@@ -438,6 +438,51 @@ describe('variable utils', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ enabled: false, count: 2 },
|
||||
{ enabled: true, count: 2 },
|
||||
])(
|
||||
'maintains saved route references separately from execution inputs: %j',
|
||||
({ enabled, count }) => {
|
||||
const node = createNode<AgentV2NodeType>({
|
||||
type: BlockEnum.AgentV2,
|
||||
title: 'Agent',
|
||||
desc: '',
|
||||
version: '2',
|
||||
agent_node_kind: 'dify_agent',
|
||||
agent_output_routes: {
|
||||
enabled,
|
||||
routes: Array.from({ length: count }, (_, index) => ({
|
||||
id: `route-${index}`,
|
||||
name: '{{#upstream.foo#}}',
|
||||
})),
|
||||
},
|
||||
})
|
||||
const users = findUsedVarNodes(['upstream', 'foo'], [node])
|
||||
expect(users).toEqual([node])
|
||||
expect(getNodeUsedVars(node, { forExecution: true })).toEqual(
|
||||
enabled ? [['upstream', 'foo']] : [],
|
||||
)
|
||||
const renamed = updateNodeVars(
|
||||
users[0]!,
|
||||
['upstream', 'foo'],
|
||||
['upstream', 'bar'],
|
||||
) as Node<AgentV2NodeType>
|
||||
expect(renamed.data.agent_output_routes?.routes?.map((route) => route.name)).toEqual(
|
||||
Array.from({ length: count }, () => '{{#upstream.bar#}}'),
|
||||
)
|
||||
const copied = updateNodeVars(
|
||||
renamed,
|
||||
['upstream', 'bar'],
|
||||
['copy', 'bar'],
|
||||
) as Node<AgentV2NodeType>
|
||||
expect(getNodeUsedVars(copied)).toContainEqual(['copy', 'bar'])
|
||||
// Text references remain visible after deletion, as in Question Classifier conditions.
|
||||
const deleted = updateNodeVars(copied, ['copy', 'bar'], [])
|
||||
expect(findUsedVarNodes(['copy', 'bar'], [deleted])).toEqual([deleted])
|
||||
},
|
||||
)
|
||||
|
||||
it('keeps datasource output variables typed through the public picker projection', () => {
|
||||
const provider = createDatasourceProvider()
|
||||
provider.declaration.datasources![0]!.output_schema = {
|
||||
|
||||
@@ -1228,7 +1228,8 @@ const replaceOldVarInPromptItem = (
|
||||
: {}),
|
||||
})
|
||||
|
||||
export const getNodeUsedVars = (node: Node): ValueSelector[] => {
|
||||
/** Include saved references for rename/delete/copy; execution excludes inactive route conditions. */
|
||||
export const getNodeUsedVars = (node: Node, { forExecution = false } = {}): ValueSelector[] => {
|
||||
const { data } = node
|
||||
const { type } = data
|
||||
let res: ValueSelector[] = []
|
||||
@@ -1341,7 +1342,12 @@ export const getNodeUsedVars = (node: Node): ValueSelector[] => {
|
||||
case BlockEnum.Agent: {
|
||||
if (isAgentV2NodeData(data)) {
|
||||
const payload = data as AgentV2NodeType
|
||||
res = matchNotSystemVars([payload.agent_task || ''])
|
||||
res = matchNotSystemVars([
|
||||
payload.agent_task || '',
|
||||
...(!forExecution || payload.agent_output_routes?.enabled
|
||||
? (payload.agent_output_routes?.routes?.map((route) => route.name ?? '') ?? [])
|
||||
: []),
|
||||
])
|
||||
break
|
||||
}
|
||||
|
||||
@@ -1358,7 +1364,12 @@ export const getNodeUsedVars = (node: Node): ValueSelector[] => {
|
||||
}
|
||||
case BlockEnum.AgentV2: {
|
||||
const payload = data as AgentV2NodeType
|
||||
res = matchNotSystemVars([payload.agent_task || ''])
|
||||
res = matchNotSystemVars([
|
||||
payload.agent_task || '',
|
||||
...(!forExecution || payload.agent_output_routes?.enabled
|
||||
? (payload.agent_output_routes?.routes?.map((route) => route.name ?? '') ?? [])
|
||||
: []),
|
||||
])
|
||||
break
|
||||
}
|
||||
case BlockEnum.DataSource: {
|
||||
@@ -1734,6 +1745,9 @@ export const updateNodeVars = (
|
||||
oldVarSelector,
|
||||
newVarSelector,
|
||||
)
|
||||
payload.agent_output_routes?.routes?.forEach((route) => {
|
||||
route.name = replaceOldVarInText(route.name ?? '', oldVarSelector, newVarSelector)
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
@@ -1779,6 +1793,9 @@ export const updateNodeVars = (
|
||||
oldVarSelector,
|
||||
newVarSelector,
|
||||
)
|
||||
payload.agent_output_routes?.routes?.forEach((route) => {
|
||||
route.name = replaceOldVarInText(route.name ?? '', oldVarSelector, newVarSelector)
|
||||
})
|
||||
break
|
||||
}
|
||||
case BlockEnum.DataSource: {
|
||||
|
||||
@@ -10,6 +10,7 @@ import BlockIcon from '@/app/components/workflow/block-icon'
|
||||
import { ToolType } from '@/app/components/workflow/block-selector/types'
|
||||
import { useCollaboration } from '@/app/components/workflow/collaboration/hooks/use-collaboration'
|
||||
import { useHooksStore } from '@/app/components/workflow/hooks-store'
|
||||
import { hasAgentV2OutputRoutes } from '@/app/components/workflow/nodes/agent-v2/types'
|
||||
import { useNodeIterationInteractions } from '@/app/components/workflow/nodes/iteration/use-interactions'
|
||||
import { useNodeLoopInteractions } from '@/app/components/workflow/nodes/loop/use-interactions'
|
||||
import CopyID from '@/app/components/workflow/nodes/tool/components/copy-id'
|
||||
@@ -226,6 +227,7 @@ const BaseNode: FC<BaseNodeProps> = ({ id, data, children }) => {
|
||||
{data.type !== BlockEnum.StartPlaceholder &&
|
||||
data.type !== BlockEnum.IfElse &&
|
||||
data.type !== BlockEnum.QuestionClassifier &&
|
||||
!hasAgentV2OutputRoutes(data) &&
|
||||
data.type !== BlockEnum.HumanInput &&
|
||||
!data._isCandidate && (
|
||||
<NodeSourceHandle
|
||||
|
||||
@@ -112,6 +112,26 @@ describe('agent/default', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ enabled: false, conditions: [''], valid: true },
|
||||
{ enabled: true, conditions: [], valid: false },
|
||||
{ enabled: true, conditions: ['Accept'], valid: false },
|
||||
{ enabled: true, conditions: ['Accept', ' '], valid: false },
|
||||
{ enabled: true, conditions: ['Accept', 'Reject'], valid: true },
|
||||
])('requires at least two complete routes when enabled: %j', ({ enabled, conditions, valid }) => {
|
||||
const result = nodeDefault.checkValid(
|
||||
createPayload({
|
||||
agent_output_routes: {
|
||||
enabled,
|
||||
routes: conditions.map((name, index) => ({ id: `route-${index}`, name })),
|
||||
},
|
||||
}),
|
||||
t,
|
||||
)
|
||||
|
||||
expect(result.isValid).toBe(valid)
|
||||
})
|
||||
|
||||
it('links directly to the New Agent section of the shared agent node document', () => {
|
||||
expect(nodeDefault.metaData.helpLinkUri).toBe('agent#new-agent')
|
||||
})
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { AgentV2NodeType } from '../types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { screen } from '@testing-library/react'
|
||||
import { renderWorkflowFlowComponent as render } from '@/app/components/workflow/__tests__/workflow-test-env'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { ErrorHandleTypeEnum } from '../../_base/components/error-handle/types'
|
||||
import BaseNode from '../../_base/node'
|
||||
import { AgentV2Node } from '../node'
|
||||
|
||||
const { mockUseAgentRosterDetail, mockUseWorkflowInlineAgentDetail } = vi.hoisted(() => ({
|
||||
@@ -28,12 +31,29 @@ vi.mock('../../_base/components/setting-item', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../hooks', () => ({
|
||||
vi.mock('../hooks', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../hooks')>()),
|
||||
useAgentRosterDetail: (agentId?: string) => mockUseAgentRosterDetail(agentId),
|
||||
useWorkflowInlineAgentDetail: (nodeId?: string, agentId?: string | null) =>
|
||||
mockUseWorkflowInlineAgentDetail(nodeId, agentId),
|
||||
useCreateInlineAgentBinding: () => ({ createInlineAgentBinding: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('../../../hooks/use-tool-icon', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../../../hooks/use-tool-icon')>()),
|
||||
useToolIcon: () => undefined,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/collaboration/hooks/use-collaboration', () => ({
|
||||
useCollaboration: () => ({ nodePanelPresence: {} }),
|
||||
}))
|
||||
|
||||
// BaseNode reads plugin permissions even when rendering an Agent node.
|
||||
vi.mock('@/context/permission-state', async () => {
|
||||
const { createPermissionStateModuleMock } = await import('@/test/console/state-fixture')
|
||||
return createPermissionStateModuleMock(() => ({ workspacePermissionKeys: [] }))
|
||||
})
|
||||
|
||||
const createData = (overrides: Partial<AgentV2NodeType> = {}): AgentV2NodeType => ({
|
||||
title: 'Agent',
|
||||
desc: '',
|
||||
@@ -171,4 +191,37 @@ describe('agent/node', () => {
|
||||
'workflow.errorMsg.fieldRequired',
|
||||
)
|
||||
})
|
||||
|
||||
it('switches the composed card between route handles and source while preserving the failure exit', () => {
|
||||
const data = createData({
|
||||
error_strategy: ErrorHandleTypeEnum.failBranch,
|
||||
agent_output_routes: {
|
||||
enabled: true,
|
||||
routes: [
|
||||
{ id: 'accepted', name: 'Accepted' },
|
||||
{ id: 'rejected', name: 'Rejected' },
|
||||
],
|
||||
},
|
||||
})
|
||||
const card = (data: AgentV2NodeType) => (
|
||||
<BaseNode id="agent-node" data={data}>
|
||||
<AgentV2Node id="agent-node" data={data} />
|
||||
</BaseNode>
|
||||
)
|
||||
const { container, rerender } = render(card(data))
|
||||
// ReactFlow consumes these IDs to connect and restore workflow edges.
|
||||
const sourceHandles = () =>
|
||||
Array.from(container.querySelectorAll('.react-flow__handle.source'), (handle) =>
|
||||
handle.getAttribute('data-handleid'),
|
||||
).sort()
|
||||
|
||||
expect(sourceHandles()).toEqual(['accepted', 'fail-branch', 'rejected'])
|
||||
rerender(
|
||||
card({
|
||||
...data,
|
||||
agent_output_routes: { ...data.agent_output_routes!, enabled: false },
|
||||
}),
|
||||
)
|
||||
expect(sourceHandles()).toEqual(['fail-branch', 'source'])
|
||||
})
|
||||
})
|
||||
@@ -8,6 +8,8 @@ import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { FlowType } from '@/types/common'
|
||||
import { AgentV2Panel } from '../panel'
|
||||
|
||||
vi.mock('../components/agent-output-routes', () => ({ AgentOutputRoutes: () => null }))
|
||||
|
||||
const {
|
||||
mockEditorFocus,
|
||||
mockEditorUpdate,
|
||||
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { AgentV2NodeType } from '../../types'
|
||||
import type { Edge, Node } from '@/app/components/workflow/types'
|
||||
import { act, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useEdges, useNodes } from 'reactflow'
|
||||
import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
|
||||
import { useNodesInteractions } from '@/app/components/workflow/hooks/use-nodes-interactions'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { AgentOutputRoutes } from '../agent-output-routes'
|
||||
|
||||
vi.mock('../../../_base/hooks/use-available-var-list', () => ({
|
||||
default: () => ({ availableVars: [], availableNodesWithParent: [] }),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../../hooks')>()),
|
||||
useCreateInlineAgentBinding: () => ({ createInlineAgentBinding: vi.fn() }),
|
||||
}))
|
||||
|
||||
// Route history depends on the list mutation, not the rich text editor's token handling.
|
||||
vi.mock('../../../_base/components/prompt/editor', () => ({
|
||||
default: ({
|
||||
title,
|
||||
value,
|
||||
onRemove,
|
||||
showRemove,
|
||||
}: {
|
||||
title: ReactNode
|
||||
value: string
|
||||
onRemove: () => void
|
||||
showRemove?: boolean
|
||||
}) => (
|
||||
<div>
|
||||
{title}
|
||||
<textarea aria-label="Condition" value={value} readOnly />
|
||||
{showRemove && (
|
||||
<button type="button" onClick={onRemove} aria-label={`Remove ${value}`}>
|
||||
Remove route
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
function HistoryEditor() {
|
||||
const nodes = useNodes<AgentV2NodeType>()
|
||||
const edges = useEdges()
|
||||
const { handleHistoryBack } = useNodesInteractions()
|
||||
const agent = nodes.find((node) => node.id === 'agent')
|
||||
if (!agent) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<AgentOutputRoutes id={agent.id} data={agent.data} />
|
||||
<button type="button" onClick={handleHistoryBack}>
|
||||
Undo
|
||||
</button>
|
||||
<output aria-label="Connections">{edges.map((edge) => edge.id).join(',')}</output>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function renderHistoryEditor({ connected = false }: { connected?: boolean } = {}) {
|
||||
const routes = [
|
||||
{ id: 'accepted', name: 'Accept', label: 'Accepted' },
|
||||
{ id: 'rejected', name: 'Reject', label: 'Rejected' },
|
||||
...(connected ? [{ id: 'deferred', name: 'Defer', label: 'Deferred' }] : []),
|
||||
]
|
||||
const agent: Node<AgentV2NodeType> = {
|
||||
id: 'agent',
|
||||
type: 'default',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
type: BlockEnum.AgentV2,
|
||||
title: 'Agent',
|
||||
desc: '',
|
||||
version: '2',
|
||||
agent_node_kind: 'dify_agent',
|
||||
agent_output_routes: { enabled: true, routes },
|
||||
_targetBranches: routes,
|
||||
_connectedSourceHandleIds: connected ? ['accepted'] : [],
|
||||
},
|
||||
}
|
||||
const nodes: Node[] = [agent]
|
||||
const edges: Edge[] = []
|
||||
if (connected) {
|
||||
nodes.push({
|
||||
id: 'end',
|
||||
type: 'default',
|
||||
position: { x: 300, y: 0 },
|
||||
data: {
|
||||
type: BlockEnum.End,
|
||||
title: 'End',
|
||||
desc: '',
|
||||
_connectedTargetHandleIds: ['target'],
|
||||
},
|
||||
})
|
||||
edges.push({
|
||||
id: 'accepted-result',
|
||||
source: 'agent',
|
||||
sourceHandle: 'accepted',
|
||||
target: 'end',
|
||||
targetHandle: 'target',
|
||||
data: { sourceType: BlockEnum.AgentV2, targetType: BlockEnum.End },
|
||||
})
|
||||
}
|
||||
return renderWorkflowFlowComponent(<HistoryEditor />, {
|
||||
nodes,
|
||||
edges,
|
||||
historyStore: { nodes, edges },
|
||||
hooksStoreProps: { doSyncWorkflowDraft: vi.fn() },
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('restores enabled routes with one undo when disabling them removes no edges', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
renderHistoryEditor()
|
||||
const toggle = screen.getByRole('switch', { name: 'workflow.nodes.agent.outputRoutes.title' })
|
||||
|
||||
await user.click(toggle)
|
||||
expect(toggle).not.toBeChecked()
|
||||
await act(() => vi.advanceTimersByTimeAsync(500))
|
||||
await user.click(screen.getByRole('button', { name: 'Undo' }))
|
||||
|
||||
expect(toggle).toBeChecked()
|
||||
expect(screen.getAllByRole('textbox', { name: 'Condition' })).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('restores a deleted route and its connection with one undo', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
renderHistoryEditor({ connected: true })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Remove Accept' }))
|
||||
expect(screen.queryByRole('button', { name: 'Accepted' })).not.toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Connections')).toBeEmptyDOMElement()
|
||||
await act(() => vi.advanceTimersByTimeAsync(500))
|
||||
await user.click(screen.getByRole('button', { name: 'Undo' }))
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Accepted' })).toBeVisible()
|
||||
expect(screen.getAllByRole('textbox', { name: 'Condition' })).toHaveLength(3)
|
||||
expect(screen.getByLabelText('Connections')).toHaveTextContent('accepted-result')
|
||||
})
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { AgentV2NodeType } from '../../types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useState } from 'react'
|
||||
import { BlockEnum, VarType } from '@/app/components/workflow/types'
|
||||
import { ErrorHandleTypeEnum } from '../../../_base/components/error-handle/types'
|
||||
import { getAgentV2DeclaredOutputs } from '../../output-variables'
|
||||
import { AgentOutputRoutes } from '../agent-output-routes'
|
||||
|
||||
const { removeEdges, removeVariable, saveHistory, update } = vi.hoisted(() => ({
|
||||
removeEdges: vi.fn(),
|
||||
removeVariable: vi.fn(),
|
||||
saveHistory: vi.fn(),
|
||||
update: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/app/components/workflow/hooks/use-node-data-update', () => ({
|
||||
useNodeDataUpdate: () => ({ handleNodeDataUpdate: update }),
|
||||
}))
|
||||
vi.mock('@/app/components/workflow/hooks/use-edges-interactions', () => ({
|
||||
useEdgesInteractions: () => ({ removeBranchEdges: removeEdges }),
|
||||
}))
|
||||
vi.mock('@/app/components/workflow/hooks/use-nodes-sync-draft', () => ({
|
||||
useNodesSyncDraft: () => ({ handleSyncWorkflowDraft: vi.fn() }),
|
||||
}))
|
||||
vi.mock('@/app/components/workflow/hooks/use-workflow', () => ({
|
||||
useNodesReadOnly: () => ({ nodesReadOnly: false, getNodesReadOnly: () => false }),
|
||||
useWorkflow: () => ({ removeUsedVarInNodes: removeVariable }),
|
||||
}))
|
||||
vi.mock('@/app/components/workflow/hooks/use-workflow-history', () => ({
|
||||
useWorkflowHistory: () => ({ saveStateToHistory: saveHistory }),
|
||||
WorkflowHistoryEvent: { NodeChange: 'NodeChange' },
|
||||
}))
|
||||
vi.mock('../../../_base/hooks/use-available-var-list', () => ({
|
||||
default: () => ({ availableVars: [], availableNodesWithParent: [] }),
|
||||
}))
|
||||
vi.mock('react-sortablejs', () => ({
|
||||
ReactSortable: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
}))
|
||||
// The rich text editor's own tests cover variable tokens; this boundary exercises
|
||||
// the route editor's persisted condition, label, and list mutations.
|
||||
vi.mock('../../../_base/components/prompt/editor', () => ({
|
||||
default: ({
|
||||
title,
|
||||
value,
|
||||
onChange,
|
||||
onRemove,
|
||||
showRemove,
|
||||
}: {
|
||||
title: ReactNode
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
onRemove: () => void
|
||||
showRemove?: boolean
|
||||
}) => (
|
||||
<div>
|
||||
{title}
|
||||
<textarea
|
||||
aria-label="Condition"
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
{showRemove && (
|
||||
<button type="button" onClick={onRemove}>
|
||||
Remove route
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
function Harness({
|
||||
initial,
|
||||
errorStrategy,
|
||||
}: {
|
||||
initial?: AgentV2NodeType['agent_output_routes']
|
||||
errorStrategy?: ErrorHandleTypeEnum
|
||||
}) {
|
||||
const [data, setData] = useState<AgentV2NodeType>({
|
||||
type: BlockEnum.AgentV2,
|
||||
title: 'Agent',
|
||||
desc: '',
|
||||
version: '2',
|
||||
agent_node_kind: 'dify_agent',
|
||||
agent_output_routes: initial,
|
||||
error_strategy: errorStrategy,
|
||||
default_value:
|
||||
errorStrategy === ErrorHandleTypeEnum.defaultValue
|
||||
? [{ key: 'text', type: VarType.string, value: 'fallback' }]
|
||||
: undefined,
|
||||
})
|
||||
update.mockImplementation(({ data: changed }: { data: Partial<AgentV2NodeType> }) =>
|
||||
setData((data) => ({ ...data, ...changed })),
|
||||
)
|
||||
return (
|
||||
<>
|
||||
<AgentOutputRoutes id="agent" data={data} />
|
||||
<output aria-label="Available outputs">
|
||||
{getAgentV2DeclaredOutputs(data)
|
||||
.map((output) => output.name)
|
||||
.join(',')}
|
||||
</output>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('enables two routes, preserves edited conditions through toggles, and changes success handles', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Harness initial={{ enabled: false, routes: [] }} />)
|
||||
const toggle = screen.getByRole('switch', { name: 'workflow.nodes.agent.outputRoutes.title' })
|
||||
await user.click(toggle)
|
||||
expect(screen.getAllByRole('textbox', { name: 'Condition' })).toHaveLength(2)
|
||||
expect(screen.getByLabelText('Available outputs')).toHaveTextContent('text,switch')
|
||||
expect(removeEdges).toHaveBeenCalledWith('agent', ['source'])
|
||||
await user.type(screen.getAllByRole('textbox', { name: 'Condition' })[0]!, 'Request approved')
|
||||
const saved = update.mock.lastCall![0].data.agent_output_routes
|
||||
await user.click(toggle)
|
||||
expect(removeEdges).toHaveBeenCalledWith(
|
||||
'agent',
|
||||
saved.routes.map((route: { id: string }) => route.id),
|
||||
)
|
||||
expect(removeVariable).toHaveBeenCalledWith(['agent', 'switch'])
|
||||
expect(screen.getByLabelText('Available outputs').textContent).toBe('text')
|
||||
await user.click(toggle)
|
||||
expect(screen.getAllByRole('textbox', { name: 'Condition' })[0]).toHaveValue('Request approved')
|
||||
expect(update.mock.lastCall![0].data.agent_output_routes.routes).toEqual(saved.routes)
|
||||
expect(saveHistory).toHaveBeenCalledWith('NodeChange')
|
||||
})
|
||||
|
||||
it('removes a route by stable ID and keeps at least two routes with switch available', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<Harness
|
||||
initial={{
|
||||
enabled: true,
|
||||
routes: [
|
||||
{ id: 'accepted', name: 'Accept' },
|
||||
{ id: 'rejected', name: 'Reject' },
|
||||
{ id: 'deferred', name: 'Defer' },
|
||||
],
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
await user.click(screen.getAllByRole('button', { name: 'Remove route' })[0]!)
|
||||
expect(removeEdges).toHaveBeenCalledWith('agent', ['accepted'])
|
||||
expect(removeVariable).not.toHaveBeenCalled()
|
||||
expect(update.mock.lastCall![0].data._targetBranches).toEqual([
|
||||
{ id: 'rejected', name: 'Reject' },
|
||||
{ id: 'deferred', name: 'Defer' },
|
||||
])
|
||||
expect(screen.getByLabelText('Available outputs').textContent).toBe('text,switch')
|
||||
expect(screen.getAllByRole('textbox', { name: 'Condition' })).toHaveLength(2)
|
||||
expect(screen.queryByRole('button', { name: 'Remove route' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('collapses routes independently of their enabled state and keeps one title', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<Harness
|
||||
initial={{
|
||||
enabled: true,
|
||||
routes: [
|
||||
{ id: 'accepted', name: 'Accept' },
|
||||
{ id: 'rejected', name: 'Reject' },
|
||||
],
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
const title = 'workflow.nodes.agent.outputRoutes.title'
|
||||
const disclosure = screen.getByRole('button', { name: new RegExp(title) })
|
||||
const toggle = screen.getByRole('switch', { name: title })
|
||||
expect(screen.getAllByText(title, { exact: true })).toHaveLength(1)
|
||||
expect(disclosure).toHaveAttribute('aria-expanded', 'true')
|
||||
|
||||
await user.click(disclosure)
|
||||
expect(disclosure).toHaveAttribute('aria-expanded', 'false')
|
||||
expect(screen.queryByRole('textbox', { name: 'Condition' })).not.toBeInTheDocument()
|
||||
expect(toggle).toBeChecked()
|
||||
expect(update).not.toHaveBeenCalled()
|
||||
expect(removeEdges).not.toHaveBeenCalled()
|
||||
|
||||
await user.click(disclosure)
|
||||
expect(screen.getAllByRole('textbox', { name: 'Condition' })[0]).toHaveValue('Accept')
|
||||
await user.click(toggle)
|
||||
expect(disclosure).toHaveAttribute('aria-disabled', 'true')
|
||||
await user.click(disclosure)
|
||||
expect(screen.queryByRole('textbox', { name: 'Condition' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('workflow.nodes.agent.outputRoutes.add')).not.toBeInTheDocument()
|
||||
await user.click(toggle)
|
||||
expect(disclosure).not.toHaveAttribute('aria-disabled', 'true')
|
||||
expect(disclosure).toHaveAttribute('aria-expanded', 'true')
|
||||
expect(screen.getAllByRole('textbox', { name: 'Condition' })).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('keeps a saved disabled route and adds only the missing route when enabling', async () => {
|
||||
const user = userEvent.setup()
|
||||
const saved = { id: 'accepted', name: 'Accept', label: 'Accepted' }
|
||||
render(<Harness initial={{ enabled: false, routes: [saved] }} />)
|
||||
|
||||
await user.click(screen.getByRole('switch', { name: 'workflow.nodes.agent.outputRoutes.title' }))
|
||||
|
||||
const routes = update.mock.lastCall![0].data.agent_output_routes.routes
|
||||
expect(routes).toHaveLength(2)
|
||||
expect(routes[0]).toEqual(saved)
|
||||
expect(routes[1]).toMatchObject({ id: expect.any(String), name: '' })
|
||||
expect(routes[1].id).not.toBe(saved.id)
|
||||
expect(screen.getAllByRole('textbox', { name: 'Condition' })[0]).toHaveValue('Accept')
|
||||
expect(screen.queryByRole('button', { name: 'Remove route' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('clears node-level default values when enabling routes', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Harness errorStrategy={ErrorHandleTypeEnum.defaultValue} />)
|
||||
await user.click(screen.getByRole('switch', { name: 'workflow.nodes.agent.outputRoutes.title' }))
|
||||
expect(update.mock.lastCall![0].data).toMatchObject({
|
||||
agent_output_routes: { enabled: true },
|
||||
error_strategy: undefined,
|
||||
default_value: undefined,
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { WorkflowOutputRoutes } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import type { AgentV2NodeType } from '../types'
|
||||
import type { NodeProps, Var } from '@/app/components/workflow/types'
|
||||
import { Switch } from '@langgenius/dify-ui/switch'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useEdgesInteractions } from '@/app/components/workflow/hooks/use-edges-interactions'
|
||||
import { useNodeDataUpdate } from '@/app/components/workflow/hooks/use-node-data-update'
|
||||
import { useNodesSyncDraft } from '@/app/components/workflow/hooks/use-nodes-sync-draft'
|
||||
import { useNodesReadOnly, useWorkflow } from '@/app/components/workflow/hooks/use-workflow'
|
||||
import {
|
||||
useWorkflowHistory,
|
||||
WorkflowHistoryEvent,
|
||||
} from '@/app/components/workflow/hooks/use-workflow-history'
|
||||
import { VarType } from '@/app/components/workflow/types'
|
||||
import BranchList from '../../_base/components/branch-list/class-list'
|
||||
import { ErrorHandleTypeEnum } from '../../_base/components/error-handle/types'
|
||||
|
||||
const filterVar = (variable: Var) =>
|
||||
variable.type === VarType.string || variable.type === VarType.number
|
||||
|
||||
export function AgentOutputRoutes({ id, data }: NodeProps<AgentV2NodeType>) {
|
||||
const { t } = useTranslation()
|
||||
const { nodesReadOnly, getNodesReadOnly } = useNodesReadOnly()
|
||||
const { removeBranchEdges } = useEdgesInteractions()
|
||||
const { handleNodeDataUpdate } = useNodeDataUpdate()
|
||||
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
|
||||
const { saveStateToHistory } = useWorkflowHistory()
|
||||
const { removeUsedVarInNodes } = useWorkflow()
|
||||
const routes = data.agent_output_routes
|
||||
const list = (routes?.routes ?? []).map((route) => ({ ...route, name: route.name ?? '' }))
|
||||
const defaultLabel = (index: number) =>
|
||||
t(($) => $['nodes.agent.outputRoutes.route'], { ns: 'workflow', index })
|
||||
|
||||
const update = (next: WorkflowOutputRoutes) => {
|
||||
if (getNodesReadOnly()) return
|
||||
|
||||
const nextRoutes = next.routes?.map(({ id, name, label }) => ({ id, name, label }))
|
||||
const currentHandles = routes?.enabled ? list.map((route) => route.id) : ['source']
|
||||
const nextHandles = next.enabled ? (nextRoutes ?? []).map((route) => route.id) : ['source']
|
||||
// Downstream references must be removed before their connecting edges disappear.
|
||||
if (routes?.enabled && !next.enabled) removeUsedVarInNodes([id, 'switch'])
|
||||
handleNodeDataUpdate({
|
||||
id,
|
||||
data: {
|
||||
agent_output_routes: { ...next, routes: nextRoutes },
|
||||
...(next.enabled && data.error_strategy === ErrorHandleTypeEnum.defaultValue
|
||||
? { error_strategy: undefined, default_value: undefined }
|
||||
: {}),
|
||||
_targetBranches: next.enabled
|
||||
? nextRoutes?.map((route) => ({ id: route.id, name: route.name ?? '' }))
|
||||
: [],
|
||||
},
|
||||
})
|
||||
removeBranchEdges(
|
||||
id,
|
||||
currentHandles.filter((handle) => !nextHandles.includes(handle)),
|
||||
)
|
||||
handleSyncWorkflowDraft()
|
||||
saveStateToHistory(WorkflowHistoryEvent.NodeChange)
|
||||
}
|
||||
const toggle = (enabled: boolean) => {
|
||||
const nextList = [...list]
|
||||
if (enabled) {
|
||||
while (nextList.length < 2)
|
||||
nextList.push({
|
||||
id: crypto.randomUUID(),
|
||||
name: '',
|
||||
label: defaultLabel(nextList.length + 1),
|
||||
})
|
||||
}
|
||||
update({ enabled, routes: nextList })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-b border-divider-subtle px-4 py-3">
|
||||
<BranchList
|
||||
nodeId={id}
|
||||
list={list}
|
||||
minItems={2}
|
||||
enabled={!!routes?.enabled}
|
||||
actions={
|
||||
<Switch
|
||||
aria-label={t(($) => $['nodes.agent.outputRoutes.title'], { ns: 'workflow' })}
|
||||
checked={!!routes?.enabled}
|
||||
disabled={nodesReadOnly}
|
||||
onCheckedChange={toggle}
|
||||
/>
|
||||
}
|
||||
onChange={(routes) => update({ enabled: true, routes })}
|
||||
onRemove={(routeId) =>
|
||||
update({ enabled: true, routes: list.filter((route) => route.id !== routeId) })
|
||||
}
|
||||
handleSortTopic={(routes) => update({ enabled: true, routes })}
|
||||
readonly={nodesReadOnly}
|
||||
filterVar={filterVar}
|
||||
labels={{
|
||||
title: t(($) => $['nodes.agent.outputRoutes.title'], { ns: 'workflow' }),
|
||||
add: t(($) => $['nodes.agent.outputRoutes.add'], { ns: 'workflow' }),
|
||||
placeholder: t(($) => $['nodes.agent.outputRoutes.placeholder'], { ns: 'workflow' }),
|
||||
renameHint: t(($) => $['nodes.agent.outputRoutes.renameHint'], { ns: 'workflow' }),
|
||||
defaultLabel,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+4
-6
@@ -3,7 +3,7 @@ import type {
|
||||
DeclaredOutputType,
|
||||
} from '@dify/contracts/api/console/apps/types.gen'
|
||||
import type { TFunction } from 'i18next'
|
||||
import { agentV2SystemTextOutput } from '../../output-variables'
|
||||
import { AGENT_V2_RESERVED_OUTPUT_NAMES } from '../../output-variables'
|
||||
|
||||
export type DeclaredOutputChildConfig = NonNullable<DeclaredOutputConfig['children']>[number]
|
||||
|
||||
@@ -279,15 +279,13 @@ export function getDefaultValueErrorKey(draft: OutputDraft) {
|
||||
}
|
||||
|
||||
export function isDefaultOutput(output: DeclaredOutputConfig) {
|
||||
return (
|
||||
agentV2SystemTextOutput.name === output.name &&
|
||||
agentV2SystemTextOutput.type === output.type &&
|
||||
getOutputTypeOptionValue(agentV2SystemTextOutput) === getOutputTypeOptionValue(output)
|
||||
)
|
||||
return AGENT_V2_RESERVED_OUTPUT_NAMES.has(output.name)
|
||||
}
|
||||
|
||||
export function getOutputDescription(output: EditableOutputConfig, t: TFunction) {
|
||||
if (output.name === 'text') return t(($) => $['nodes.agent.outputVars.text'], { ns: 'workflow' })
|
||||
if (output.name === 'switch')
|
||||
return t(($) => $['nodes.agent.outputVars.switch'], { ns: 'workflow' })
|
||||
return output.description || ''
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,19 @@ const nodeDefault: NodeDefault<AgentV2NodeType> = {
|
||||
}
|
||||
}
|
||||
|
||||
const routes = payload.agent_output_routes
|
||||
if (
|
||||
routes?.enabled &&
|
||||
((routes.routes?.length ?? 0) < 2 || routes.routes?.some((route) => !route.name?.trim()))
|
||||
)
|
||||
return {
|
||||
isValid: false,
|
||||
errorMessage: t(($) => $['errorMsg.fieldRequired'], {
|
||||
ns: 'workflow',
|
||||
field: t(($) => $['nodes.agent.outputRoutes.title'], { ns: 'workflow' }),
|
||||
}),
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: true,
|
||||
errorMessage: '',
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import type { NodeProps } from '../../types'
|
||||
import type { AgentV2NodeType } from './types'
|
||||
import type { AppIconType } from '@/types/app'
|
||||
import { useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useUpdateNodeInternals } from 'reactflow'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import { NodeBranches } from '../_base/components/branch-list/node-branches'
|
||||
import { SettingItem } from '../_base/components/setting-item'
|
||||
import { useAgentRosterDetail, useWorkflowInlineAgentDetail } from './hooks'
|
||||
import { hasInlineAgentBinding, hasValidRosterAgentBinding } from './types'
|
||||
@@ -96,7 +99,12 @@ function AgentNodeModel({
|
||||
)
|
||||
}
|
||||
|
||||
export function AgentV2Node({ id, data }: NodeProps<AgentV2NodeType>) {
|
||||
export function AgentV2Node(props: NodeProps<AgentV2NodeType>) {
|
||||
const { id, data } = props
|
||||
const updateNodeInternals = useUpdateNodeInternals()
|
||||
useEffect(() => {
|
||||
updateNodeInternals(id)
|
||||
}, [id, data.agent_output_routes, updateNodeInternals])
|
||||
const { t } = useTranslation()
|
||||
const hasValidAgent = hasValidRosterAgentBinding(data)
|
||||
const isInlineAgent = hasInlineAgentBinding(data)
|
||||
@@ -110,11 +118,27 @@ export function AgentV2Node({ id, data }: NodeProps<AgentV2NodeType>) {
|
||||
|
||||
if (isInlineAgent || hasValidAgent)
|
||||
return (
|
||||
<AgentNodeModel
|
||||
data={data}
|
||||
agent={rosterAgentQuery.data}
|
||||
isLoading={isInlineAgentDetailLoading}
|
||||
/>
|
||||
<>
|
||||
<AgentNodeModel
|
||||
data={data}
|
||||
agent={rosterAgentQuery.data}
|
||||
isLoading={isInlineAgentDetailLoading}
|
||||
/>
|
||||
{data.agent_output_routes?.enabled && (
|
||||
<div className="px-3 pb-2">
|
||||
<NodeBranches
|
||||
node={props}
|
||||
branches={(data.agent_output_routes.routes ?? []).map((route) => ({
|
||||
...route,
|
||||
name: route.name ?? '',
|
||||
}))}
|
||||
defaultLabel={(index) =>
|
||||
t(($) => $['nodes.agent.outputRoutes.route'], { ns: 'workflow', index })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { DeclaredOutputConfig } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import type {
|
||||
DeclaredOutputConfig,
|
||||
WorkflowOutputRoutes,
|
||||
} from '@dify/contracts/api/console/apps/types.gen'
|
||||
import type { AgentV2NodeType } from './types'
|
||||
import type { Var } from '@/app/components/workflow/types'
|
||||
import { VarType } from '@/app/components/workflow/types'
|
||||
@@ -35,15 +38,29 @@ const arrayItemVarTypes: Record<DeclaredOutputConfig['type'], VarType> = {
|
||||
}
|
||||
|
||||
export function getAgentV2DeclaredOutputs(data: AgentV2NodeType) {
|
||||
return normalizeAgentV2DeclaredOutputs(data.agent_declared_outputs ?? [])
|
||||
return normalizeAgentV2DeclaredOutputs(
|
||||
data.agent_declared_outputs ?? [],
|
||||
data.agent_output_routes,
|
||||
)
|
||||
}
|
||||
|
||||
export function getAgentV2CustomDeclaredOutputs(outputs: readonly DeclaredOutputConfig[]) {
|
||||
return outputs.filter((output) => !AGENT_V2_RESERVED_OUTPUT_NAMES.has(output.name))
|
||||
}
|
||||
|
||||
export function normalizeAgentV2DeclaredOutputs(outputs: readonly DeclaredOutputConfig[]) {
|
||||
return [agentV2SystemTextOutput, ...getAgentV2CustomDeclaredOutputs(outputs)]
|
||||
export function normalizeAgentV2DeclaredOutputs(
|
||||
outputs: readonly DeclaredOutputConfig[],
|
||||
routes?: WorkflowOutputRoutes,
|
||||
) {
|
||||
const systemOutputs: DeclaredOutputConfig[] = [agentV2SystemTextOutput]
|
||||
if (routes?.enabled)
|
||||
systemOutputs.push({
|
||||
name: 'switch',
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'Selected output route ID.',
|
||||
})
|
||||
return [...systemOutputs, ...getAgentV2CustomDeclaredOutputs(outputs)]
|
||||
}
|
||||
|
||||
function getDeclaredOutputVarType(output: DeclaredOutputConfig) {
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
WorkflowInlineAgentConfigureWorkspace,
|
||||
WorkflowRosterAgentOrchestratePanelContent,
|
||||
} from './components/agent-orchestrate-panel-content'
|
||||
import { AgentOutputRoutes } from './components/agent-output-routes'
|
||||
import { AgentOutputVariables } from './components/agent-output-variables'
|
||||
import { OutputEditCard } from './components/agent-output-variables/edit-card'
|
||||
import { createDraft, isDefaultOutput } from './components/agent-output-variables/utils'
|
||||
@@ -140,11 +141,14 @@ export function AgentV2Panel({ id, data }: NodePanelProps<AgentV2NodeType>) {
|
||||
const [localDeclaredOutputs, setLocalDeclaredOutputs] = useState<DeclaredOutputConfig[] | null>(
|
||||
null,
|
||||
)
|
||||
const normalizedDeclaredOutputs = useMemo(
|
||||
() => normalizeAgentV2DeclaredOutputs(inputs.agent_declared_outputs ?? []),
|
||||
[inputs.agent_declared_outputs],
|
||||
const declaredOutputs = useMemo(
|
||||
() =>
|
||||
normalizeAgentV2DeclaredOutputs(
|
||||
localDeclaredOutputs ?? inputs.agent_declared_outputs ?? [],
|
||||
inputs.agent_output_routes,
|
||||
),
|
||||
[localDeclaredOutputs, inputs.agent_declared_outputs, inputs.agent_output_routes],
|
||||
)
|
||||
const declaredOutputs = localDeclaredOutputs ?? normalizedDeclaredOutputs
|
||||
const rosterAgentId =
|
||||
inputs.agent_binding?.binding_type === 'roster_agent'
|
||||
? inputs.agent_binding.agent_id
|
||||
@@ -544,7 +548,10 @@ export function AgentV2Panel({ id, data }: NodePanelProps<AgentV2NodeType>) {
|
||||
setIsOutputVariablesCollapsed(false)
|
||||
const previousOutputs = getAgentV2DeclaredOutputs(inputsRef.current)
|
||||
let nextAgentTask = agentTask
|
||||
let nextOutputs = normalizeAgentV2DeclaredOutputs(outputs)
|
||||
let nextOutputs = normalizeAgentV2DeclaredOutputs(
|
||||
outputs,
|
||||
inputsRef.current.agent_output_routes,
|
||||
)
|
||||
if (agentTask !== undefined) {
|
||||
const nextPromptOutputNames = extractAgentOutputNames(agentTask)
|
||||
const removedPromptOutputNames = [...promptOutputNamesRef.current].filter(
|
||||
@@ -727,6 +734,7 @@ export function AgentV2Panel({ id, data }: NodePanelProps<AgentV2NodeType>) {
|
||||
onOutputsChange={handleDeclaredOutputsChange}
|
||||
/>
|
||||
</div>
|
||||
<AgentOutputRoutes id={id} data={inputs} />
|
||||
<div>
|
||||
<AgentOutputVariables
|
||||
collapsed={isOutputVariablesCollapsed}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { DeclaredOutputConfig } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import type {
|
||||
DeclaredOutputConfig,
|
||||
WorkflowOutputRoutes,
|
||||
} from '@dify/contracts/api/console/apps/types.gen'
|
||||
import type { AgentBinding } from '@/app/components/workflow/block-selector/types'
|
||||
import type { CommonNodeType } from '@/app/components/workflow/types'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
@@ -6,6 +9,7 @@ import { BlockEnum } from '@/app/components/workflow/types'
|
||||
export type AgentV2NodeType = CommonNodeType & {
|
||||
agent_binding?: AgentBinding
|
||||
agent_declared_outputs?: DeclaredOutputConfig[]
|
||||
agent_output_routes?: WorkflowOutputRoutes
|
||||
agent_node_kind: 'dify_agent'
|
||||
agent_task?: string
|
||||
version: '2'
|
||||
@@ -49,3 +53,7 @@ export function needsInlineAgentBindingCreation(data: AgentV2NodeType) {
|
||||
export function hasValidAgentBinding(data: AgentV2NodeType) {
|
||||
return hasValidInlineAgentBinding(data) || hasValidRosterAgentBinding(data)
|
||||
}
|
||||
|
||||
export function hasAgentV2OutputRoutes(data: CommonNodeType): data is AgentV2NodeType {
|
||||
return isAgentV2NodeData(data) && !!data.agent_output_routes?.enabled
|
||||
}
|
||||
@@ -65,7 +65,9 @@ const useSingleRunFormParams = ({
|
||||
}
|
||||
> = {}
|
||||
iterationChildrenNodes.forEach((node) => {
|
||||
const nodeVars = getNodeUsedVars(node).filter((item) => item && item.length > 0)
|
||||
const nodeVars = getNodeUsedVars(node, { forExecution: true }).filter(
|
||||
(item) => item && item.length > 0,
|
||||
)
|
||||
nodeVars.forEach((varSelector) => {
|
||||
if (varSelector[0] === id) {
|
||||
// skip iteration node itself variable: item, index
|
||||
|
||||
@@ -67,7 +67,9 @@ export const buildUsedOutVars = ({
|
||||
const allVarObject: Record<string, { inSingleRunPassedKey: string }> = {}
|
||||
|
||||
loopChildrenNodes.forEach((node) => {
|
||||
const nodeVars = getNodeUsedVars(node).filter((item) => item && item.length > 0)
|
||||
const nodeVars = getNodeUsedVars(node, { forExecution: true }).filter(
|
||||
(item) => item && item.length > 0,
|
||||
)
|
||||
nodeVars.forEach((varSelector) => {
|
||||
if (varSelector[0] === currentNodeId) return
|
||||
if (isNodeInLoop(varSelector[0]!)) return
|
||||
|
||||
+4
-12
@@ -1,4 +1,5 @@
|
||||
import type { QuestionClassifierNodeType, Topic } from '../types'
|
||||
import type { QuestionClassifierNodeType } from '../types'
|
||||
import type { Topic } from '@/app/components/workflow/nodes/_base/components/branch-list/types'
|
||||
import type { PanelProps } from '@/types/workflow'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
@@ -6,9 +7,9 @@ import { useTextGenerationCurrentProviderAndModelAndModelList } from '@/app/comp
|
||||
import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
|
||||
import { BlockEnum, VarType } from '@/app/components/workflow/types'
|
||||
import { useEdgesInteractions } from '../../../hooks/use-edges-interactions'
|
||||
import ClassItem from '../../_base/components/branch-list/class-item'
|
||||
import ClassList from '../../_base/components/branch-list/class-list'
|
||||
import AdvancedSetting from '../components/advanced-setting'
|
||||
import ClassItem from '../components/class-item'
|
||||
import ClassList from '../components/class-list'
|
||||
import Node from '../node'
|
||||
import Panel from '../panel'
|
||||
import useConfig from '../use-config'
|
||||
@@ -94,15 +95,6 @@ vi.mock(
|
||||
}),
|
||||
)
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/collapse', () => ({
|
||||
FieldCollapse: ({ title, children }: any) => (
|
||||
<div>
|
||||
<div>{title}</div>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/field', () => ({
|
||||
default: ({ title, operations, children }: any) => (
|
||||
<div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { QuestionClassifierNodeType, Topic } from '../types'
|
||||
import type { QuestionClassifierNodeType } from '../types'
|
||||
import type { Topic } from '@/app/components/workflow/nodes/_base/components/branch-list/types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useTextGenerationCurrentProviderAndModelAndModelList } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
|
||||
@@ -61,7 +61,7 @@ vi.mock('@/app/components/workflow/nodes/_base/components/config-vision', () =>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../components/class-list', () => ({
|
||||
vi.mock('../../_base/components/branch-list/class-list', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div>class-list</div>,
|
||||
}))
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
import type { Topic } from '../../types'
|
||||
import type { Topic } from '@/app/components/workflow/nodes/_base/components/branch-list/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import ClassItem from '../class-item'
|
||||
import ClassItem from '../../../_base/components/branch-list/class-item'
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-available-var-list', () => ({
|
||||
__esModule: true,
|
||||
|
||||
+3
-3
@@ -1,9 +1,9 @@
|
||||
import type { Topic } from '../../types'
|
||||
import type { Topic } from '@/app/components/workflow/nodes/_base/components/branch-list/types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useState } from 'react'
|
||||
import { useEdgesInteractions } from '../../../../hooks/use-edges-interactions'
|
||||
import ClassList from '../class-list'
|
||||
import ClassList from '../../../_base/components/branch-list/class-list'
|
||||
|
||||
vi.mock('react-sortablejs', () => ({
|
||||
__esModule: true,
|
||||
@@ -18,7 +18,7 @@ vi.mock('../../../../hooks/use-edges-interactions', async (importOriginal) => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../class-item', () => ({
|
||||
vi.mock('../../../_base/components/branch-list/class-item', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
payload,
|
||||
|
||||
@@ -1,223 +0,0 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { Topic } from '@/app/components/workflow/nodes/question-classifier/types'
|
||||
import type { ValueSelector, Var } from '@/app/components/workflow/types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { IconButton } from '@langgenius/dify-ui/icon-button'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { produce } from 'immer'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ReactSortable } from 'react-sortablejs'
|
||||
import { useKeyboardSortable } from '@/app/components/base/keyboard-sortable/use-keyboard-sortable'
|
||||
import { useEdgesInteractions } from '../../../hooks/use-edges-interactions'
|
||||
import AddButton from '../../_base/components/add-button'
|
||||
import { useInlineLabelHintDismissed } from '../storage'
|
||||
import Item from './class-item'
|
||||
import { getDefaultClassLabel, isDefaultClassLabel } from './class-label-utils'
|
||||
|
||||
const i18nPrefix = 'nodes.questionClassifiers'
|
||||
type Props = Readonly<{
|
||||
nodeId: string
|
||||
list: Topic[]
|
||||
onChange: (list: Topic[]) => void
|
||||
readonly?: boolean
|
||||
filterVar: (payload: Var, valueSelector: ValueSelector) => boolean
|
||||
handleSortTopic?: (newTopics: (Topic & { id: string })[]) => void
|
||||
}>
|
||||
|
||||
const ClassList: FC<Props> = ({
|
||||
nodeId,
|
||||
list,
|
||||
onChange,
|
||||
readonly,
|
||||
filterVar,
|
||||
handleSortTopic = noop,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { handleEdgeDeleteByDeleteBranch } = useEdgesInteractions()
|
||||
const listContainerRef = useRef<HTMLDivElement>(null)
|
||||
const [shouldScrollToEnd, setShouldScrollToEnd] = useState(false)
|
||||
const prevListLength = useRef(list.length)
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const [storedRenameHintDismissed, setIsRenameHintDismissed] = useInlineLabelHintDismissed()
|
||||
const isRenameHintDismissed = storedRenameHintDismissed ?? false
|
||||
|
||||
const handleClassChange = useCallback(
|
||||
(index: number) => {
|
||||
return (value: Topic) => {
|
||||
const newList = produce(list, (draft) => {
|
||||
draft[index] = value
|
||||
})
|
||||
onChange(newList)
|
||||
}
|
||||
},
|
||||
[list, onChange],
|
||||
)
|
||||
|
||||
const handleAddClass = useCallback(() => {
|
||||
const newList = produce(list, (draft) => {
|
||||
draft.push({
|
||||
id: `${Date.now()}`,
|
||||
name: '',
|
||||
label: getDefaultClassLabel(t, draft.length + 1),
|
||||
})
|
||||
})
|
||||
onChange(newList)
|
||||
setShouldScrollToEnd(true)
|
||||
if (collapsed) setCollapsed(false)
|
||||
}, [collapsed, list, onChange, t])
|
||||
|
||||
const handleRemoveClass = useCallback(
|
||||
(index: number) => {
|
||||
return () => {
|
||||
handleEdgeDeleteByDeleteBranch(nodeId, list[index]!.id)
|
||||
const newList = produce(list, (draft) => {
|
||||
draft.splice(index, 1)
|
||||
})
|
||||
onChange(newList)
|
||||
}
|
||||
},
|
||||
[list, onChange, handleEdgeDeleteByDeleteBranch, nodeId],
|
||||
)
|
||||
|
||||
const keyboardSort = useKeyboardSortable({
|
||||
items: list,
|
||||
onChange: handleSortTopic,
|
||||
disabled: readonly,
|
||||
getItemLabel: (item) => item.label || item.name,
|
||||
})
|
||||
const sortableTopics = useMemo(
|
||||
() => keyboardSort.items.map((item) => ({ ...item })),
|
||||
[keyboardSort.items],
|
||||
)
|
||||
|
||||
const topicCount = list.length
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldScrollToEnd && list.length > prevListLength.current) setShouldScrollToEnd(false)
|
||||
prevListLength.current = list.length
|
||||
}, [list.length, shouldScrollToEnd])
|
||||
|
||||
const handleCollapse = useCallback(() => {
|
||||
setCollapsed(!collapsed)
|
||||
}, [collapsed])
|
||||
|
||||
const dismissRenameHint = useCallback(() => {
|
||||
if (isRenameHintDismissed) return
|
||||
|
||||
setIsRenameHintDismissed(true)
|
||||
}, [isRenameHintDismissed, setIsRenameHintDismissed])
|
||||
|
||||
const shouldShowRenameHint =
|
||||
!readonly &&
|
||||
!isRenameHintDismissed &&
|
||||
list.some((item, index) => {
|
||||
return isDefaultClassLabel(item.label, index + 1, t)
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
{keyboardSort.announcement}
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
className="flex cursor-pointer items-center border-none bg-transparent p-0 text-left text-xs font-semibold text-text-secondary uppercase focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
|
||||
onClick={handleCollapse}
|
||||
>
|
||||
{t(($) => $[`${i18nPrefix}.class`], { ns: 'workflow' })}{' '}
|
||||
<span className="text-text-destructive">*</span>
|
||||
{list.length > 0 && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'i-custom-vender-solid-general-arrow-down-round-fill h-4 w-4',
|
||||
cn(
|
||||
'size-4 text-text-quaternary transition-transform duration-200',
|
||||
collapsed && '-rotate-90',
|
||||
),
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{shouldShowRenameHint && (
|
||||
<div className="mb-2 rounded-lg border border-divider-subtle bg-components-panel-bg px-3 py-2 text-xs text-text-tertiary">
|
||||
{t(($) => $[`${i18nPrefix}.renameHint`], { ns: 'workflow' })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!collapsed && (
|
||||
<div ref={listContainerRef} className="overflow-y-visible pl-3">
|
||||
<ReactSortable
|
||||
list={sortableTopics}
|
||||
setList={(items) => {
|
||||
if (
|
||||
!keyboardSort.isSorting &&
|
||||
items.some((item, index) => item.id !== sortableTopics[index]?.id)
|
||||
)
|
||||
handleSortTopic(items)
|
||||
}}
|
||||
handle=".handle"
|
||||
ghostClass="bg-components-panel-bg"
|
||||
animation={150}
|
||||
disabled={readonly || keyboardSort.isSorting}
|
||||
className="space-y-2"
|
||||
>
|
||||
{keyboardSort.items.map((item, index) => {
|
||||
const canDrag = !readonly && topicCount >= 2
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className={cn(
|
||||
'group relative -ml-3 min-h-10 rounded-[10px] bg-components-panel-bg px-0 py-0',
|
||||
)}
|
||||
style={{
|
||||
// Performance hint for browser
|
||||
contain: 'layout style paint',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
{canDrag && (
|
||||
<IconButton
|
||||
{...keyboardSort.getHandleProps(index)}
|
||||
className="handle pointer-events-none absolute top-1.5 left-0.5 z-10 opacity-0 group-hover:pointer-events-auto group-hover:opacity-100 focus:pointer-events-auto focus:opacity-100 aria-pressed:pointer-events-auto aria-pressed:opacity-100"
|
||||
>
|
||||
<span aria-hidden="true" className="i-ri-draggable size-3" />
|
||||
</IconButton>
|
||||
)}
|
||||
<Item
|
||||
className={cn(canDrag && 'handle')}
|
||||
headerClassName={cn(
|
||||
canDrag && 'cursor-grab group-focus-within:pl-5 group-hover:pl-5',
|
||||
)}
|
||||
nodeId={nodeId}
|
||||
key={item.id}
|
||||
payload={item}
|
||||
onChange={handleClassChange(keyboardSort.getItemKey(index))}
|
||||
onRemove={handleRemoveClass(keyboardSort.getItemKey(index))}
|
||||
index={index + 1}
|
||||
readonly={readonly}
|
||||
filterVar={filterVar}
|
||||
onLabelEditStart={dismissRenameHint}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</ReactSortable>
|
||||
</div>
|
||||
)}
|
||||
{!readonly && !collapsed && (
|
||||
<div className="mt-2">
|
||||
<AddButton
|
||||
onClick={handleAddClass}
|
||||
text={t(($) => $[`${i18nPrefix}.addClass`], { ns: 'workflow' })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
export default React.memo(ClassList)
|
||||
@@ -1,67 +1,13 @@
|
||||
import type { TFunction } from 'i18next'
|
||||
import type { FC } from 'react'
|
||||
import type { NodeProps } from 'reactflow'
|
||||
import type { QuestionClassifierNodeType } from './types'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useTextGenerationCurrentProviderAndModelAndModelList } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
import { ModelSelector } from '@/app/components/header/account-setting/model-provider-page/model-selector'
|
||||
import { NodeSourceHandle } from '../_base/components/node-handle'
|
||||
import ReadonlyInputWithSelectVar from '../_base/components/readonly-input-with-select-var'
|
||||
import { getDisplayClassLabel } from './components/class-label-utils'
|
||||
|
||||
const MAX_CLASS_TEXT_LENGTH = 50
|
||||
|
||||
type TruncatedClassItemProps = {
|
||||
topic: { id: string; name: string; label?: string }
|
||||
index: number
|
||||
nodeId: string
|
||||
t: TFunction
|
||||
}
|
||||
|
||||
const TruncatedClassItem: FC<TruncatedClassItemProps> = ({ topic, index, nodeId, t }) => {
|
||||
const displayLabel = getDisplayClassLabel(topic.label, index + 1, t)
|
||||
const truncatedText =
|
||||
topic.name.length > MAX_CLASS_TEXT_LENGTH
|
||||
? `${topic.name.slice(0, MAX_CLASS_TEXT_LENGTH)}...`
|
||||
: topic.name
|
||||
|
||||
const shouldShowTooltip = topic.name.length > MAX_CLASS_TEXT_LENGTH
|
||||
|
||||
const content = (
|
||||
<div className="truncate system-xs-regular text-text-tertiary">
|
||||
<ReadonlyInputWithSelectVar value={truncatedText} nodeId={nodeId} className="truncate" />
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-0.5 rounded-md bg-workflow-block-parma-bg px-1.25 py-0.75">
|
||||
<div className="text-xs/4 font-semibold text-text-secondary">{displayLabel}</div>
|
||||
{shouldShowTooltip ? (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
openOnHover
|
||||
aria-label={topic.name}
|
||||
className="w-full border-0 bg-transparent p-0 text-left"
|
||||
>
|
||||
{content}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="max-w-75 px-3 py-2 system-xs-regular wrap-break-word text-text-tertiary">
|
||||
<ReadonlyInputWithSelectVar value={topic.name} nodeId={nodeId} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : (
|
||||
content
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import { NodeBranches } from '../_base/components/branch-list/node-branches'
|
||||
|
||||
const Node: FC<NodeProps<QuestionClassifierNodeType>> = (props) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { data, id } = props
|
||||
const { data } = props
|
||||
const { provider, name: modelId } = data.model
|
||||
// const tempTopics = data.topics
|
||||
const topics = data.classes
|
||||
@@ -80,22 +26,7 @@ const Node: FC<NodeProps<QuestionClassifierNodeType>> = (props) => {
|
||||
disabled
|
||||
/>
|
||||
)}
|
||||
{!!topics.length && (
|
||||
<div className="mt-2 space-y-0.5">
|
||||
<div className="space-y-0.5">
|
||||
{topics.map((topic, index) => (
|
||||
<div key={topic.id} className="relative">
|
||||
<TruncatedClassItem topic={topic} index={index} nodeId={id} t={t} />
|
||||
<NodeSourceHandle
|
||||
{...props}
|
||||
handleId={topic.id}
|
||||
handleClassName="top-1/2! -translate-y-1/2! -right-[21px]!"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!!topics.length && <NodeBranches node={props} branches={topics} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,10 +8,10 @@ import { FieldCollapse } from '@/app/components/workflow/nodes/_base/components/
|
||||
import Field from '@/app/components/workflow/nodes/_base/components/field'
|
||||
import OutputVars, { VarItem } from '@/app/components/workflow/nodes/_base/components/output-vars'
|
||||
import Split from '@/app/components/workflow/nodes/_base/components/split'
|
||||
import ClassList from '../_base/components/branch-list/class-list'
|
||||
import ConfigVision from '../_base/components/config-vision'
|
||||
import VarReferencePicker from '../_base/components/variable/var-reference-picker'
|
||||
import AdvancedSetting from './components/advanced-setting'
|
||||
import ClassList from './components/class-list'
|
||||
import useConfig from './use-config'
|
||||
|
||||
const i18nPrefix = 'nodes.questionClassifiers'
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Topic } from '../_base/components/branch-list/types'
|
||||
import type {
|
||||
CommonNodeType,
|
||||
Memory,
|
||||
@@ -6,12 +7,6 @@ import type {
|
||||
VisionSetting,
|
||||
} from '@/app/components/workflow/types'
|
||||
|
||||
export type Topic = {
|
||||
id: string
|
||||
name: string
|
||||
label?: string
|
||||
}
|
||||
|
||||
export type QuestionClassifierNodeType = CommonNodeType & {
|
||||
query_variable_selector: ValueSelector
|
||||
model: ModelConfig
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Memory, ValueSelector, Var } from '../../types'
|
||||
import type { QuestionClassifierNodeType, Topic } from './types'
|
||||
import type { QuestionClassifierNodeType } from './types'
|
||||
import type { Topic } from '@/app/components/workflow/nodes/_base/components/branch-list/types'
|
||||
import { produce } from 'immer'
|
||||
import { startTransition, useCallback, useEffect, useRef } from 'react'
|
||||
import { useUpdateNodeInternals } from 'reactflow'
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import type { ElkNode, LayoutOptions } from 'elkjs/lib/elk-api'
|
||||
import type { HumanInputNodeType } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import type { CaseItem, IfElseNodeType } from '@/app/components/workflow/nodes/if-else/types'
|
||||
import type {
|
||||
QuestionClassifierNodeType,
|
||||
Topic,
|
||||
} from '@/app/components/workflow/nodes/question-classifier/types'
|
||||
import type { QuestionClassifierNodeType } from '@/app/components/workflow/nodes/question-classifier/types'
|
||||
import type { Edge, Node } from '@/app/components/workflow/types'
|
||||
import { cloneDeep } from 'es-toolkit/object'
|
||||
import {
|
||||
@@ -12,6 +9,7 @@ import {
|
||||
NODE_LAYOUT_HORIZONTAL_PADDING,
|
||||
NODE_LAYOUT_VERTICAL_PADDING,
|
||||
} from '@/app/components/workflow/constants'
|
||||
import { hasAgentV2OutputRoutes } from '@/app/components/workflow/nodes/agent-v2/types'
|
||||
import { CUSTOM_ITERATION_START_NODE } from '@/app/components/workflow/nodes/iteration-start/constants'
|
||||
import { CUSTOM_LOOP_START_NODE } from '@/app/components/workflow/nodes/loop-start/constants'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
@@ -303,9 +301,11 @@ const sortQuestionClassifierOutEdges = (classifierNode: Node, outEdges: Edge[]):
|
||||
const handleB = edgeB.sourceHandle
|
||||
|
||||
if (handleA && handleB) {
|
||||
const classes = (classifierNode.data as QuestionClassifierNodeType).classes || []
|
||||
const indexA = classes.findIndex((t: Topic) => t.id === handleA)
|
||||
const indexB = classes.findIndex((t: Topic) => t.id === handleB)
|
||||
const classes = hasAgentV2OutputRoutes(classifierNode.data)
|
||||
? (classifierNode.data.agent_output_routes?.routes ?? [])
|
||||
: (classifierNode.data as QuestionClassifierNodeType).classes || []
|
||||
const indexA = classes.findIndex((t) => t.id === handleA)
|
||||
const indexB = classes.findIndex((t) => t.id === handleB)
|
||||
|
||||
if (indexA !== -1 && indexB !== -1) return indexA - indexB
|
||||
}
|
||||
@@ -382,7 +382,7 @@ const buildPortAwareGraph = (nodes: Node[], edges: Edge[]) => {
|
||||
let outEdges = outEdgesByNode.get(node.id) || []
|
||||
|
||||
if (node.data.type === BlockEnum.IfElse) outEdges = sortIfElseOutEdges(node, outEdges)
|
||||
else if (node.data.type === BlockEnum.QuestionClassifier)
|
||||
else if (node.data.type === BlockEnum.QuestionClassifier || hasAgentV2OutputRoutes(node.data))
|
||||
outEdges = sortQuestionClassifierOutEdges(node, outEdges)
|
||||
else if (node.data.type === BlockEnum.HumanInput)
|
||||
outEdges = sortHumanInputOutEdges(node, outEdges)
|
||||
|
||||
@@ -6,6 +6,10 @@ import type { ToolNodeType } from '../nodes/tool/types'
|
||||
import type { Edge, Node } from '../types'
|
||||
import { cloneDeep } from 'es-toolkit/object'
|
||||
import { getConnectedEdges } from 'reactflow'
|
||||
import {
|
||||
hasAgentV2OutputRoutes,
|
||||
isAgentV2NodeData,
|
||||
} from '@/app/components/workflow/nodes/agent-v2/types'
|
||||
import { correctModelProvider } from '@/utils'
|
||||
import { getIterationStartNode, getLoopStartNode } from '.'
|
||||
import {
|
||||
@@ -259,6 +263,13 @@ export const initialNodes = (originNodes: Node[], originEdges: Edge[]) => {
|
||||
})
|
||||
}
|
||||
|
||||
if (hasAgentV2OutputRoutes(node.data))
|
||||
node.data._targetBranches = (node.data.agent_output_routes?.routes ?? []).map((route) => ({
|
||||
id: route.id,
|
||||
name: route.name ?? '',
|
||||
}))
|
||||
else if (isAgentV2NodeData(node.data)) node.data._targetBranches = []
|
||||
|
||||
if (node.data.type === BlockEnum.Iteration) {
|
||||
const iterationNodeData = node.data as IterationNodeType
|
||||
iterationNodeData._children = iterationOrLoopNodeMap[node.id] || []
|
||||
|
||||
+68
-16
@@ -1,22 +1,15 @@
|
||||
import type { AgentV2NodeType } from '@/app/components/workflow/nodes/agent-v2/types'
|
||||
import { render } from '@testing-library/react'
|
||||
import { ReactFlowProvider } from 'reactflow'
|
||||
import { ErrorHandleTypeEnum } from '@/app/components/workflow/nodes/_base/components/error-handle/types'
|
||||
import { UserActionButtonType } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import CustomNode from '../index'
|
||||
|
||||
vi.mock('reactflow', () => ({
|
||||
Handle: (props: { id: string; type: string; className?: string }) => (
|
||||
<div
|
||||
data-testid="handle"
|
||||
data-handleid={props.id}
|
||||
data-type={props.type}
|
||||
className={props.className}
|
||||
/>
|
||||
),
|
||||
Position: {
|
||||
Left: 'left',
|
||||
Right: 'right',
|
||||
},
|
||||
}))
|
||||
vi.mock('react-i18next', async () => {
|
||||
const { createReactI18nextMock } = await import('@/test/i18n-mock')
|
||||
return createReactI18nextMock({ 'workflow.nodes.agent.outputRoutes.route': 'Route {{index}}' })
|
||||
})
|
||||
|
||||
describe('workflow preview custom node', () => {
|
||||
it('renders the mapped node component inside the shared base card', () => {
|
||||
@@ -38,7 +31,9 @@ describe('workflow preview custom node', () => {
|
||||
} as never,
|
||||
}
|
||||
|
||||
const { container, getByText } = render(<CustomNode {...props} />)
|
||||
const { container, getByText } = render(<CustomNode {...props} />, {
|
||||
wrapper: ReactFlowProvider,
|
||||
})
|
||||
|
||||
expect(getByText('Classifier node')).toBeInTheDocument()
|
||||
expect(container.querySelector('[data-handleid="class-a"]')).toBeInTheDocument()
|
||||
@@ -70,10 +65,67 @@ describe('workflow preview custom node', () => {
|
||||
} as never,
|
||||
}
|
||||
|
||||
const { container, getByText } = render(<CustomNode {...props} />)
|
||||
const { container, getByText } = render(<CustomNode {...props} />, {
|
||||
wrapper: ReactFlowProvider,
|
||||
})
|
||||
|
||||
expect(getByText('Human Input')).toBeInTheDocument()
|
||||
expect(container.querySelector('[data-handleid="approve"]')).toBeInTheDocument()
|
||||
expect(container.querySelector('[data-handleid="__timeout"]')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([BlockEnum.Agent, BlockEnum.AgentV2])(
|
||||
'renders route labels and connections for %s while preserving the failure exit',
|
||||
(type) => {
|
||||
const data: AgentV2NodeType = {
|
||||
type,
|
||||
title: 'Agent',
|
||||
desc: '',
|
||||
agent_node_kind: 'dify_agent',
|
||||
version: '2',
|
||||
error_strategy: ErrorHandleTypeEnum.failBranch,
|
||||
agent_output_routes: {
|
||||
enabled: true,
|
||||
routes: [
|
||||
{ id: 'accepted', name: 'The request is accepted', label: 'Accepted' },
|
||||
{ id: 'rejected', name: 'The request is rejected' },
|
||||
],
|
||||
},
|
||||
}
|
||||
const props: React.ComponentProps<typeof CustomNode> = {
|
||||
id: 'agent-1',
|
||||
type: 'custom-node',
|
||||
selected: false,
|
||||
zIndex: 1,
|
||||
isConnectable: false,
|
||||
dragging: false,
|
||||
xPos: 0,
|
||||
yPos: 0,
|
||||
dragHandle: undefined,
|
||||
data,
|
||||
}
|
||||
const { container, getByText, queryByText, rerender } = render(<CustomNode {...props} />, {
|
||||
wrapper: ReactFlowProvider,
|
||||
})
|
||||
// ReactFlow resolves the saved edges through these source handle IDs.
|
||||
const sourceHandles = () =>
|
||||
Array.from(container.querySelectorAll('.react-flow__handle.source'), (handle) =>
|
||||
handle.getAttribute('data-handleid'),
|
||||
).sort()
|
||||
|
||||
expect(getByText('Accepted')).toBeInTheDocument()
|
||||
expect(getByText('Route 2')).toBeInTheDocument()
|
||||
expect(sourceHandles()).toEqual(['accepted', 'fail-branch', 'rejected'])
|
||||
|
||||
rerender(
|
||||
<CustomNode
|
||||
{...props}
|
||||
data={{ ...data, agent_output_routes: { ...data.agent_output_routes, enabled: false } }}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(queryByText('Accepted')).not.toBeInTheDocument()
|
||||
expect(sourceHandles()).toEqual(['fail-branch', 'source'])
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { NodeProps } from '@/app/components/workflow/types'
|
||||
import { memo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import InfoPanel from '@/app/components/workflow/nodes/_base/components/info-panel'
|
||||
import { hasAgentV2OutputRoutes } from '@/app/components/workflow/nodes/agent-v2/types'
|
||||
import { NodeSourceHandle } from '../../node-handle'
|
||||
|
||||
const Node = (props: NodeProps) => {
|
||||
const { data } = props
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (!hasAgentV2OutputRoutes(data)) return null
|
||||
|
||||
return (
|
||||
<div className="mb-1 space-y-0.5 px-3 py-1">
|
||||
{data.agent_output_routes?.routes?.map((route, index) => (
|
||||
<div key={route.id} className="relative">
|
||||
<InfoPanel
|
||||
title={
|
||||
route.label?.trim() ||
|
||||
t(($) => $['nodes.agent.outputRoutes.route'], { ns: 'workflow', index: index + 1 })
|
||||
}
|
||||
content=""
|
||||
/>
|
||||
<NodeSourceHandle
|
||||
{...props}
|
||||
handleId={route.id}
|
||||
handleClassName="top-1/2! -translate-y-1/2! -right-[21px]!"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(Node)
|
||||
@@ -6,6 +6,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/pop
|
||||
import { cloneElement, memo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import BlockIcon from '@/app/components/workflow/block-icon'
|
||||
import { hasAgentV2OutputRoutes } from '@/app/components/workflow/nodes/agent-v2/types'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { hasErrorHandleNode } from '@/app/components/workflow/utils'
|
||||
import ErrorHandleOnNode from '../error-handle-on-node'
|
||||
@@ -56,7 +57,8 @@ const BaseCard = ({ id, data, children }: NodeCardProps) => {
|
||||
/>
|
||||
{data.type !== BlockEnum.IfElse &&
|
||||
data.type !== BlockEnum.QuestionClassifier &&
|
||||
data.type !== BlockEnum.HumanInput && (
|
||||
data.type !== BlockEnum.HumanInput &&
|
||||
!hasAgentV2OutputRoutes(data) && (
|
||||
<NodeSourceHandle
|
||||
id={id}
|
||||
data={data}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import AgentNode from './agent-v2/node'
|
||||
import HumanInputNode from './human-input/node'
|
||||
import IfElseNode from './if-else/node'
|
||||
import IterationNode from './iteration/node'
|
||||
@@ -6,6 +7,8 @@ import LoopNode from './loop/node'
|
||||
import QuestionClassifierNode from './question-classifier/node'
|
||||
|
||||
export const NodeComponentMap: Record<string, any> = {
|
||||
[BlockEnum.Agent]: AgentNode,
|
||||
[BlockEnum.AgentV2]: AgentNode,
|
||||
[BlockEnum.QuestionClassifier]: QuestionClassifierNode,
|
||||
[BlockEnum.IfElse]: IfElseNode,
|
||||
[BlockEnum.HumanInput]: HumanInputNode,
|
||||
|
||||
+1
-1
@@ -3,8 +3,8 @@ import type { NodeProps } from 'reactflow'
|
||||
import type { QuestionClassifierNodeType } from '@/app/components/workflow/nodes/question-classifier/types'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getDisplayClassLabel } from '@/app/components/workflow/nodes/_base/components/branch-list/class-label-utils'
|
||||
import InfoPanel from '@/app/components/workflow/nodes/_base/components/info-panel'
|
||||
import { getDisplayClassLabel } from '@/app/components/workflow/nodes/question-classifier/components/class-label-utils'
|
||||
import { NodeSourceHandle } from '../../node-handle'
|
||||
|
||||
const Node: FC<NodeProps<QuestionClassifierNodeType>> = (props) => {
|
||||
|
||||
@@ -372,6 +372,11 @@
|
||||
"nodes.agent.model": "النموذج",
|
||||
"nodes.agent.modelNotInstallTooltip": "هذا النموذج غير مثبت",
|
||||
"nodes.agent.modelNotSelected": "النموذج غير محدد",
|
||||
"nodes.agent.outputRoutes.add": "إضافة مسار",
|
||||
"nodes.agent.outputRoutes.placeholder": "صف متى يجب على الوكيل اختيار هذا المسار",
|
||||
"nodes.agent.outputRoutes.renameHint": "انقر مرتين على عنوان المسار لإعادة تسميته",
|
||||
"nodes.agent.outputRoutes.route": "المسار {{index}}",
|
||||
"nodes.agent.outputRoutes.title": "مسارات الإخراج",
|
||||
"nodes.agent.outputVars.confirm": "تأكيد",
|
||||
"nodes.agent.outputVars.defaultValueArrayInvalid": "أدخل مصفوفة JSON.",
|
||||
"nodes.agent.outputVars.defaultValueBooleanInvalid": "أدخل true أو false.",
|
||||
@@ -394,6 +399,7 @@
|
||||
"nodes.agent.outputVars.newOutput": "إخراج جديد",
|
||||
"nodes.agent.outputVars.requiredLabel": "مطلوب",
|
||||
"nodes.agent.outputVars.showAdvancedOptions": "إظهار الخيارات المتقدمة",
|
||||
"nodes.agent.outputVars.switch": "معرّف مسار الإخراج المحدد.",
|
||||
"nodes.agent.outputVars.text": "محتوى تم إنشاؤه بواسطة الوكيل",
|
||||
"nodes.agent.outputVars.typeLabel": "نوع الإخراج",
|
||||
"nodes.agent.outputVars.usage": "معلومات استخدام النموذج",
|
||||
|
||||
@@ -372,6 +372,11 @@
|
||||
"nodes.agent.model": "Model",
|
||||
"nodes.agent.modelNotInstallTooltip": "Bu model quraşdırılmayıb",
|
||||
"nodes.agent.modelNotSelected": "Model seçilməyib",
|
||||
"nodes.agent.outputRoutes.add": "Marşrut əlavə et",
|
||||
"nodes.agent.outputRoutes.placeholder": "Agentin bu marşrutu nə vaxt seçməli olduğunu təsvir edin",
|
||||
"nodes.agent.outputRoutes.renameHint": "Adını dəyişmək üçün marşrut başlığına iki dəfə klikləyin",
|
||||
"nodes.agent.outputRoutes.route": "Marşrut {{index}}",
|
||||
"nodes.agent.outputRoutes.title": "Çıxış marşrutları",
|
||||
"nodes.agent.outputVars.confirm": "Təsdiqlə",
|
||||
"nodes.agent.outputVars.defaultValueArrayInvalid": "JSON massivi daxil edin.",
|
||||
"nodes.agent.outputVars.defaultValueBooleanInvalid": "true və ya false daxil edin.",
|
||||
@@ -394,6 +399,7 @@
|
||||
"nodes.agent.outputVars.newOutput": "Yeni çıxış",
|
||||
"nodes.agent.outputVars.requiredLabel": "Məcburi",
|
||||
"nodes.agent.outputVars.showAdvancedOptions": "Əlavə seçimləri göstər",
|
||||
"nodes.agent.outputVars.switch": "Seçilmiş çıxış marşrutunun identifikatoru.",
|
||||
"nodes.agent.outputVars.text": "Agentin yaratdığı məzmun",
|
||||
"nodes.agent.outputVars.typeLabel": "Çıxış növü",
|
||||
"nodes.agent.outputVars.usage": "Model istifadəsi haqqında məlumat",
|
||||
|
||||
@@ -372,6 +372,11 @@
|
||||
"nodes.agent.model": "Modell",
|
||||
"nodes.agent.modelNotInstallTooltip": "Dieses Modell ist nicht installiert",
|
||||
"nodes.agent.modelNotSelected": "Modell nicht ausgewählt",
|
||||
"nodes.agent.outputRoutes.add": "Route hinzufügen",
|
||||
"nodes.agent.outputRoutes.placeholder": "Beschreiben Sie, wann der Agent diese Route wählen soll",
|
||||
"nodes.agent.outputRoutes.renameHint": "Zum Umbenennen auf den Routentitel doppelklicken",
|
||||
"nodes.agent.outputRoutes.route": "Route {{index}}",
|
||||
"nodes.agent.outputRoutes.title": "Ausgaberouten",
|
||||
"nodes.agent.outputVars.confirm": "Bestätigen",
|
||||
"nodes.agent.outputVars.defaultValueArrayInvalid": "Geben Sie ein JSON-Array ein.",
|
||||
"nodes.agent.outputVars.defaultValueBooleanInvalid": "Geben Sie true oder false ein.",
|
||||
@@ -394,6 +399,7 @@
|
||||
"nodes.agent.outputVars.newOutput": "Neue Ausgabe",
|
||||
"nodes.agent.outputVars.requiredLabel": "Erforderlich",
|
||||
"nodes.agent.outputVars.showAdvancedOptions": "Erweiterte Optionen anzeigen",
|
||||
"nodes.agent.outputVars.switch": "ID der ausgewählten Ausgaberoute.",
|
||||
"nodes.agent.outputVars.text": "Von Agenten generierte Inhalte",
|
||||
"nodes.agent.outputVars.typeLabel": "Ausgabetyp",
|
||||
"nodes.agent.outputVars.usage": "Nutzungsinformationen des Modells",
|
||||
|
||||
@@ -372,6 +372,11 @@
|
||||
"nodes.agent.model": "model",
|
||||
"nodes.agent.modelNotInstallTooltip": "This model is not installed",
|
||||
"nodes.agent.modelNotSelected": "Model not selected",
|
||||
"nodes.agent.outputRoutes.add": "Add Route",
|
||||
"nodes.agent.outputRoutes.placeholder": "Describe when the Agent should choose this route",
|
||||
"nodes.agent.outputRoutes.renameHint": "Double-click the route title to rename",
|
||||
"nodes.agent.outputRoutes.route": "Route {{index}}",
|
||||
"nodes.agent.outputRoutes.title": "Output Routes",
|
||||
"nodes.agent.outputVars.confirm": "Confirm",
|
||||
"nodes.agent.outputVars.defaultValueArrayInvalid": "Enter a JSON array.",
|
||||
"nodes.agent.outputVars.defaultValueBooleanInvalid": "Enter true or false.",
|
||||
@@ -394,6 +399,7 @@
|
||||
"nodes.agent.outputVars.newOutput": "New output",
|
||||
"nodes.agent.outputVars.requiredLabel": "Required",
|
||||
"nodes.agent.outputVars.showAdvancedOptions": "Show advanced options",
|
||||
"nodes.agent.outputVars.switch": "Selected output route ID.",
|
||||
"nodes.agent.outputVars.text": "agent generated content",
|
||||
"nodes.agent.outputVars.typeLabel": "Output type",
|
||||
"nodes.agent.outputVars.usage": "Model Usage Information",
|
||||
|
||||
@@ -372,6 +372,11 @@
|
||||
"nodes.agent.model": "modelo",
|
||||
"nodes.agent.modelNotInstallTooltip": "Este modelo no está instalado",
|
||||
"nodes.agent.modelNotSelected": "Modelo no seleccionado",
|
||||
"nodes.agent.outputRoutes.add": "Añadir ruta",
|
||||
"nodes.agent.outputRoutes.placeholder": "Describe cuándo el agente debe elegir esta ruta",
|
||||
"nodes.agent.outputRoutes.renameHint": "Haz doble clic en el título de la ruta para cambiar el nombre",
|
||||
"nodes.agent.outputRoutes.route": "Ruta {{index}}",
|
||||
"nodes.agent.outputRoutes.title": "Rutas de salida",
|
||||
"nodes.agent.outputVars.confirm": "Confirmar",
|
||||
"nodes.agent.outputVars.defaultValueArrayInvalid": "Introduce un arreglo JSON.",
|
||||
"nodes.agent.outputVars.defaultValueBooleanInvalid": "Introduce true o false.",
|
||||
@@ -394,6 +399,7 @@
|
||||
"nodes.agent.outputVars.newOutput": "Nueva salida",
|
||||
"nodes.agent.outputVars.requiredLabel": "Obligatorio",
|
||||
"nodes.agent.outputVars.showAdvancedOptions": "Mostrar opciones avanzadas",
|
||||
"nodes.agent.outputVars.switch": "ID de la ruta de salida seleccionada.",
|
||||
"nodes.agent.outputVars.text": "Contenido generado por el agente",
|
||||
"nodes.agent.outputVars.typeLabel": "Tipo de salida",
|
||||
"nodes.agent.outputVars.usage": "Información de uso del modelo",
|
||||
|
||||
@@ -372,6 +372,11 @@
|
||||
"nodes.agent.model": "مدل",
|
||||
"nodes.agent.modelNotInstallTooltip": "این مدل نصب نشده است",
|
||||
"nodes.agent.modelNotSelected": "مدل انتخاب نشده است",
|
||||
"nodes.agent.outputRoutes.add": "افزودن مسیر",
|
||||
"nodes.agent.outputRoutes.placeholder": "توضیح دهید عامل چه زمانی باید این مسیر را انتخاب کند",
|
||||
"nodes.agent.outputRoutes.renameHint": "برای تغییر نام، روی عنوان مسیر دوبار کلیک کنید",
|
||||
"nodes.agent.outputRoutes.route": "مسیر {{index}}",
|
||||
"nodes.agent.outputRoutes.title": "مسیرهای خروجی",
|
||||
"nodes.agent.outputVars.confirm": "تأیید",
|
||||
"nodes.agent.outputVars.defaultValueArrayInvalid": "یک آرایه JSON وارد کنید.",
|
||||
"nodes.agent.outputVars.defaultValueBooleanInvalid": "true یا false وارد کنید.",
|
||||
@@ -394,6 +399,7 @@
|
||||
"nodes.agent.outputVars.newOutput": "خروجی جدید",
|
||||
"nodes.agent.outputVars.requiredLabel": "الزامی",
|
||||
"nodes.agent.outputVars.showAdvancedOptions": "نمایش گزینههای پیشرفته",
|
||||
"nodes.agent.outputVars.switch": "شناسهٔ مسیر خروجی انتخابشده.",
|
||||
"nodes.agent.outputVars.text": "محتوای متنی تولیدشده توسط عامل",
|
||||
"nodes.agent.outputVars.typeLabel": "نوع خروجی",
|
||||
"nodes.agent.outputVars.usage": "اطلاعات مصرف مدل",
|
||||
|
||||
@@ -372,6 +372,11 @@
|
||||
"nodes.agent.model": "modèle",
|
||||
"nodes.agent.modelNotInstallTooltip": "Ce modèle n’est pas installé",
|
||||
"nodes.agent.modelNotSelected": "Modèle non sélectionné",
|
||||
"nodes.agent.outputRoutes.add": "Ajouter une route",
|
||||
"nodes.agent.outputRoutes.placeholder": "Décrivez quand l’agent doit choisir cette route",
|
||||
"nodes.agent.outputRoutes.renameHint": "Double-cliquez sur le titre de la route pour la renommer",
|
||||
"nodes.agent.outputRoutes.route": "Route {{index}}",
|
||||
"nodes.agent.outputRoutes.title": "Routes de sortie",
|
||||
"nodes.agent.outputVars.confirm": "Confirmer",
|
||||
"nodes.agent.outputVars.defaultValueArrayInvalid": "Saisissez un tableau JSON.",
|
||||
"nodes.agent.outputVars.defaultValueBooleanInvalid": "Saisissez true ou false.",
|
||||
@@ -394,6 +399,7 @@
|
||||
"nodes.agent.outputVars.newOutput": "Nouvelle sortie",
|
||||
"nodes.agent.outputVars.requiredLabel": "Requis",
|
||||
"nodes.agent.outputVars.showAdvancedOptions": "Afficher les options avancées",
|
||||
"nodes.agent.outputVars.switch": "Identifiant de la route de sortie sélectionnée.",
|
||||
"nodes.agent.outputVars.text": "Contenu généré par l’agent",
|
||||
"nodes.agent.outputVars.typeLabel": "Type de sortie",
|
||||
"nodes.agent.outputVars.usage": "Informations sur l’utilisation du modèle",
|
||||
|
||||
@@ -372,6 +372,11 @@
|
||||
"nodes.agent.model": "मॉडल",
|
||||
"nodes.agent.modelNotInstallTooltip": "यह मॉडल स्थापित नहीं है",
|
||||
"nodes.agent.modelNotSelected": "मॉडल का चयन नहीं किया गया",
|
||||
"nodes.agent.outputRoutes.add": "मार्ग जोड़ें",
|
||||
"nodes.agent.outputRoutes.placeholder": "बताएँ कि एजेंट को यह मार्ग कब चुनना चाहिए",
|
||||
"nodes.agent.outputRoutes.renameHint": "नाम बदलने के लिए मार्ग शीर्षक पर डबल क्लिक करें",
|
||||
"nodes.agent.outputRoutes.route": "मार्ग {{index}}",
|
||||
"nodes.agent.outputRoutes.title": "आउटपुट मार्ग",
|
||||
"nodes.agent.outputVars.confirm": "पुष्टि करें",
|
||||
"nodes.agent.outputVars.defaultValueArrayInvalid": "एक JSON सरणी दर्ज करें।",
|
||||
"nodes.agent.outputVars.defaultValueBooleanInvalid": "true या false दर्ज करें।",
|
||||
@@ -394,6 +399,7 @@
|
||||
"nodes.agent.outputVars.newOutput": "नया आउटपुट",
|
||||
"nodes.agent.outputVars.requiredLabel": "आवश्यक",
|
||||
"nodes.agent.outputVars.showAdvancedOptions": "उन्नत विकल्प दिखाएँ",
|
||||
"nodes.agent.outputVars.switch": "चुने गए आउटपुट रूट की आईडी।",
|
||||
"nodes.agent.outputVars.text": "एजेंट द्वारा उत्पन्न सामग्री",
|
||||
"nodes.agent.outputVars.typeLabel": "आउटपुट प्रकार",
|
||||
"nodes.agent.outputVars.usage": "मॉडल उपयोग जानकारी",
|
||||
|
||||
@@ -372,6 +372,11 @@
|
||||
"nodes.agent.model": "pola",
|
||||
"nodes.agent.modelNotInstallTooltip": "Model ini tidak dipasang",
|
||||
"nodes.agent.modelNotSelected": "Model tidak dipilih",
|
||||
"nodes.agent.outputRoutes.add": "Tambah rute",
|
||||
"nodes.agent.outputRoutes.placeholder": "Jelaskan kapan agen harus memilih rute ini",
|
||||
"nodes.agent.outputRoutes.renameHint": "Klik dua kali judul rute untuk mengganti nama",
|
||||
"nodes.agent.outputRoutes.route": "Rute {{index}}",
|
||||
"nodes.agent.outputRoutes.title": "Rute keluaran",
|
||||
"nodes.agent.outputVars.confirm": "Konfirmasi",
|
||||
"nodes.agent.outputVars.defaultValueArrayInvalid": "Masukkan array JSON.",
|
||||
"nodes.agent.outputVars.defaultValueBooleanInvalid": "Masukkan true atau false.",
|
||||
@@ -394,6 +399,7 @@
|
||||
"nodes.agent.outputVars.newOutput": "Output baru",
|
||||
"nodes.agent.outputVars.requiredLabel": "Wajib",
|
||||
"nodes.agent.outputVars.showAdvancedOptions": "Tampilkan opsi lanjutan",
|
||||
"nodes.agent.outputVars.switch": "ID rute keluaran yang dipilih.",
|
||||
"nodes.agent.outputVars.text": "Konten yang dihasilkan agen",
|
||||
"nodes.agent.outputVars.typeLabel": "Tipe output",
|
||||
"nodes.agent.outputVars.usage": "Informasi Penggunaan Model",
|
||||
|
||||
@@ -372,6 +372,11 @@
|
||||
"nodes.agent.model": "modello",
|
||||
"nodes.agent.modelNotInstallTooltip": "Questo modello non è installato",
|
||||
"nodes.agent.modelNotSelected": "Modello non selezionato",
|
||||
"nodes.agent.outputRoutes.add": "Aggiungi percorso",
|
||||
"nodes.agent.outputRoutes.placeholder": "Descrivi quando l’agente deve scegliere questo percorso",
|
||||
"nodes.agent.outputRoutes.renameHint": "Fai doppio clic sul titolo del percorso per rinominarlo",
|
||||
"nodes.agent.outputRoutes.route": "Percorso {{index}}",
|
||||
"nodes.agent.outputRoutes.title": "Percorsi di uscita",
|
||||
"nodes.agent.outputVars.confirm": "Conferma",
|
||||
"nodes.agent.outputVars.defaultValueArrayInvalid": "Inserisci un array JSON.",
|
||||
"nodes.agent.outputVars.defaultValueBooleanInvalid": "Inserisci true o false.",
|
||||
@@ -394,6 +399,7 @@
|
||||
"nodes.agent.outputVars.newOutput": "Nuovo output",
|
||||
"nodes.agent.outputVars.requiredLabel": "Obbligatorio",
|
||||
"nodes.agent.outputVars.showAdvancedOptions": "Mostra opzioni avanzate",
|
||||
"nodes.agent.outputVars.switch": "ID del percorso di output selezionato.",
|
||||
"nodes.agent.outputVars.text": "Contenuto generato dall'agente",
|
||||
"nodes.agent.outputVars.typeLabel": "Tipo di output",
|
||||
"nodes.agent.outputVars.usage": "Informazioni sull'utilizzo del modello",
|
||||
|
||||
@@ -372,6 +372,11 @@
|
||||
"nodes.agent.model": "モデル",
|
||||
"nodes.agent.modelNotInstallTooltip": "このモデルはインストールされていません",
|
||||
"nodes.agent.modelNotSelected": "モデルが選択されていません",
|
||||
"nodes.agent.outputRoutes.add": "ルートを追加",
|
||||
"nodes.agent.outputRoutes.placeholder": "エージェントがこのルートを選択する条件を記述",
|
||||
"nodes.agent.outputRoutes.renameHint": "ルート名をダブルクリックして変更",
|
||||
"nodes.agent.outputRoutes.route": "ルート {{index}}",
|
||||
"nodes.agent.outputRoutes.title": "出力ルート",
|
||||
"nodes.agent.outputVars.confirm": "確認",
|
||||
"nodes.agent.outputVars.defaultValueArrayInvalid": "JSON 配列を入力してください。",
|
||||
"nodes.agent.outputVars.defaultValueBooleanInvalid": "true または false を入力してください。",
|
||||
@@ -394,6 +399,7 @@
|
||||
"nodes.agent.outputVars.newOutput": "新しい出力",
|
||||
"nodes.agent.outputVars.requiredLabel": "必須",
|
||||
"nodes.agent.outputVars.showAdvancedOptions": "詳細オプションを表示",
|
||||
"nodes.agent.outputVars.switch": "選択された出力ルートの ID。",
|
||||
"nodes.agent.outputVars.text": "エージェント生成コンテンツ",
|
||||
"nodes.agent.outputVars.typeLabel": "出力タイプ",
|
||||
"nodes.agent.outputVars.usage": "モデル使用量",
|
||||
|
||||
Loaded 100 of 114 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user