From e135ffc2c1c613db374ee9749b34ee2902ee1e1b Mon Sep 17 00:00:00 2001 From: Yi Xiao <54782454+YIXIAO0@users.noreply.github.com> Date: Tue, 3 Dec 2024 13:56:40 +0800 Subject: [PATCH] Feat: upgrade variable assigner (#11285) Signed-off-by: -LAN- Co-authored-by: -LAN- --- api/controllers/console/app/workflow.py | 6 +- api/core/app/apps/workflow_app_runner.py | 5 +- api/core/variables/types.py | 9 +- .../workflow/graph_engine/graph_engine.py | 5 +- .../answer/answer_stream_generate_router.py | 2 +- api/core/workflow/nodes/base/entities.py | 1 + api/core/workflow/nodes/base/node.py | 4 +- api/core/workflow/nodes/enums.py | 4 +- .../nodes/iteration/iteration_node.py | 7 +- api/core/workflow/nodes/node_mapping.py | 105 ++++++-- .../nodes/variable_assigner/__init__.py | 8 - .../variable_assigner/common/__init__.py | 0 .../nodes/variable_assigner/common/exc.py | 4 + .../nodes/variable_assigner/common/helpers.py | 19 ++ .../workflow/nodes/variable_assigner/exc.py | 2 - .../nodes/variable_assigner/v1/__init__.py | 3 + .../nodes/variable_assigner/{ => v1}/node.py | 36 +-- .../variable_assigner/{ => v1}/node_data.py | 3 - .../nodes/variable_assigner/v2/__init__.py | 3 + .../nodes/variable_assigner/v2/constants.py | 11 + .../nodes/variable_assigner/v2/entities.py | 20 ++ .../nodes/variable_assigner/v2/enums.py | 18 ++ .../nodes/variable_assigner/v2/exc.py | 31 +++ .../nodes/variable_assigner/v2/helpers.py | 91 +++++++ .../nodes/variable_assigner/v2/node.py | 159 ++++++++++++ api/core/workflow/workflow_entry.py | 11 +- api/factories/variable_factory.py | 23 +- api/models/workflow.py | 8 +- api/services/app_dsl_service.py | 4 +- api/services/workflow_service.py | 11 +- .../vdb/analyticdb/test_analyticdb.py | 2 +- .../core/app/segments/test_factory.py | 22 +- .../v1/test_variable_assigner_v1.py} | 12 +- .../variable_assigner/v2/test_helpers.py | 24 ++ .../models/test_conversation_variable.py | 2 +- api/tests/unit_tests/models/test_workflow.py | 32 ++- web/app/components/base/badge.tsx | 2 +- web/app/components/base/input/index.tsx | 4 +- .../base/list-empty/horizontal-line.tsx | 21 ++ web/app/components/base/list-empty/index.tsx | 35 +++ .../base/list-empty/vertical-line.tsx | 21 ++ web/app/components/workflow/block-icon.tsx | 1 + .../components/editor/code-editor/index.tsx | 4 +- .../workflow/nodes/_base/components/field.tsx | 1 - .../components/list-no-data-placeholder.tsx | 2 +- .../variable/assigned-var-reference-popup.tsx | 39 +++ .../variable/var-reference-picker.tsx | 21 +- .../variable/var-reference-popup.tsx | 45 +++- .../nodes/_base/hooks/use-one-step-run.ts | 4 +- .../components/operation-selector.tsx | 128 ++++++++++ .../assigner/components/var-list/index.tsx | 227 ++++++++++++++++++ .../components/var-list/use-var-list.ts | 39 +++ .../workflow/nodes/assigner/default.ts | 28 ++- .../workflow/nodes/assigner/hooks.ts | 70 ++++++ .../workflow/nodes/assigner/node.tsx | 56 ++++- .../workflow/nodes/assigner/panel.tsx | 97 +++----- .../workflow/nodes/assigner/types.ts | 29 ++- .../workflow/nodes/assigner/use-config.ts | 127 +++++----- .../workflow/nodes/assigner/utils.ts | 78 ++++++ .../components/node-variable-item.tsx | 24 +- web/i18n/en-US/workflow.ts | 27 +++ web/i18n/zh-Hans/workflow.ts | 29 ++- 62 files changed, 1565 insertions(+), 301 deletions(-) create mode 100644 api/core/workflow/nodes/variable_assigner/common/__init__.py create mode 100644 api/core/workflow/nodes/variable_assigner/common/exc.py create mode 100644 api/core/workflow/nodes/variable_assigner/common/helpers.py delete mode 100644 api/core/workflow/nodes/variable_assigner/exc.py create mode 100644 api/core/workflow/nodes/variable_assigner/v1/__init__.py rename api/core/workflow/nodes/variable_assigner/{ => v1}/node.py (69%) rename api/core/workflow/nodes/variable_assigner/{ => v1}/node_data.py (75%) create mode 100644 api/core/workflow/nodes/variable_assigner/v2/__init__.py create mode 100644 api/core/workflow/nodes/variable_assigner/v2/constants.py create mode 100644 api/core/workflow/nodes/variable_assigner/v2/entities.py create mode 100644 api/core/workflow/nodes/variable_assigner/v2/enums.py create mode 100644 api/core/workflow/nodes/variable_assigner/v2/exc.py create mode 100644 api/core/workflow/nodes/variable_assigner/v2/helpers.py create mode 100644 api/core/workflow/nodes/variable_assigner/v2/node.py rename api/tests/unit_tests/core/workflow/nodes/{test_variable_assigner.py => variable_assigner/v1/test_variable_assigner_v1.py} (92%) create mode 100644 api/tests/unit_tests/core/workflow/nodes/variable_assigner/v2/test_helpers.py create mode 100644 web/app/components/base/list-empty/horizontal-line.tsx create mode 100644 web/app/components/base/list-empty/index.tsx create mode 100644 web/app/components/base/list-empty/vertical-line.tsx create mode 100644 web/app/components/workflow/nodes/_base/components/variable/assigned-var-reference-popup.tsx create mode 100644 web/app/components/workflow/nodes/assigner/components/operation-selector.tsx create mode 100644 web/app/components/workflow/nodes/assigner/components/var-list/index.tsx create mode 100644 web/app/components/workflow/nodes/assigner/components/var-list/use-var-list.ts create mode 100644 web/app/components/workflow/nodes/assigner/hooks.ts diff --git a/api/controllers/console/app/workflow.py b/api/controllers/console/app/workflow.py index cc05a0d509..c85d554069 100644 --- a/api/controllers/console/app/workflow.py +++ b/api/controllers/console/app/workflow.py @@ -100,11 +100,11 @@ class DraftWorkflowApi(Resource): try: environment_variables_list = args.get("environment_variables") or [] environment_variables = [ - variable_factory.build_variable_from_mapping(obj) for obj in environment_variables_list + variable_factory.build_environment_variable_from_mapping(obj) for obj in environment_variables_list ] conversation_variables_list = args.get("conversation_variables") or [] conversation_variables = [ - variable_factory.build_variable_from_mapping(obj) for obj in conversation_variables_list + variable_factory.build_conversation_variable_from_mapping(obj) for obj in conversation_variables_list ] workflow = workflow_service.sync_draft_workflow( app_model=app_model, @@ -382,7 +382,7 @@ class DefaultBlockConfigApi(Resource): filters = None if args.get("q"): try: - filters = json.loads(args.get("q")) + filters = json.loads(args.get("q", "")) except json.JSONDecodeError: raise ValueError("Invalid filters") diff --git a/api/core/app/apps/workflow_app_runner.py b/api/core/app/apps/workflow_app_runner.py index 1cf72ae79e..3d46b8bab0 100644 --- a/api/core/app/apps/workflow_app_runner.py +++ b/api/core/app/apps/workflow_app_runner.py @@ -43,7 +43,7 @@ from core.workflow.graph_engine.entities.event import ( ) from core.workflow.graph_engine.entities.graph import Graph from core.workflow.nodes import NodeType -from core.workflow.nodes.node_mapping import node_type_classes_mapping +from core.workflow.nodes.node_mapping import NODE_TYPE_CLASSES_MAPPING from core.workflow.workflow_entry import WorkflowEntry from extensions.ext_database import db from models.model import App @@ -138,7 +138,8 @@ class WorkflowBasedAppRunner(AppRunner): # Get node class node_type = NodeType(iteration_node_config.get("data", {}).get("type")) - node_cls = node_type_classes_mapping[node_type] + node_version = iteration_node_config.get("data", {}).get("version", "1") + node_cls = NODE_TYPE_CLASSES_MAPPING[node_type][node_version] # init variable pool variable_pool = VariablePool( diff --git a/api/core/variables/types.py b/api/core/variables/types.py index af6a2a2937..4387e9693e 100644 --- a/api/core/variables/types.py +++ b/api/core/variables/types.py @@ -2,16 +2,19 @@ from enum import StrEnum class SegmentType(StrEnum): - NONE = "none" NUMBER = "number" STRING = "string" + OBJECT = "object" SECRET = "secret" + + FILE = "file" + ARRAY_ANY = "array[any]" ARRAY_STRING = "array[string]" ARRAY_NUMBER = "array[number]" ARRAY_OBJECT = "array[object]" - OBJECT = "object" - FILE = "file" ARRAY_FILE = "array[file]" + NONE = "none" + GROUP = "group" diff --git a/api/core/workflow/graph_engine/graph_engine.py b/api/core/workflow/graph_engine/graph_engine.py index 035c34dcf4..7cffd7bc8e 100644 --- a/api/core/workflow/graph_engine/graph_engine.py +++ b/api/core/workflow/graph_engine/graph_engine.py @@ -38,7 +38,7 @@ from core.workflow.nodes.answer.answer_stream_processor import AnswerStreamProce from core.workflow.nodes.base import BaseNode from core.workflow.nodes.end.end_stream_processor import EndStreamProcessor from core.workflow.nodes.event import RunCompletedEvent, RunRetrieverResourceEvent, RunStreamChunkEvent -from core.workflow.nodes.node_mapping import node_type_classes_mapping +from core.workflow.nodes.node_mapping import NODE_TYPE_CLASSES_MAPPING from extensions.ext_database import db from models.enums import UserFrom from models.workflow import WorkflowNodeExecutionStatus, WorkflowType @@ -227,7 +227,8 @@ class GraphEngine: # convert to specific node node_type = NodeType(node_config.get("data", {}).get("type")) - node_cls = node_type_classes_mapping[node_type] + node_version = node_config.get("data", {}).get("version", "1") + node_cls = NODE_TYPE_CLASSES_MAPPING[node_type][node_version] previous_node_id = previous_route_node_state.node_id if previous_route_node_state else None diff --git a/api/core/workflow/nodes/answer/answer_stream_generate_router.py b/api/core/workflow/nodes/answer/answer_stream_generate_router.py index 96e24a7db3..8c78016f09 100644 --- a/api/core/workflow/nodes/answer/answer_stream_generate_router.py +++ b/api/core/workflow/nodes/answer/answer_stream_generate_router.py @@ -153,7 +153,7 @@ class AnswerStreamGeneratorRouter: NodeType.IF_ELSE, NodeType.QUESTION_CLASSIFIER, NodeType.ITERATION, - NodeType.CONVERSATION_VARIABLE_ASSIGNER, + NodeType.VARIABLE_ASSIGNER, }: answer_dependencies[answer_node_id].append(source_node_id) else: diff --git a/api/core/workflow/nodes/base/entities.py b/api/core/workflow/nodes/base/entities.py index 2a864dd7a8..fb50fbd6e8 100644 --- a/api/core/workflow/nodes/base/entities.py +++ b/api/core/workflow/nodes/base/entities.py @@ -7,6 +7,7 @@ from pydantic import BaseModel class BaseNodeData(ABC, BaseModel): title: str desc: Optional[str] = None + version: str = "1" class BaseIterationNodeData(BaseNodeData): diff --git a/api/core/workflow/nodes/base/node.py b/api/core/workflow/nodes/base/node.py index 1871fff618..d0fbed31cd 100644 --- a/api/core/workflow/nodes/base/node.py +++ b/api/core/workflow/nodes/base/node.py @@ -55,7 +55,9 @@ class BaseNode(Generic[GenericNodeData]): raise ValueError("Node ID is required.") self.node_id = node_id - self.node_data: GenericNodeData = cast(GenericNodeData, self._node_data_cls(**config.get("data", {}))) + + node_data = self._node_data_cls.model_validate(config.get("data", {})) + self.node_data = cast(GenericNodeData, node_data) @abstractmethod def _run(self) -> NodeRunResult | Generator[Union[NodeEvent, "InNodeEvent"], None, None]: diff --git a/api/core/workflow/nodes/enums.py b/api/core/workflow/nodes/enums.py index 9e9e52910e..44be403ee6 100644 --- a/api/core/workflow/nodes/enums.py +++ b/api/core/workflow/nodes/enums.py @@ -14,11 +14,11 @@ class NodeType(StrEnum): HTTP_REQUEST = "http-request" TOOL = "tool" VARIABLE_AGGREGATOR = "variable-aggregator" - VARIABLE_ASSIGNER = "variable-assigner" # TODO: Merge this into VARIABLE_AGGREGATOR in the database. + LEGACY_VARIABLE_AGGREGATOR = "variable-assigner" # TODO: Merge this into VARIABLE_AGGREGATOR in the database. LOOP = "loop" ITERATION = "iteration" ITERATION_START = "iteration-start" # Fake start node for iteration. PARAMETER_EXTRACTOR = "parameter-extractor" - CONVERSATION_VARIABLE_ASSIGNER = "assigner" + VARIABLE_ASSIGNER = "assigner" DOCUMENT_EXTRACTOR = "document-extractor" LIST_OPERATOR = "list-operator" diff --git a/api/core/workflow/nodes/iteration/iteration_node.py b/api/core/workflow/nodes/iteration/iteration_node.py index e32e58b780..6079edebdb 100644 --- a/api/core/workflow/nodes/iteration/iteration_node.py +++ b/api/core/workflow/nodes/iteration/iteration_node.py @@ -298,12 +298,13 @@ class IterationNode(BaseNode[IterationNodeData]): # variable selector to variable mapping try: # Get node class - from core.workflow.nodes.node_mapping import node_type_classes_mapping + from core.workflow.nodes.node_mapping import NODE_TYPE_CLASSES_MAPPING node_type = NodeType(sub_node_config.get("data", {}).get("type")) - node_cls = node_type_classes_mapping.get(node_type) - if not node_cls: + if node_type not in NODE_TYPE_CLASSES_MAPPING: continue + node_version = sub_node_config.get("data", {}).get("version", "1") + node_cls = NODE_TYPE_CLASSES_MAPPING[node_type][node_version] sub_node_variable_mapping = node_cls.extract_variable_selector_to_variable_mapping( graph_config=graph_config, config=sub_node_config diff --git a/api/core/workflow/nodes/node_mapping.py b/api/core/workflow/nodes/node_mapping.py index c13b5ff76f..51fc5129cd 100644 --- a/api/core/workflow/nodes/node_mapping.py +++ b/api/core/workflow/nodes/node_mapping.py @@ -1,3 +1,5 @@ +from collections.abc import Mapping + from core.workflow.nodes.answer import AnswerNode from core.workflow.nodes.base import BaseNode from core.workflow.nodes.code import CodeNode @@ -16,26 +18,87 @@ from core.workflow.nodes.start import StartNode from core.workflow.nodes.template_transform import TemplateTransformNode from core.workflow.nodes.tool import ToolNode from core.workflow.nodes.variable_aggregator import VariableAggregatorNode -from core.workflow.nodes.variable_assigner import VariableAssignerNode +from core.workflow.nodes.variable_assigner.v1 import VariableAssignerNode as VariableAssignerNodeV1 +from core.workflow.nodes.variable_assigner.v2 import VariableAssignerNode as VariableAssignerNodeV2 -node_type_classes_mapping: dict[NodeType, type[BaseNode]] = { - NodeType.START: StartNode, - NodeType.END: EndNode, - NodeType.ANSWER: AnswerNode, - NodeType.LLM: LLMNode, - NodeType.KNOWLEDGE_RETRIEVAL: KnowledgeRetrievalNode, - NodeType.IF_ELSE: IfElseNode, - NodeType.CODE: CodeNode, - NodeType.TEMPLATE_TRANSFORM: TemplateTransformNode, - NodeType.QUESTION_CLASSIFIER: QuestionClassifierNode, - NodeType.HTTP_REQUEST: HttpRequestNode, - NodeType.TOOL: ToolNode, - NodeType.VARIABLE_AGGREGATOR: VariableAggregatorNode, - NodeType.VARIABLE_ASSIGNER: VariableAggregatorNode, # original name of VARIABLE_AGGREGATOR - NodeType.ITERATION: IterationNode, - NodeType.ITERATION_START: IterationStartNode, - NodeType.PARAMETER_EXTRACTOR: ParameterExtractorNode, - NodeType.CONVERSATION_VARIABLE_ASSIGNER: VariableAssignerNode, - NodeType.DOCUMENT_EXTRACTOR: DocumentExtractorNode, - NodeType.LIST_OPERATOR: ListOperatorNode, +LATEST_VERSION = "latest" + +NODE_TYPE_CLASSES_MAPPING: Mapping[NodeType, Mapping[str, type[BaseNode]]] = { + NodeType.START: { + LATEST_VERSION: StartNode, + "1": StartNode, + }, + NodeType.END: { + LATEST_VERSION: EndNode, + "1": EndNode, + }, + NodeType.ANSWER: { + LATEST_VERSION: AnswerNode, + "1": AnswerNode, + }, + NodeType.LLM: { + LATEST_VERSION: LLMNode, + "1": LLMNode, + }, + NodeType.KNOWLEDGE_RETRIEVAL: { + LATEST_VERSION: KnowledgeRetrievalNode, + "1": KnowledgeRetrievalNode, + }, + NodeType.IF_ELSE: { + LATEST_VERSION: IfElseNode, + "1": IfElseNode, + }, + NodeType.CODE: { + LATEST_VERSION: CodeNode, + "1": CodeNode, + }, + NodeType.TEMPLATE_TRANSFORM: { + LATEST_VERSION: TemplateTransformNode, + "1": TemplateTransformNode, + }, + NodeType.QUESTION_CLASSIFIER: { + LATEST_VERSION: QuestionClassifierNode, + "1": QuestionClassifierNode, + }, + NodeType.HTTP_REQUEST: { + LATEST_VERSION: HttpRequestNode, + "1": HttpRequestNode, + }, + NodeType.TOOL: { + LATEST_VERSION: ToolNode, + "1": ToolNode, + }, + NodeType.VARIABLE_AGGREGATOR: { + LATEST_VERSION: VariableAggregatorNode, + "1": VariableAggregatorNode, + }, + NodeType.LEGACY_VARIABLE_AGGREGATOR: { + LATEST_VERSION: VariableAggregatorNode, + "1": VariableAggregatorNode, + }, # original name of VARIABLE_AGGREGATOR + NodeType.ITERATION: { + LATEST_VERSION: IterationNode, + "1": IterationNode, + }, + NodeType.ITERATION_START: { + LATEST_VERSION: IterationStartNode, + "1": IterationStartNode, + }, + NodeType.PARAMETER_EXTRACTOR: { + LATEST_VERSION: ParameterExtractorNode, + "1": ParameterExtractorNode, + }, + NodeType.VARIABLE_ASSIGNER: { + LATEST_VERSION: VariableAssignerNodeV2, + "1": VariableAssignerNodeV1, + "2": VariableAssignerNodeV2, + }, + NodeType.DOCUMENT_EXTRACTOR: { + LATEST_VERSION: DocumentExtractorNode, + "1": DocumentExtractorNode, + }, + NodeType.LIST_OPERATOR: { + LATEST_VERSION: ListOperatorNode, + "1": ListOperatorNode, + }, } diff --git a/api/core/workflow/nodes/variable_assigner/__init__.py b/api/core/workflow/nodes/variable_assigner/__init__.py index eedbd6d255..e69de29bb2 100644 --- a/api/core/workflow/nodes/variable_assigner/__init__.py +++ b/api/core/workflow/nodes/variable_assigner/__init__.py @@ -1,8 +0,0 @@ -from .node import VariableAssignerNode -from .node_data import VariableAssignerData, WriteMode - -__all__ = [ - "VariableAssignerData", - "VariableAssignerNode", - "WriteMode", -] diff --git a/api/core/workflow/nodes/variable_assigner/common/__init__.py b/api/core/workflow/nodes/variable_assigner/common/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/core/workflow/nodes/variable_assigner/common/exc.py b/api/core/workflow/nodes/variable_assigner/common/exc.py new file mode 100644 index 0000000000..a1178fb020 --- /dev/null +++ b/api/core/workflow/nodes/variable_assigner/common/exc.py @@ -0,0 +1,4 @@ +class VariableOperatorNodeError(Exception): + """Base error type, don't use directly.""" + + pass diff --git a/api/core/workflow/nodes/variable_assigner/common/helpers.py b/api/core/workflow/nodes/variable_assigner/common/helpers.py new file mode 100644 index 0000000000..8031b57fa8 --- /dev/null +++ b/api/core/workflow/nodes/variable_assigner/common/helpers.py @@ -0,0 +1,19 @@ +from sqlalchemy import select +from sqlalchemy.orm import Session + +from core.variables import Variable +from core.workflow.nodes.variable_assigner.common.exc import VariableOperatorNodeError +from extensions.ext_database import db +from models import ConversationVariable + + +def update_conversation_variable(conversation_id: str, variable: Variable): + stmt = select(ConversationVariable).where( + ConversationVariable.id == variable.id, ConversationVariable.conversation_id == conversation_id + ) + with Session(db.engine) as session: + row = session.scalar(stmt) + if not row: + raise VariableOperatorNodeError("conversation variable not found in the database") + row.data = variable.model_dump_json() + session.commit() diff --git a/api/core/workflow/nodes/variable_assigner/exc.py b/api/core/workflow/nodes/variable_assigner/exc.py deleted file mode 100644 index 914be22256..0000000000 --- a/api/core/workflow/nodes/variable_assigner/exc.py +++ /dev/null @@ -1,2 +0,0 @@ -class VariableAssignerNodeError(Exception): - pass diff --git a/api/core/workflow/nodes/variable_assigner/v1/__init__.py b/api/core/workflow/nodes/variable_assigner/v1/__init__.py new file mode 100644 index 0000000000..7eb1428e50 --- /dev/null +++ b/api/core/workflow/nodes/variable_assigner/v1/__init__.py @@ -0,0 +1,3 @@ +from .node import VariableAssignerNode + +__all__ = ["VariableAssignerNode"] diff --git a/api/core/workflow/nodes/variable_assigner/node.py b/api/core/workflow/nodes/variable_assigner/v1/node.py similarity index 69% rename from api/core/workflow/nodes/variable_assigner/node.py rename to api/core/workflow/nodes/variable_assigner/v1/node.py index 4e66f640df..8eb4bd5c2d 100644 --- a/api/core/workflow/nodes/variable_assigner/node.py +++ b/api/core/workflow/nodes/variable_assigner/v1/node.py @@ -1,40 +1,36 @@ -from sqlalchemy import select -from sqlalchemy.orm import Session - from core.variables import SegmentType, Variable from core.workflow.entities.node_entities import NodeRunResult from core.workflow.nodes.base import BaseNode, BaseNodeData from core.workflow.nodes.enums import NodeType -from extensions.ext_database import db +from core.workflow.nodes.variable_assigner.common import helpers as common_helpers +from core.workflow.nodes.variable_assigner.common.exc import VariableOperatorNodeError from factories import variable_factory -from models import ConversationVariable from models.workflow import WorkflowNodeExecutionStatus -from .exc import VariableAssignerNodeError from .node_data import VariableAssignerData, WriteMode class VariableAssignerNode(BaseNode[VariableAssignerData]): _node_data_cls: type[BaseNodeData] = VariableAssignerData - _node_type: NodeType = NodeType.CONVERSATION_VARIABLE_ASSIGNER + _node_type = NodeType.VARIABLE_ASSIGNER def _run(self) -> NodeRunResult: # Should be String, Number, Object, ArrayString, ArrayNumber, ArrayObject original_variable = self.graph_runtime_state.variable_pool.get(self.node_data.assigned_variable_selector) if not isinstance(original_variable, Variable): - raise VariableAssignerNodeError("assigned variable not found") + raise VariableOperatorNodeError("assigned variable not found") match self.node_data.write_mode: case WriteMode.OVER_WRITE: income_value = self.graph_runtime_state.variable_pool.get(self.node_data.input_variable_selector) if not income_value: - raise VariableAssignerNodeError("input value not found") + raise VariableOperatorNodeError("input value not found") updated_variable = original_variable.model_copy(update={"value": income_value.value}) case WriteMode.APPEND: income_value = self.graph_runtime_state.variable_pool.get(self.node_data.input_variable_selector) if not income_value: - raise VariableAssignerNodeError("input value not found") + raise VariableOperatorNodeError("input value not found") updated_value = original_variable.value + [income_value.value] updated_variable = original_variable.model_copy(update={"value": updated_value}) @@ -43,7 +39,7 @@ class VariableAssignerNode(BaseNode[VariableAssignerData]): updated_variable = original_variable.model_copy(update={"value": income_value.to_object()}) case _: - raise VariableAssignerNodeError(f"unsupported write mode: {self.node_data.write_mode}") + raise VariableOperatorNodeError(f"unsupported write mode: {self.node_data.write_mode}") # Over write the variable. self.graph_runtime_state.variable_pool.add(self.node_data.assigned_variable_selector, updated_variable) @@ -52,8 +48,8 @@ class VariableAssignerNode(BaseNode[VariableAssignerData]): # Update conversation variable. conversation_id = self.graph_runtime_state.variable_pool.get(["sys", "conversation_id"]) if not conversation_id: - raise VariableAssignerNodeError("conversation_id not found") - update_conversation_variable(conversation_id=conversation_id.text, variable=updated_variable) + raise VariableOperatorNodeError("conversation_id not found") + common_helpers.update_conversation_variable(conversation_id=conversation_id.text, variable=updated_variable) return NodeRunResult( status=WorkflowNodeExecutionStatus.SUCCEEDED, @@ -63,18 +59,6 @@ class VariableAssignerNode(BaseNode[VariableAssignerData]): ) -def update_conversation_variable(conversation_id: str, variable: Variable): - stmt = select(ConversationVariable).where( - ConversationVariable.id == variable.id, ConversationVariable.conversation_id == conversation_id - ) - with Session(db.engine) as session: - row = session.scalar(stmt) - if not row: - raise VariableAssignerNodeError("conversation variable not found in the database") - row.data = variable.model_dump_json() - session.commit() - - def get_zero_value(t: SegmentType): match t: case SegmentType.ARRAY_OBJECT | SegmentType.ARRAY_STRING | SegmentType.ARRAY_NUMBER: @@ -86,4 +70,4 @@ def get_zero_value(t: SegmentType): case SegmentType.NUMBER: return variable_factory.build_segment(0) case _: - raise VariableAssignerNodeError(f"unsupported variable type: {t}") + raise VariableOperatorNodeError(f"unsupported variable type: {t}") diff --git a/api/core/workflow/nodes/variable_assigner/node_data.py b/api/core/workflow/nodes/variable_assigner/v1/node_data.py similarity index 75% rename from api/core/workflow/nodes/variable_assigner/node_data.py rename to api/core/workflow/nodes/variable_assigner/v1/node_data.py index 474ecefe76..9734d64712 100644 --- a/api/core/workflow/nodes/variable_assigner/node_data.py +++ b/api/core/workflow/nodes/variable_assigner/v1/node_data.py @@ -1,6 +1,5 @@ from collections.abc import Sequence from enum import StrEnum -from typing import Optional from core.workflow.nodes.base import BaseNodeData @@ -12,8 +11,6 @@ class WriteMode(StrEnum): class VariableAssignerData(BaseNodeData): - title: str = "Variable Assigner" - desc: Optional[str] = "Assign a value to a variable" assigned_variable_selector: Sequence[str] write_mode: WriteMode input_variable_selector: Sequence[str] diff --git a/api/core/workflow/nodes/variable_assigner/v2/__init__.py b/api/core/workflow/nodes/variable_assigner/v2/__init__.py new file mode 100644 index 0000000000..7eb1428e50 --- /dev/null +++ b/api/core/workflow/nodes/variable_assigner/v2/__init__.py @@ -0,0 +1,3 @@ +from .node import VariableAssignerNode + +__all__ = ["VariableAssignerNode"] diff --git a/api/core/workflow/nodes/variable_assigner/v2/constants.py b/api/core/workflow/nodes/variable_assigner/v2/constants.py new file mode 100644 index 0000000000..3797bfa77a --- /dev/null +++ b/api/core/workflow/nodes/variable_assigner/v2/constants.py @@ -0,0 +1,11 @@ +from core.variables import SegmentType + +EMPTY_VALUE_MAPPING = { + SegmentType.STRING: "", + SegmentType.NUMBER: 0, + SegmentType.OBJECT: {}, + SegmentType.ARRAY_ANY: [], + SegmentType.ARRAY_STRING: [], + SegmentType.ARRAY_NUMBER: [], + SegmentType.ARRAY_OBJECT: [], +} diff --git a/api/core/workflow/nodes/variable_assigner/v2/entities.py b/api/core/workflow/nodes/variable_assigner/v2/entities.py new file mode 100644 index 0000000000..01df33b6d4 --- /dev/null +++ b/api/core/workflow/nodes/variable_assigner/v2/entities.py @@ -0,0 +1,20 @@ +from collections.abc import Sequence +from typing import Any + +from pydantic import BaseModel + +from core.workflow.nodes.base import BaseNodeData + +from .enums import InputType, Operation + + +class VariableOperationItem(BaseModel): + variable_selector: Sequence[str] + input_type: InputType + operation: Operation + value: Any | None = None + + +class VariableAssignerNodeData(BaseNodeData): + version: str = "2" + items: Sequence[VariableOperationItem] diff --git a/api/core/workflow/nodes/variable_assigner/v2/enums.py b/api/core/workflow/nodes/variable_assigner/v2/enums.py new file mode 100644 index 0000000000..36cf68aa19 --- /dev/null +++ b/api/core/workflow/nodes/variable_assigner/v2/enums.py @@ -0,0 +1,18 @@ +from enum import StrEnum + + +class Operation(StrEnum): + OVER_WRITE = "over-write" + CLEAR = "clear" + APPEND = "append" + EXTEND = "extend" + SET = "set" + ADD = "+=" + SUBTRACT = "-=" + MULTIPLY = "*=" + DIVIDE = "/=" + + +class InputType(StrEnum): + VARIABLE = "variable" + CONSTANT = "constant" diff --git a/api/core/workflow/nodes/variable_assigner/v2/exc.py b/api/core/workflow/nodes/variable_assigner/v2/exc.py new file mode 100644 index 0000000000..5b1ef4b04f --- /dev/null +++ b/api/core/workflow/nodes/variable_assigner/v2/exc.py @@ -0,0 +1,31 @@ +from collections.abc import Sequence +from typing import Any + +from core.workflow.nodes.variable_assigner.common.exc import VariableOperatorNodeError + +from .enums import InputType, Operation + + +class OperationNotSupportedError(VariableOperatorNodeError): + def __init__(self, *, operation: Operation, varialbe_type: str): + super().__init__(f"Operation {operation} is not supported for type {varialbe_type}") + + +class InputTypeNotSupportedError(VariableOperatorNodeError): + def __init__(self, *, input_type: InputType, operation: Operation): + super().__init__(f"Input type {input_type} is not supported for operation {operation}") + + +class VariableNotFoundError(VariableOperatorNodeError): + def __init__(self, *, variable_selector: Sequence[str]): + super().__init__(f"Variable {variable_selector} not found") + + +class InvalidInputValueError(VariableOperatorNodeError): + def __init__(self, *, value: Any): + super().__init__(f"Invalid input value {value}") + + +class ConversationIDNotFoundError(VariableOperatorNodeError): + def __init__(self): + super().__init__("conversation_id not found") diff --git a/api/core/workflow/nodes/variable_assigner/v2/helpers.py b/api/core/workflow/nodes/variable_assigner/v2/helpers.py new file mode 100644 index 0000000000..a86c7eb94a --- /dev/null +++ b/api/core/workflow/nodes/variable_assigner/v2/helpers.py @@ -0,0 +1,91 @@ +from typing import Any + +from core.variables import SegmentType + +from .enums import Operation + + +def is_operation_supported(*, variable_type: SegmentType, operation: Operation): + match operation: + case Operation.OVER_WRITE | Operation.CLEAR: + return True + case Operation.SET: + return variable_type in {SegmentType.OBJECT, SegmentType.STRING, SegmentType.NUMBER} + case Operation.ADD | Operation.SUBTRACT | Operation.MULTIPLY | Operation.DIVIDE: + # Only number variable can be added, subtracted, multiplied or divided + return variable_type == SegmentType.NUMBER + case Operation.APPEND | Operation.EXTEND: + # Only array variable can be appended or extended + return variable_type in { + SegmentType.ARRAY_ANY, + SegmentType.ARRAY_OBJECT, + SegmentType.ARRAY_STRING, + SegmentType.ARRAY_NUMBER, + SegmentType.ARRAY_FILE, + } + case _: + return False + + +def is_variable_input_supported(*, operation: Operation): + if operation in {Operation.SET, Operation.ADD, Operation.SUBTRACT, Operation.MULTIPLY, Operation.DIVIDE}: + return False + return True + + +def is_constant_input_supported(*, variable_type: SegmentType, operation: Operation): + match variable_type: + case SegmentType.STRING | SegmentType.OBJECT: + return operation in {Operation.OVER_WRITE, Operation.SET} + case SegmentType.NUMBER: + return operation in { + Operation.OVER_WRITE, + Operation.SET, + Operation.ADD, + Operation.SUBTRACT, + Operation.MULTIPLY, + Operation.DIVIDE, + } + case _: + return False + + +def is_input_value_valid(*, variable_type: SegmentType, operation: Operation, value: Any): + if operation == Operation.CLEAR: + return True + match variable_type: + case SegmentType.STRING: + return isinstance(value, str) + + case SegmentType.NUMBER: + if not isinstance(value, int | float): + return False + if operation == Operation.DIVIDE and value == 0: + return False + return True + + case SegmentType.OBJECT: + return isinstance(value, dict) + + # Array & Append + case SegmentType.ARRAY_ANY if operation == Operation.APPEND: + return isinstance(value, str | float | int | dict) + case SegmentType.ARRAY_STRING if operation == Operation.APPEND: + return isinstance(value, str) + case SegmentType.ARRAY_NUMBER if operation == Operation.APPEND: + return isinstance(value, int | float) + case SegmentType.ARRAY_OBJECT if operation == Operation.APPEND: + return isinstance(value, dict) + + # Array & Extend / Overwrite + case SegmentType.ARRAY_ANY if operation in {Operation.EXTEND, Operation.OVER_WRITE}: + return isinstance(value, list) and all(isinstance(item, str | float | int | dict) for item in value) + case SegmentType.ARRAY_STRING if operation in {Operation.EXTEND, Operation.OVER_WRITE}: + return isinstance(value, list) and all(isinstance(item, str) for item in value) + case SegmentType.ARRAY_NUMBER if operation in {Operation.EXTEND, Operation.OVER_WRITE}: + return isinstance(value, list) and all(isinstance(item, int | float) for item in value) + case SegmentType.ARRAY_OBJECT if operation in {Operation.EXTEND, Operation.OVER_WRITE}: + return isinstance(value, list) and all(isinstance(item, dict) for item in value) + + case _: + return False diff --git a/api/core/workflow/nodes/variable_assigner/v2/node.py b/api/core/workflow/nodes/variable_assigner/v2/node.py new file mode 100644 index 0000000000..ea59a2f170 --- /dev/null +++ b/api/core/workflow/nodes/variable_assigner/v2/node.py @@ -0,0 +1,159 @@ +import json +from typing import Any + +from core.variables import SegmentType, Variable +from core.workflow.constants import CONVERSATION_VARIABLE_NODE_ID +from core.workflow.entities.node_entities import NodeRunResult +from core.workflow.nodes.base import BaseNode +from core.workflow.nodes.enums import NodeType +from core.workflow.nodes.variable_assigner.common import helpers as common_helpers +from core.workflow.nodes.variable_assigner.common.exc import VariableOperatorNodeError +from models.workflow import WorkflowNodeExecutionStatus + +from . import helpers +from .constants import EMPTY_VALUE_MAPPING +from .entities import VariableAssignerNodeData +from .enums import InputType, Operation +from .exc import ( + ConversationIDNotFoundError, + InputTypeNotSupportedError, + InvalidInputValueError, + OperationNotSupportedError, + VariableNotFoundError, +) + + +class VariableAssignerNode(BaseNode[VariableAssignerNodeData]): + _node_data_cls = VariableAssignerNodeData + _node_type = NodeType.VARIABLE_ASSIGNER + + def _run(self) -> NodeRunResult: + inputs = self.node_data.model_dump() + process_data = {} + # NOTE: This node has no outputs + updated_variables: list[Variable] = [] + + try: + for item in self.node_data.items: + variable = self.graph_runtime_state.variable_pool.get(item.variable_selector) + + # ==================== Validation Part + + # Check if variable exists + if not isinstance(variable, Variable): + raise VariableNotFoundError(variable_selector=item.variable_selector) + + # Check if operation is supported + if not helpers.is_operation_supported(variable_type=variable.value_type, operation=item.operation): + raise OperationNotSupportedError(operation=item.operation, varialbe_type=variable.value_type) + + # Check if variable input is supported + if item.input_type == InputType.VARIABLE and not helpers.is_variable_input_supported( + operation=item.operation + ): + raise InputTypeNotSupportedError(input_type=InputType.VARIABLE, operation=item.operation) + + # Check if constant input is supported + if item.input_type == InputType.CONSTANT and not helpers.is_constant_input_supported( + variable_type=variable.value_type, operation=item.operation + ): + raise InputTypeNotSupportedError(input_type=InputType.CONSTANT, operation=item.operation) + + # Get value from variable pool + if ( + item.input_type == InputType.VARIABLE + and item.operation != Operation.CLEAR + and item.value is not None + ): + value = self.graph_runtime_state.variable_pool.get(item.value) + if value is None: + raise VariableNotFoundError(variable_selector=item.value) + # Skip if value is NoneSegment + if value.value_type == SegmentType.NONE: + continue + item.value = value.value + + # If set string / bytes / bytearray to object, try convert string to object. + if ( + item.operation == Operation.SET + and variable.value_type == SegmentType.OBJECT + and isinstance(item.value, str | bytes | bytearray) + ): + try: + item.value = json.loads(item.value) + except json.JSONDecodeError: + raise InvalidInputValueError(value=item.value) + + # Check if input value is valid + if not helpers.is_input_value_valid( + variable_type=variable.value_type, operation=item.operation, value=item.value + ): + raise InvalidInputValueError(value=item.value) + + # ==================== Execution Part + + updated_value = self._handle_item( + variable=variable, + operation=item.operation, + value=item.value, + ) + variable = variable.model_copy(update={"value": updated_value}) + updated_variables.append(variable) + except VariableOperatorNodeError as e: + return NodeRunResult( + status=WorkflowNodeExecutionStatus.FAILED, + inputs=inputs, + process_data=process_data, + error=str(e), + ) + + # Update variables + for variable in updated_variables: + self.graph_runtime_state.variable_pool.add(variable.selector, variable) + process_data[variable.name] = variable.value + + if variable.selector[0] == CONVERSATION_VARIABLE_NODE_ID: + conversation_id = self.graph_runtime_state.variable_pool.get(["sys", "conversation_id"]) + if not conversation_id: + raise ConversationIDNotFoundError + else: + conversation_id = conversation_id.value + common_helpers.update_conversation_variable( + conversation_id=conversation_id, + variable=variable, + ) + + return NodeRunResult( + status=WorkflowNodeExecutionStatus.SUCCEEDED, + inputs=inputs, + process_data=process_data, + ) + + def _handle_item( + self, + *, + variable: Variable, + operation: Operation, + value: Any, + ): + match operation: + case Operation.OVER_WRITE: + return value + case Operation.CLEAR: + return EMPTY_VALUE_MAPPING[variable.value_type] + case Operation.APPEND: + return variable.value + [value] + case Operation.EXTEND: + return variable.value + value + case Operation.SET: + return value + case Operation.ADD: + return variable.value + value + case Operation.SUBTRACT: + return variable.value - value + case Operation.MULTIPLY: + return variable.value * value + case Operation.DIVIDE: + return variable.value / value + case _: + raise OperationNotSupportedError(operation=operation, varialbe_type=variable.value_type) diff --git a/api/core/workflow/workflow_entry.py b/api/core/workflow/workflow_entry.py index 6f7b143ad6..811e40c11e 100644 --- a/api/core/workflow/workflow_entry.py +++ b/api/core/workflow/workflow_entry.py @@ -2,7 +2,7 @@ import logging import time import uuid from collections.abc import Generator, Mapping, Sequence -from typing import Any, Optional, cast +from typing import Any, Optional from configs import dify_config from core.app.apps.base_app_queue_manager import GenerateTaskStoppedError @@ -19,7 +19,7 @@ from core.workflow.graph_engine.graph_engine import GraphEngine from core.workflow.nodes import NodeType from core.workflow.nodes.base import BaseNode from core.workflow.nodes.event import NodeEvent -from core.workflow.nodes.node_mapping import node_type_classes_mapping +from core.workflow.nodes.node_mapping import NODE_TYPE_CLASSES_MAPPING from factories import file_factory from models.enums import UserFrom from models.workflow import ( @@ -145,11 +145,8 @@ class WorkflowEntry: # Get node class node_type = NodeType(node_config.get("data", {}).get("type")) - node_cls = node_type_classes_mapping.get(node_type) - node_cls = cast(type[BaseNode], node_cls) - - if not node_cls: - raise ValueError(f"Node class not found for node type {node_type}") + node_version = node_config.get("data", {}).get("version", "1") + node_cls = NODE_TYPE_CLASSES_MAPPING[node_type][node_version] # init variable pool variable_pool = VariablePool(environment_variables=workflow.environment_variables) diff --git a/api/factories/variable_factory.py b/api/factories/variable_factory.py index 5b004405b4..16a578728a 100644 --- a/api/factories/variable_factory.py +++ b/api/factories/variable_factory.py @@ -36,6 +36,7 @@ from core.variables.variables import ( StringVariable, Variable, ) +from core.workflow.constants import CONVERSATION_VARIABLE_NODE_ID, ENVIRONMENT_VARIABLE_NODE_ID class InvalidSelectorError(ValueError): @@ -62,11 +63,25 @@ SEGMENT_TO_VARIABLE_MAP = { } -def build_variable_from_mapping(mapping: Mapping[str, Any], /) -> Variable: - if (value_type := mapping.get("value_type")) is None: - raise VariableError("missing value type") +def build_conversation_variable_from_mapping(mapping: Mapping[str, Any], /) -> Variable: if not mapping.get("name"): raise VariableError("missing name") + return _build_variable_from_mapping(mapping=mapping, selector=[CONVERSATION_VARIABLE_NODE_ID, mapping["name"]]) + + +def build_environment_variable_from_mapping(mapping: Mapping[str, Any], /) -> Variable: + if not mapping.get("name"): + raise VariableError("missing name") + return _build_variable_from_mapping(mapping=mapping, selector=[ENVIRONMENT_VARIABLE_NODE_ID, mapping["name"]]) + + +def _build_variable_from_mapping(*, mapping: Mapping[str, Any], selector: Sequence[str]) -> Variable: + """ + This factory function is used to create the environment variable or the conversation variable, + not support the File type. + """ + if (value_type := mapping.get("value_type")) is None: + raise VariableError("missing value type") if (value := mapping.get("value")) is None: raise VariableError("missing value") match value_type: @@ -92,6 +107,8 @@ def build_variable_from_mapping(mapping: Mapping[str, Any], /) -> Variable: raise VariableError(f"not supported value type {value_type}") if result.size > dify_config.MAX_VARIABLE_SIZE: raise VariableError(f"variable size {result.size} exceeds limit {dify_config.MAX_VARIABLE_SIZE}") + if not result.selector: + result = result.model_copy(update={"selector": selector}) return result diff --git a/api/models/workflow.py b/api/models/workflow.py index fd53f137f9..c0e70889a8 100644 --- a/api/models/workflow.py +++ b/api/models/workflow.py @@ -238,7 +238,9 @@ class Workflow(db.Model): tenant_id = contexts.tenant_id.get() environment_variables_dict: dict[str, Any] = json.loads(self._environment_variables) - results = [variable_factory.build_variable_from_mapping(v) for v in environment_variables_dict.values()] + results = [ + variable_factory.build_environment_variable_from_mapping(v) for v in environment_variables_dict.values() + ] # decrypt secret variables value decrypt_func = ( @@ -303,7 +305,7 @@ class Workflow(db.Model): self._conversation_variables = "{}" variables_dict: dict[str, Any] = json.loads(self._conversation_variables) - results = [variable_factory.build_variable_from_mapping(v) for v in variables_dict.values()] + results = [variable_factory.build_conversation_variable_from_mapping(v) for v in variables_dict.values()] return results @conversation_variables.setter @@ -793,4 +795,4 @@ class ConversationVariable(db.Model): def to_variable(self) -> Variable: mapping = json.loads(self.data) - return variable_factory.build_variable_from_mapping(mapping) + return variable_factory.build_conversation_variable_from_mapping(mapping) diff --git a/api/services/app_dsl_service.py b/api/services/app_dsl_service.py index a4d71d5424..2f202374fd 100644 --- a/api/services/app_dsl_service.py +++ b/api/services/app_dsl_service.py @@ -387,11 +387,11 @@ class AppDslService: environment_variables_list = workflow_data.get("environment_variables", []) environment_variables = [ - variable_factory.build_variable_from_mapping(obj) for obj in environment_variables_list + variable_factory.build_environment_variable_from_mapping(obj) for obj in environment_variables_list ] conversation_variables_list = workflow_data.get("conversation_variables", []) conversation_variables = [ - variable_factory.build_variable_from_mapping(obj) for obj in conversation_variables_list + variable_factory.build_conversation_variable_from_mapping(obj) for obj in conversation_variables_list ] workflow_service = WorkflowService() diff --git a/api/services/workflow_service.py b/api/services/workflow_service.py index aa2babd7f7..37d7d0937c 100644 --- a/api/services/workflow_service.py +++ b/api/services/workflow_service.py @@ -12,7 +12,7 @@ from core.workflow.entities.node_entities import NodeRunResult from core.workflow.errors import WorkflowNodeRunFailedError from core.workflow.nodes import NodeType from core.workflow.nodes.event import RunCompletedEvent -from core.workflow.nodes.node_mapping import node_type_classes_mapping +from core.workflow.nodes.node_mapping import LATEST_VERSION, NODE_TYPE_CLASSES_MAPPING from core.workflow.workflow_entry import WorkflowEntry from events.app_event import app_draft_workflow_was_synced, app_published_workflow_was_updated from extensions.ext_database import db @@ -176,7 +176,8 @@ class WorkflowService: """ # return default block config default_block_configs = [] - for node_type, node_class in node_type_classes_mapping.items(): + for node_class_mapping in NODE_TYPE_CLASSES_MAPPING.values(): + node_class = node_class_mapping[LATEST_VERSION] default_config = node_class.get_default_config() if default_config: default_block_configs.append(default_config) @@ -190,13 +191,13 @@ class WorkflowService: :param filters: filter by node config parameters. :return: """ - node_type_enum: NodeType = NodeType(node_type) + node_type_enum = NodeType(node_type) # return default block config - node_class = node_type_classes_mapping.get(node_type_enum) - if not node_class: + if node_type_enum not in NODE_TYPE_CLASSES_MAPPING: return None + node_class = NODE_TYPE_CLASSES_MAPPING[node_type_enum][LATEST_VERSION] default_config = node_class.get_default_config(filters=filters) if not default_config: return None diff --git a/api/tests/integration_tests/vdb/analyticdb/test_analyticdb.py b/api/tests/integration_tests/vdb/analyticdb/test_analyticdb.py index 4f44d2ffd6..5dd4754e8e 100644 --- a/api/tests/integration_tests/vdb/analyticdb/test_analyticdb.py +++ b/api/tests/integration_tests/vdb/analyticdb/test_analyticdb.py @@ -1,4 +1,4 @@ -from core.rag.datasource.vdb.analyticdb.analyticdb_vector import AnalyticdbConfig, AnalyticdbVector +from core.rag.datasource.vdb.analyticdb.analyticdb_vector import AnalyticdbVector from core.rag.datasource.vdb.analyticdb.analyticdb_vector_openapi import AnalyticdbVectorOpenAPIConfig from core.rag.datasource.vdb.analyticdb.analyticdb_vector_sql import AnalyticdbVectorBySqlConfig from tests.integration_tests.vdb.test_vector_store import AbstractVectorTest, setup_mock_redis diff --git a/api/tests/unit_tests/core/app/segments/test_factory.py b/api/tests/unit_tests/core/app/segments/test_factory.py index 882a87239b..e6e289c12a 100644 --- a/api/tests/unit_tests/core/app/segments/test_factory.py +++ b/api/tests/unit_tests/core/app/segments/test_factory.py @@ -19,36 +19,36 @@ from factories import variable_factory def test_string_variable(): test_data = {"value_type": "string", "name": "test_text", "value": "Hello, World!"} - result = variable_factory.build_variable_from_mapping(test_data) + result = variable_factory.build_conversation_variable_from_mapping(test_data) assert isinstance(result, StringVariable) def test_integer_variable(): test_data = {"value_type": "number", "name": "test_int", "value": 42} - result = variable_factory.build_variable_from_mapping(test_data) + result = variable_factory.build_conversation_variable_from_mapping(test_data) assert isinstance(result, IntegerVariable) def test_float_variable(): test_data = {"value_type": "number", "name": "test_float", "value": 3.14} - result = variable_factory.build_variable_from_mapping(test_data) + result = variable_factory.build_conversation_variable_from_mapping(test_data) assert isinstance(result, FloatVariable) def test_secret_variable(): test_data = {"value_type": "secret", "name": "test_secret", "value": "secret_value"} - result = variable_factory.build_variable_from_mapping(test_data) + result = variable_factory.build_conversation_variable_from_mapping(test_data) assert isinstance(result, SecretVariable) def test_invalid_value_type(): test_data = {"value_type": "unknown", "name": "test_invalid", "value": "value"} with pytest.raises(VariableError): - variable_factory.build_variable_from_mapping(test_data) + variable_factory.build_conversation_variable_from_mapping(test_data) def test_build_a_blank_string(): - result = variable_factory.build_variable_from_mapping( + result = variable_factory.build_conversation_variable_from_mapping( { "value_type": "string", "name": "blank", @@ -80,7 +80,7 @@ def test_object_variable(): "key2": 2, }, } - variable = variable_factory.build_variable_from_mapping(mapping) + variable = variable_factory.build_conversation_variable_from_mapping(mapping) assert isinstance(variable, ObjectSegment) assert isinstance(variable.value["key1"], str) assert isinstance(variable.value["key2"], int) @@ -97,7 +97,7 @@ def test_array_string_variable(): "text", ], } - variable = variable_factory.build_variable_from_mapping(mapping) + variable = variable_factory.build_conversation_variable_from_mapping(mapping) assert isinstance(variable, ArrayStringVariable) assert isinstance(variable.value[0], str) assert isinstance(variable.value[1], str) @@ -114,7 +114,7 @@ def test_array_number_variable(): 2.0, ], } - variable = variable_factory.build_variable_from_mapping(mapping) + variable = variable_factory.build_conversation_variable_from_mapping(mapping) assert isinstance(variable, ArrayNumberVariable) assert isinstance(variable.value[0], int) assert isinstance(variable.value[1], float) @@ -137,7 +137,7 @@ def test_array_object_variable(): }, ], } - variable = variable_factory.build_variable_from_mapping(mapping) + variable = variable_factory.build_conversation_variable_from_mapping(mapping) assert isinstance(variable, ArrayObjectVariable) assert isinstance(variable.value[0], dict) assert isinstance(variable.value[1], dict) @@ -149,7 +149,7 @@ def test_array_object_variable(): def test_variable_cannot_large_than_200_kb(): with pytest.raises(VariableError): - variable_factory.build_variable_from_mapping( + variable_factory.build_conversation_variable_from_mapping( { "id": str(uuid4()), "value_type": "string", diff --git a/api/tests/unit_tests/core/workflow/nodes/test_variable_assigner.py b/api/tests/unit_tests/core/workflow/nodes/variable_assigner/v1/test_variable_assigner_v1.py similarity index 92% rename from api/tests/unit_tests/core/workflow/nodes/test_variable_assigner.py rename to api/tests/unit_tests/core/workflow/nodes/variable_assigner/v1/test_variable_assigner_v1.py index 096ae0ea52..9793da129d 100644 --- a/api/tests/unit_tests/core/workflow/nodes/test_variable_assigner.py +++ b/api/tests/unit_tests/core/workflow/nodes/variable_assigner/v1/test_variable_assigner_v1.py @@ -10,7 +10,8 @@ from core.workflow.enums import SystemVariableKey from core.workflow.graph_engine.entities.graph import Graph from core.workflow.graph_engine.entities.graph_init_params import GraphInitParams from core.workflow.graph_engine.entities.graph_runtime_state import GraphRuntimeState -from core.workflow.nodes.variable_assigner import VariableAssignerNode, WriteMode +from core.workflow.nodes.variable_assigner.v1 import VariableAssignerNode +from core.workflow.nodes.variable_assigner.v1.node_data import WriteMode from models.enums import UserFrom from models.workflow import WorkflowType @@ -84,6 +85,7 @@ def test_overwrite_string_variable(): config={ "id": "node_id", "data": { + "title": "test", "assigned_variable_selector": ["conversation", conversation_variable.name], "write_mode": WriteMode.OVER_WRITE.value, "input_variable_selector": [DEFAULT_NODE_ID, input_variable.name], @@ -91,7 +93,7 @@ def test_overwrite_string_variable(): }, ) - with mock.patch("core.workflow.nodes.variable_assigner.node.update_conversation_variable") as mock_run: + with mock.patch("core.workflow.nodes.variable_assigner.common.helpers.update_conversation_variable") as mock_run: list(node.run()) mock_run.assert_called_once() @@ -166,6 +168,7 @@ def test_append_variable_to_array(): config={ "id": "node_id", "data": { + "title": "test", "assigned_variable_selector": ["conversation", conversation_variable.name], "write_mode": WriteMode.APPEND.value, "input_variable_selector": [DEFAULT_NODE_ID, input_variable.name], @@ -173,7 +176,7 @@ def test_append_variable_to_array(): }, ) - with mock.patch("core.workflow.nodes.variable_assigner.node.update_conversation_variable") as mock_run: + with mock.patch("core.workflow.nodes.variable_assigner.common.helpers.update_conversation_variable") as mock_run: list(node.run()) mock_run.assert_called_once() @@ -237,6 +240,7 @@ def test_clear_array(): config={ "id": "node_id", "data": { + "title": "test", "assigned_variable_selector": ["conversation", conversation_variable.name], "write_mode": WriteMode.CLEAR.value, "input_variable_selector": [], @@ -244,7 +248,7 @@ def test_clear_array(): }, ) - with mock.patch("core.workflow.nodes.variable_assigner.node.update_conversation_variable") as mock_run: + with mock.patch("core.workflow.nodes.variable_assigner.common.helpers.update_conversation_variable") as mock_run: list(node.run()) mock_run.assert_called_once() diff --git a/api/tests/unit_tests/core/workflow/nodes/variable_assigner/v2/test_helpers.py b/api/tests/unit_tests/core/workflow/nodes/variable_assigner/v2/test_helpers.py new file mode 100644 index 0000000000..16c1370018 --- /dev/null +++ b/api/tests/unit_tests/core/workflow/nodes/variable_assigner/v2/test_helpers.py @@ -0,0 +1,24 @@ +import pytest + +from core.variables import SegmentType +from core.workflow.nodes.variable_assigner.v2.enums import Operation +from core.workflow.nodes.variable_assigner.v2.helpers import is_input_value_valid + + +def test_is_input_value_valid_overwrite_array_string(): + # Valid cases + assert is_input_value_valid( + variable_type=SegmentType.ARRAY_STRING, operation=Operation.OVER_WRITE, value=["hello", "world"] + ) + assert is_input_value_valid(variable_type=SegmentType.ARRAY_STRING, operation=Operation.OVER_WRITE, value=[]) + + # Invalid cases + assert not is_input_value_valid( + variable_type=SegmentType.ARRAY_STRING, operation=Operation.OVER_WRITE, value="not an array" + ) + assert not is_input_value_valid( + variable_type=SegmentType.ARRAY_STRING, operation=Operation.OVER_WRITE, value=[1, 2, 3] + ) + assert not is_input_value_valid( + variable_type=SegmentType.ARRAY_STRING, operation=Operation.OVER_WRITE, value=["valid", 123, "invalid"] + ) diff --git a/api/tests/unit_tests/models/test_conversation_variable.py b/api/tests/unit_tests/models/test_conversation_variable.py index b879afa3e7..5d84a2ec85 100644 --- a/api/tests/unit_tests/models/test_conversation_variable.py +++ b/api/tests/unit_tests/models/test_conversation_variable.py @@ -6,7 +6,7 @@ from models import ConversationVariable def test_from_variable_and_to_variable(): - variable = variable_factory.build_variable_from_mapping( + variable = variable_factory.build_conversation_variable_from_mapping( { "id": str(uuid4()), "name": "name", diff --git a/api/tests/unit_tests/models/test_workflow.py b/api/tests/unit_tests/models/test_workflow.py index 478fa8012b..fe56f18f1b 100644 --- a/api/tests/unit_tests/models/test_workflow.py +++ b/api/tests/unit_tests/models/test_workflow.py @@ -24,10 +24,18 @@ def test_environment_variables(): ) # Create some EnvironmentVariable instances - variable1 = StringVariable.model_validate({"name": "var1", "value": "value1", "id": str(uuid4())}) - variable2 = IntegerVariable.model_validate({"name": "var2", "value": 123, "id": str(uuid4())}) - variable3 = SecretVariable.model_validate({"name": "var3", "value": "secret", "id": str(uuid4())}) - variable4 = FloatVariable.model_validate({"name": "var4", "value": 3.14, "id": str(uuid4())}) + variable1 = StringVariable.model_validate( + {"name": "var1", "value": "value1", "id": str(uuid4()), "selector": ["env", "var1"]} + ) + variable2 = IntegerVariable.model_validate( + {"name": "var2", "value": 123, "id": str(uuid4()), "selector": ["env", "var2"]} + ) + variable3 = SecretVariable.model_validate( + {"name": "var3", "value": "secret", "id": str(uuid4()), "selector": ["env", "var3"]} + ) + variable4 = FloatVariable.model_validate( + {"name": "var4", "value": 3.14, "id": str(uuid4()), "selector": ["env", "var4"]} + ) with ( mock.patch("core.helper.encrypter.encrypt_token", return_value="encrypted_token"), @@ -58,10 +66,18 @@ def test_update_environment_variables(): ) # Create some EnvironmentVariable instances - variable1 = StringVariable.model_validate({"name": "var1", "value": "value1", "id": str(uuid4())}) - variable2 = IntegerVariable.model_validate({"name": "var2", "value": 123, "id": str(uuid4())}) - variable3 = SecretVariable.model_validate({"name": "var3", "value": "secret", "id": str(uuid4())}) - variable4 = FloatVariable.model_validate({"name": "var4", "value": 3.14, "id": str(uuid4())}) + variable1 = StringVariable.model_validate( + {"name": "var1", "value": "value1", "id": str(uuid4()), "selector": ["env", "var1"]} + ) + variable2 = IntegerVariable.model_validate( + {"name": "var2", "value": 123, "id": str(uuid4()), "selector": ["env", "var2"]} + ) + variable3 = SecretVariable.model_validate( + {"name": "var3", "value": "secret", "id": str(uuid4()), "selector": ["env", "var3"]} + ) + variable4 = FloatVariable.model_validate( + {"name": "var4", "value": 3.14, "id": str(uuid4()), "selector": ["env", "var4"]} + ) with ( mock.patch("core.helper.encrypter.encrypt_token", return_value="encrypted_token"), diff --git a/web/app/components/base/badge.tsx b/web/app/components/base/badge.tsx index c3300a1e67..722fde3237 100644 --- a/web/app/components/base/badge.tsx +++ b/web/app/components/base/badge.tsx @@ -15,7 +15,7 @@ const Badge = ({ return (
{ + return ( + + + + + + + + + + + ) +} + +export default HorizontalLine diff --git a/web/app/components/base/list-empty/index.tsx b/web/app/components/base/list-empty/index.tsx new file mode 100644 index 0000000000..e925878bc1 --- /dev/null +++ b/web/app/components/base/list-empty/index.tsx @@ -0,0 +1,35 @@ +import React from 'react' +import { Variable02 } from '../icons/src/vender/solid/development' +import VerticalLine from './vertical-line' +import HorizontalLine from './horizontal-line' + +type ListEmptyProps = { + title?: string + description?: React.ReactNode +} + +const ListEmpty = ({ + title, + description, +}: ListEmptyProps) => { + return ( +
+
+
+ + + + + +
+
+
+
{title}
+ {description} +
+
+ ) +} + +export default ListEmpty diff --git a/web/app/components/base/list-empty/vertical-line.tsx b/web/app/components/base/list-empty/vertical-line.tsx new file mode 100644 index 0000000000..63e57447bf --- /dev/null +++ b/web/app/components/base/list-empty/vertical-line.tsx @@ -0,0 +1,21 @@ +type VerticalLineProps = { + className?: string +} +const VerticalLine = ({ + className, +}: VerticalLineProps) => { + return ( + + + + + + + + + + + ) +} + +export default VerticalLine diff --git a/web/app/components/workflow/block-icon.tsx b/web/app/components/workflow/block-icon.tsx index b115a7b3c3..1001e981c5 100644 --- a/web/app/components/workflow/block-icon.tsx +++ b/web/app/components/workflow/block-icon.tsx @@ -48,6 +48,7 @@ const getIcon = (type: BlockEnum, className: string) => { [BlockEnum.VariableAggregator]: , [BlockEnum.Assigner]: , [BlockEnum.Tool]: , + [BlockEnum.IterationStart]: , [BlockEnum.Iteration]: , [BlockEnum.ParameterExtractor]: , [BlockEnum.DocExtractor]: , diff --git a/web/app/components/workflow/nodes/_base/components/editor/code-editor/index.tsx b/web/app/components/workflow/nodes/_base/components/editor/code-editor/index.tsx index 28d07936d3..2d75679b08 100644 --- a/web/app/components/workflow/nodes/_base/components/editor/code-editor/index.tsx +++ b/web/app/components/workflow/nodes/_base/components/editor/code-editor/index.tsx @@ -33,6 +33,7 @@ export type Props = { showFileList?: boolean onGenerated?: (value: string) => void showCodeGenerator?: boolean + className?: string } export const languageMap = { @@ -67,6 +68,7 @@ const CodeEditor: FC = ({ showFileList, onGenerated, showCodeGenerator = false, + className, }) => { const [isFocus, setIsFocus] = React.useState(false) const [isMounted, setIsMounted] = React.useState(false) @@ -187,7 +189,7 @@ const CodeEditor: FC = ({ ) return ( -
+
{noWrapper ?
= ({ triggerClassName='w-4 h-4 ml-1' /> )} -
{operations &&
{operations}
} diff --git a/web/app/components/workflow/nodes/_base/components/list-no-data-placeholder.tsx b/web/app/components/workflow/nodes/_base/components/list-no-data-placeholder.tsx index 4ec9d27f50..bf592deaec 100644 --- a/web/app/components/workflow/nodes/_base/components/list-no-data-placeholder.tsx +++ b/web/app/components/workflow/nodes/_base/components/list-no-data-placeholder.tsx @@ -10,7 +10,7 @@ const ListNoDataPlaceholder: FC = ({ children, }) => { return ( -
+
{children}
) diff --git a/web/app/components/workflow/nodes/_base/components/variable/assigned-var-reference-popup.tsx b/web/app/components/workflow/nodes/_base/components/variable/assigned-var-reference-popup.tsx new file mode 100644 index 0000000000..9ad5ad4a5a --- /dev/null +++ b/web/app/components/workflow/nodes/_base/components/variable/assigned-var-reference-popup.tsx @@ -0,0 +1,39 @@ +'use client' +import type { FC } from 'react' +import React from 'react' +import { useTranslation } from 'react-i18next' +import VarReferenceVars from './var-reference-vars' +import type { NodeOutPutVar, ValueSelector, Var } from '@/app/components/workflow/types' +import ListEmpty from '@/app/components/base/list-empty' + +type Props = { + vars: NodeOutPutVar[] + onChange: (value: ValueSelector, varDetail: Var) => void + itemWidth?: number +} +const AssignedVarReferencePopup: FC = ({ + vars, + onChange, + itemWidth, +}) => { + const { t } = useTranslation() + // max-h-[300px] overflow-y-auto todo: use portal to handle long list + return ( +
+ {(!vars || vars.length === 0) + ? + : + } +
+ ) +} +export default React.memo(AssignedVarReferencePopup) diff --git a/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.tsx b/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.tsx index 0c553a2738..e4d354a615 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.tsx @@ -60,6 +60,9 @@ type Props = { onRemove?: () => void typePlaceHolder?: string isSupportFileVar?: boolean + placeholder?: string + minWidth?: number + popupFor?: 'assigned' | 'toAssigned' } const VarReferencePicker: FC = ({ @@ -83,6 +86,9 @@ const VarReferencePicker: FC = ({ onRemove, typePlaceHolder, isSupportFileVar = true, + placeholder, + minWidth, + popupFor, }) => { const { t } = useTranslation() const store = useStoreApi() @@ -261,7 +267,7 @@ const VarReferencePicker: FC = ({ { }}>
) - : (
+ : (
{isSupportConstantValue ?
{ e.stopPropagation() @@ -285,7 +291,7 @@ const VarReferencePicker: FC = ({ />
: (!hasValue &&
- +
)} {isConstant ? ( @@ -329,17 +335,17 @@ const VarReferencePicker: FC = ({ {!hasValue && } {isEnv && } {isChatVar && } -
{varName}
-
{type}
{!isValidVar && } ) - :
{t('workflow.common.setVarValuePlaceholder')}
} + :
{placeholder ?? t('workflow.common.setVarValuePlaceholder')}
}
@@ -378,12 +384,13 @@ const VarReferencePicker: FC = ({ + }} className='mt-1'> {!isConstant && ( )} diff --git a/web/app/components/workflow/nodes/_base/components/variable/var-reference-popup.tsx b/web/app/components/workflow/nodes/_base/components/variable/var-reference-popup.tsx index cd03da1556..d9a4d2c946 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/var-reference-popup.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/var-reference-popup.tsx @@ -1,33 +1,64 @@ 'use client' import type { FC } from 'react' import React from 'react' +import { useTranslation } from 'react-i18next' +import { useContext } from 'use-context-selector' import VarReferenceVars from './var-reference-vars' import type { NodeOutPutVar, ValueSelector, Var } from '@/app/components/workflow/types' +import ListEmpty from '@/app/components/base/list-empty' +import { LanguagesSupported } from '@/i18n/language' +import I18n from '@/context/i18n' type Props = { vars: NodeOutPutVar[] + popupFor?: 'assigned' | 'toAssigned' onChange: (value: ValueSelector, varDetail: Var) => void itemWidth?: number isSupportFileVar?: boolean } const VarReferencePopup: FC = ({ vars, + popupFor, onChange, itemWidth, isSupportFileVar = true, }) => { + const { t } = useTranslation() + const { locale } = useContext(I18n) // max-h-[300px] overflow-y-auto todo: use portal to handle long list return (
- + {((!vars || vars.length === 0) && popupFor) + ? (popupFor === 'toAssigned' + ? ( + + {t('workflow.variableReference.noVarsForOperation')} +
} + /> + ) + : ( + + {t('workflow.variableReference.assignedVarsDescription')} + {t('workflow.variableReference.conversationVars')} +
} + /> + )) + : + }
) } diff --git a/web/app/components/workflow/nodes/_base/hooks/use-one-step-run.ts b/web/app/components/workflow/nodes/_base/hooks/use-one-step-run.ts index c500f0c8cf..6791a2f746 100644 --- a/web/app/components/workflow/nodes/_base/hooks/use-one-step-run.ts +++ b/web/app/components/workflow/nodes/_base/hooks/use-one-step-run.ts @@ -24,6 +24,7 @@ import QuestionClassifyDefault from '@/app/components/workflow/nodes/question-cl import HTTPDefault from '@/app/components/workflow/nodes/http/default' import ToolDefault from '@/app/components/workflow/nodes/tool/default' import VariableAssigner from '@/app/components/workflow/nodes/variable-assigner/default' +import Assigner from '@/app/components/workflow/nodes/assigner/default' import ParameterExtractorDefault from '@/app/components/workflow/nodes/parameter-extractor/default' import IterationDefault from '@/app/components/workflow/nodes/iteration/default' import { ssePost } from '@/service/base' @@ -39,6 +40,7 @@ const { checkValid: checkQuestionClassifyValid } = QuestionClassifyDefault const { checkValid: checkHttpValid } = HTTPDefault const { checkValid: checkToolValid } = ToolDefault const { checkValid: checkVariableAssignerValid } = VariableAssigner +const { checkValid: checkAssignerValid } = Assigner const { checkValid: checkParameterExtractorValid } = ParameterExtractorDefault const { checkValid: checkIterationValid } = IterationDefault @@ -51,7 +53,7 @@ const checkValidFns: Record = { [BlockEnum.QuestionClassifier]: checkQuestionClassifyValid, [BlockEnum.HttpRequest]: checkHttpValid, [BlockEnum.Tool]: checkToolValid, - [BlockEnum.VariableAssigner]: checkVariableAssignerValid, + [BlockEnum.VariableAssigner]: checkAssignerValid, [BlockEnum.VariableAggregator]: checkVariableAssignerValid, [BlockEnum.ParameterExtractor]: checkParameterExtractorValid, [BlockEnum.Iteration]: checkIterationValid, diff --git a/web/app/components/workflow/nodes/assigner/components/operation-selector.tsx b/web/app/components/workflow/nodes/assigner/components/operation-selector.tsx new file mode 100644 index 0000000000..8542bb4829 --- /dev/null +++ b/web/app/components/workflow/nodes/assigner/components/operation-selector.tsx @@ -0,0 +1,128 @@ +import type { FC } from 'react' +import { useState } from 'react' +import { + RiArrowDownSLine, + RiCheckLine, +} from '@remixicon/react' +import classNames from 'classnames' +import { useTranslation } from 'react-i18next' +import type { WriteMode } from '../types' +import { getOperationItems } from '../utils' +import { + PortalToFollowElem, + PortalToFollowElemContent, + PortalToFollowElemTrigger, +} from '@/app/components/base/portal-to-follow-elem' +import type { VarType } from '@/app/components/workflow/types' +import Divider from '@/app/components/base/divider' + +type Item = { + value: string | number + name: string +} + +type OperationSelectorProps = { + value: string | number + onSelect: (value: Item) => void + placeholder?: string + disabled?: boolean + className?: string + popupClassName?: string + assignedVarType?: VarType + writeModeTypes?: WriteMode[] + writeModeTypesArr?: WriteMode[] + writeModeTypesNum?: WriteMode[] +} + +const i18nPrefix = 'workflow.nodes.assigner' + +const OperationSelector: FC = ({ + value, + onSelect, + disabled = false, + className, + popupClassName, + assignedVarType, + writeModeTypes, + writeModeTypesArr, + writeModeTypesNum, +}) => { + const { t } = useTranslation() + const [open, setOpen] = useState(false) + + const items = getOperationItems(assignedVarType, writeModeTypes, writeModeTypesArr, writeModeTypesNum) + + const selectedItem = items.find(item => item.value === value) + + return ( + + !disabled && setOpen(v => !v)} + > +
+
+ + {selectedItem?.name ? t(`${i18nPrefix}.operations.${selectedItem?.name}`) : t(`${i18nPrefix}.operations.title`)} + +
+ +
+
+ + +
+
+
+
{t(`${i18nPrefix}.operations.title`)}
+
+ {items.map(item => ( + item.value === 'divider' + ? ( + + ) + : ( +
{ + onSelect(item) + setOpen(false) + }} + > +
+ {t(`${i18nPrefix}.operations.${item.name}`)} +
+ {item.value === value && ( +
+ +
+ )} +
+ ) + ))} +
+
+
+
+ ) +} + +export default OperationSelector diff --git a/web/app/components/workflow/nodes/assigner/components/var-list/index.tsx b/web/app/components/workflow/nodes/assigner/components/var-list/index.tsx new file mode 100644 index 0000000000..42ee9845dd --- /dev/null +++ b/web/app/components/workflow/nodes/assigner/components/var-list/index.tsx @@ -0,0 +1,227 @@ +'use client' +import type { FC } from 'react' +import { useTranslation } from 'react-i18next' +import React, { useCallback } from 'react' +import produce from 'immer' +import { RiDeleteBinLine } from '@remixicon/react' +import OperationSelector from '../operation-selector' +import { AssignerNodeInputType, WriteMode } from '../../types' +import type { AssignerNodeOperation } from '../../types' +import ListNoDataPlaceholder from '@/app/components/workflow/nodes/_base/components/list-no-data-placeholder' +import VarReferencePicker from '@/app/components/workflow/nodes/_base/components/variable/var-reference-picker' +import type { ValueSelector, Var, VarType } from '@/app/components/workflow/types' +import { CodeLanguage } from '@/app/components/workflow/nodes/code/types' +import ActionButton from '@/app/components/base/action-button' +import Input from '@/app/components/base/input' +import Textarea from '@/app/components/base/textarea' +import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor' + +type Props = { + readonly: boolean + nodeId: string + list: AssignerNodeOperation[] + onChange: (list: AssignerNodeOperation[], value?: ValueSelector) => void + onOpen?: (index: number) => void + filterVar?: (payload: Var, valueSelector: ValueSelector) => boolean + filterToAssignedVar?: (payload: Var, assignedVarType: VarType, write_mode: WriteMode) => boolean + getAssignedVarType?: (valueSelector: ValueSelector) => VarType + getToAssignedVarType?: (assignedVarType: VarType, write_mode: WriteMode) => VarType + writeModeTypes?: WriteMode[] + writeModeTypesArr?: WriteMode[] + writeModeTypesNum?: WriteMode[] +} + +const VarList: FC = ({ + readonly, + nodeId, + list, + onChange, + onOpen = () => { }, + filterVar, + filterToAssignedVar, + getAssignedVarType, + getToAssignedVarType, + writeModeTypes, + writeModeTypesArr, + writeModeTypesNum, +}) => { + const { t } = useTranslation() + const handleAssignedVarChange = useCallback((index: number) => { + return (value: ValueSelector | string) => { + const newList = produce(list, (draft) => { + draft[index].variable_selector = value as ValueSelector + draft[index].operation = WriteMode.overwrite + draft[index].value = undefined + }) + onChange(newList, value as ValueSelector) + } + }, [list, onChange]) + + const handleOperationChange = useCallback((index: number) => { + return (item: { value: string | number }) => { + const newList = produce(list, (draft) => { + draft[index].operation = item.value as WriteMode + draft[index].value = '' // Clear value when operation changes + if (item.value === WriteMode.set || item.value === WriteMode.increment || item.value === WriteMode.decrement + || item.value === WriteMode.multiply || item.value === WriteMode.divide) + draft[index].input_type = AssignerNodeInputType.constant + else + draft[index].input_type = AssignerNodeInputType.variable + }) + onChange(newList) + } + }, [list, onChange]) + + const handleToAssignedVarChange = useCallback((index: number) => { + return (value: ValueSelector | string | number) => { + const newList = produce(list, (draft) => { + draft[index].value = value as ValueSelector + }) + onChange(newList, value as ValueSelector) + } + }, [list, onChange]) + + const handleVarRemove = useCallback((index: number) => { + return () => { + const newList = produce(list, (draft) => { + draft.splice(index, 1) + }) + onChange(newList) + } + }, [list, onChange]) + + const handleOpen = useCallback((index: number) => { + return () => onOpen(index) + }, [onOpen]) + + const handleFilterToAssignedVar = useCallback((index: number) => { + return (payload: Var, valueSelector: ValueSelector) => { + const item = list[index] + const assignedVarType = item.variable_selector ? getAssignedVarType?.(item.variable_selector) : undefined + + if (!filterToAssignedVar || !item.variable_selector || !assignedVarType || !item.operation) + return true + + return filterToAssignedVar( + payload, + assignedVarType, + item.operation, + ) + } + }, [list, filterToAssignedVar, getAssignedVarType]) + + if (list.length === 0) { + return ( + + {t('workflow.nodes.assigner.noVarTip')} + + ) + } + + return ( +
+ {list.map((item, index) => { + const assignedVarType = item.variable_selector ? getAssignedVarType?.(item.variable_selector) : undefined + const toAssignedVarType = (assignedVarType && item.operation && getToAssignedVarType) + ? getToAssignedVarType(assignedVarType, item.operation) + : undefined + + return ( +
+
+
+ + +
+ {item.operation !== WriteMode.clear && item.operation !== WriteMode.set + && !writeModeTypesNum?.includes(item.operation) + && ( + + ) + } + {item.operation === WriteMode.set && assignedVarType && ( + <> + {assignedVarType === 'number' && ( + handleToAssignedVarChange(index)(Number(e.target.value))} + className='w-full' + /> + )} + {assignedVarType === 'string' && ( +