Feat: upgrade variable assigner (#11285)

Signed-off-by: -LAN- <laipz8200@outlook.com>
Co-authored-by: -LAN- <laipz8200@outlook.com>
This commit is contained in:
Yi Xiao 2024-12-03 13:56:40 +08:00 committed by GitHub
parent e79eac688a
commit e135ffc2c1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
62 changed files with 1565 additions and 301 deletions

View File

@ -100,11 +100,11 @@ class DraftWorkflowApi(Resource):
try: try:
environment_variables_list = args.get("environment_variables") or [] environment_variables_list = args.get("environment_variables") or []
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 = args.get("conversation_variables") or [] conversation_variables_list = args.get("conversation_variables") or []
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 = workflow_service.sync_draft_workflow( workflow = workflow_service.sync_draft_workflow(
app_model=app_model, app_model=app_model,
@ -382,7 +382,7 @@ class DefaultBlockConfigApi(Resource):
filters = None filters = None
if args.get("q"): if args.get("q"):
try: try:
filters = json.loads(args.get("q")) filters = json.loads(args.get("q", ""))
except json.JSONDecodeError: except json.JSONDecodeError:
raise ValueError("Invalid filters") raise ValueError("Invalid filters")

View File

@ -43,7 +43,7 @@ from core.workflow.graph_engine.entities.event import (
) )
from core.workflow.graph_engine.entities.graph import Graph from core.workflow.graph_engine.entities.graph import Graph
from core.workflow.nodes import NodeType 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 core.workflow.workflow_entry import WorkflowEntry
from extensions.ext_database import db from extensions.ext_database import db
from models.model import App from models.model import App
@ -138,7 +138,8 @@ class WorkflowBasedAppRunner(AppRunner):
# Get node class # Get node class
node_type = NodeType(iteration_node_config.get("data", {}).get("type")) 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 # init variable pool
variable_pool = VariablePool( variable_pool = VariablePool(

View File

@ -2,16 +2,19 @@ from enum import StrEnum
class SegmentType(StrEnum): class SegmentType(StrEnum):
NONE = "none"
NUMBER = "number" NUMBER = "number"
STRING = "string" STRING = "string"
OBJECT = "object"
SECRET = "secret" SECRET = "secret"
FILE = "file"
ARRAY_ANY = "array[any]" ARRAY_ANY = "array[any]"
ARRAY_STRING = "array[string]" ARRAY_STRING = "array[string]"
ARRAY_NUMBER = "array[number]" ARRAY_NUMBER = "array[number]"
ARRAY_OBJECT = "array[object]" ARRAY_OBJECT = "array[object]"
OBJECT = "object"
FILE = "file"
ARRAY_FILE = "array[file]" ARRAY_FILE = "array[file]"
NONE = "none"
GROUP = "group" GROUP = "group"

View File

@ -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.base import BaseNode
from core.workflow.nodes.end.end_stream_processor import EndStreamProcessor from core.workflow.nodes.end.end_stream_processor import EndStreamProcessor
from core.workflow.nodes.event import RunCompletedEvent, RunRetrieverResourceEvent, RunStreamChunkEvent 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 extensions.ext_database import db
from models.enums import UserFrom from models.enums import UserFrom
from models.workflow import WorkflowNodeExecutionStatus, WorkflowType from models.workflow import WorkflowNodeExecutionStatus, WorkflowType
@ -227,7 +227,8 @@ class GraphEngine:
# convert to specific node # convert to specific node
node_type = NodeType(node_config.get("data", {}).get("type")) 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 previous_node_id = previous_route_node_state.node_id if previous_route_node_state else None

View File

@ -153,7 +153,7 @@ class AnswerStreamGeneratorRouter:
NodeType.IF_ELSE, NodeType.IF_ELSE,
NodeType.QUESTION_CLASSIFIER, NodeType.QUESTION_CLASSIFIER,
NodeType.ITERATION, NodeType.ITERATION,
NodeType.CONVERSATION_VARIABLE_ASSIGNER, NodeType.VARIABLE_ASSIGNER,
}: }:
answer_dependencies[answer_node_id].append(source_node_id) answer_dependencies[answer_node_id].append(source_node_id)
else: else:

View File

@ -7,6 +7,7 @@ from pydantic import BaseModel
class BaseNodeData(ABC, BaseModel): class BaseNodeData(ABC, BaseModel):
title: str title: str
desc: Optional[str] = None desc: Optional[str] = None
version: str = "1"
class BaseIterationNodeData(BaseNodeData): class BaseIterationNodeData(BaseNodeData):

View File

@ -55,7 +55,9 @@ class BaseNode(Generic[GenericNodeData]):
raise ValueError("Node ID is required.") raise ValueError("Node ID is required.")
self.node_id = node_id 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 @abstractmethod
def _run(self) -> NodeRunResult | Generator[Union[NodeEvent, "InNodeEvent"], None, None]: def _run(self) -> NodeRunResult | Generator[Union[NodeEvent, "InNodeEvent"], None, None]:

View File

@ -14,11 +14,11 @@ class NodeType(StrEnum):
HTTP_REQUEST = "http-request" HTTP_REQUEST = "http-request"
TOOL = "tool" TOOL = "tool"
VARIABLE_AGGREGATOR = "variable-aggregator" 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" LOOP = "loop"
ITERATION = "iteration" ITERATION = "iteration"
ITERATION_START = "iteration-start" # Fake start node for iteration. ITERATION_START = "iteration-start" # Fake start node for iteration.
PARAMETER_EXTRACTOR = "parameter-extractor" PARAMETER_EXTRACTOR = "parameter-extractor"
CONVERSATION_VARIABLE_ASSIGNER = "assigner" VARIABLE_ASSIGNER = "assigner"
DOCUMENT_EXTRACTOR = "document-extractor" DOCUMENT_EXTRACTOR = "document-extractor"
LIST_OPERATOR = "list-operator" LIST_OPERATOR = "list-operator"

View File

@ -298,12 +298,13 @@ class IterationNode(BaseNode[IterationNodeData]):
# variable selector to variable mapping # variable selector to variable mapping
try: try:
# Get node class # 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_type = NodeType(sub_node_config.get("data", {}).get("type"))
node_cls = node_type_classes_mapping.get(node_type) if node_type not in NODE_TYPE_CLASSES_MAPPING:
if not node_cls:
continue 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( sub_node_variable_mapping = node_cls.extract_variable_selector_to_variable_mapping(
graph_config=graph_config, config=sub_node_config graph_config=graph_config, config=sub_node_config

View File

@ -1,3 +1,5 @@
from collections.abc import Mapping
from core.workflow.nodes.answer import AnswerNode from core.workflow.nodes.answer import AnswerNode
from core.workflow.nodes.base import BaseNode from core.workflow.nodes.base import BaseNode
from core.workflow.nodes.code import CodeNode 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.template_transform import TemplateTransformNode
from core.workflow.nodes.tool import ToolNode from core.workflow.nodes.tool import ToolNode
from core.workflow.nodes.variable_aggregator import VariableAggregatorNode 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]] = { LATEST_VERSION = "latest"
NodeType.START: StartNode,
NodeType.END: EndNode, NODE_TYPE_CLASSES_MAPPING: Mapping[NodeType, Mapping[str, type[BaseNode]]] = {
NodeType.ANSWER: AnswerNode, NodeType.START: {
NodeType.LLM: LLMNode, LATEST_VERSION: StartNode,
NodeType.KNOWLEDGE_RETRIEVAL: KnowledgeRetrievalNode, "1": StartNode,
NodeType.IF_ELSE: IfElseNode, },
NodeType.CODE: CodeNode, NodeType.END: {
NodeType.TEMPLATE_TRANSFORM: TemplateTransformNode, LATEST_VERSION: EndNode,
NodeType.QUESTION_CLASSIFIER: QuestionClassifierNode, "1": EndNode,
NodeType.HTTP_REQUEST: HttpRequestNode, },
NodeType.TOOL: ToolNode, NodeType.ANSWER: {
NodeType.VARIABLE_AGGREGATOR: VariableAggregatorNode, LATEST_VERSION: AnswerNode,
NodeType.VARIABLE_ASSIGNER: VariableAggregatorNode, # original name of VARIABLE_AGGREGATOR "1": AnswerNode,
NodeType.ITERATION: IterationNode, },
NodeType.ITERATION_START: IterationStartNode, NodeType.LLM: {
NodeType.PARAMETER_EXTRACTOR: ParameterExtractorNode, LATEST_VERSION: LLMNode,
NodeType.CONVERSATION_VARIABLE_ASSIGNER: VariableAssignerNode, "1": LLMNode,
NodeType.DOCUMENT_EXTRACTOR: DocumentExtractorNode, },
NodeType.LIST_OPERATOR: ListOperatorNode, 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,
},
} }

View File

@ -1,8 +0,0 @@
from .node import VariableAssignerNode
from .node_data import VariableAssignerData, WriteMode
__all__ = [
"VariableAssignerData",
"VariableAssignerNode",
"WriteMode",
]

View File

@ -0,0 +1,4 @@
class VariableOperatorNodeError(Exception):
"""Base error type, don't use directly."""
pass

View File

@ -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()

View File

@ -1,2 +0,0 @@
class VariableAssignerNodeError(Exception):
pass

View File

@ -0,0 +1,3 @@
from .node import VariableAssignerNode
__all__ = ["VariableAssignerNode"]

View File

@ -1,40 +1,36 @@
from sqlalchemy import select
from sqlalchemy.orm import Session
from core.variables import SegmentType, Variable from core.variables import SegmentType, Variable
from core.workflow.entities.node_entities import NodeRunResult from core.workflow.entities.node_entities import NodeRunResult
from core.workflow.nodes.base import BaseNode, BaseNodeData from core.workflow.nodes.base import BaseNode, BaseNodeData
from core.workflow.nodes.enums import NodeType 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 factories import variable_factory
from models import ConversationVariable
from models.workflow import WorkflowNodeExecutionStatus from models.workflow import WorkflowNodeExecutionStatus
from .exc import VariableAssignerNodeError
from .node_data import VariableAssignerData, WriteMode from .node_data import VariableAssignerData, WriteMode
class VariableAssignerNode(BaseNode[VariableAssignerData]): class VariableAssignerNode(BaseNode[VariableAssignerData]):
_node_data_cls: type[BaseNodeData] = VariableAssignerData _node_data_cls: type[BaseNodeData] = VariableAssignerData
_node_type: NodeType = NodeType.CONVERSATION_VARIABLE_ASSIGNER _node_type = NodeType.VARIABLE_ASSIGNER
def _run(self) -> NodeRunResult: def _run(self) -> NodeRunResult:
# Should be String, Number, Object, ArrayString, ArrayNumber, ArrayObject # Should be String, Number, Object, ArrayString, ArrayNumber, ArrayObject
original_variable = self.graph_runtime_state.variable_pool.get(self.node_data.assigned_variable_selector) original_variable = self.graph_runtime_state.variable_pool.get(self.node_data.assigned_variable_selector)
if not isinstance(original_variable, Variable): if not isinstance(original_variable, Variable):
raise VariableAssignerNodeError("assigned variable not found") raise VariableOperatorNodeError("assigned variable not found")
match self.node_data.write_mode: match self.node_data.write_mode:
case WriteMode.OVER_WRITE: case WriteMode.OVER_WRITE:
income_value = self.graph_runtime_state.variable_pool.get(self.node_data.input_variable_selector) income_value = self.graph_runtime_state.variable_pool.get(self.node_data.input_variable_selector)
if not income_value: 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}) updated_variable = original_variable.model_copy(update={"value": income_value.value})
case WriteMode.APPEND: case WriteMode.APPEND:
income_value = self.graph_runtime_state.variable_pool.get(self.node_data.input_variable_selector) income_value = self.graph_runtime_state.variable_pool.get(self.node_data.input_variable_selector)
if not income_value: 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_value = original_variable.value + [income_value.value]
updated_variable = original_variable.model_copy(update={"value": updated_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()}) updated_variable = original_variable.model_copy(update={"value": income_value.to_object()})
case _: 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. # Over write the variable.
self.graph_runtime_state.variable_pool.add(self.node_data.assigned_variable_selector, updated_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. # Update conversation variable.
conversation_id = self.graph_runtime_state.variable_pool.get(["sys", "conversation_id"]) conversation_id = self.graph_runtime_state.variable_pool.get(["sys", "conversation_id"])
if not conversation_id: if not conversation_id:
raise VariableAssignerNodeError("conversation_id not found") raise VariableOperatorNodeError("conversation_id not found")
update_conversation_variable(conversation_id=conversation_id.text, variable=updated_variable) common_helpers.update_conversation_variable(conversation_id=conversation_id.text, variable=updated_variable)
return NodeRunResult( return NodeRunResult(
status=WorkflowNodeExecutionStatus.SUCCEEDED, 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): def get_zero_value(t: SegmentType):
match t: match t:
case SegmentType.ARRAY_OBJECT | SegmentType.ARRAY_STRING | SegmentType.ARRAY_NUMBER: case SegmentType.ARRAY_OBJECT | SegmentType.ARRAY_STRING | SegmentType.ARRAY_NUMBER:
@ -86,4 +70,4 @@ def get_zero_value(t: SegmentType):
case SegmentType.NUMBER: case SegmentType.NUMBER:
return variable_factory.build_segment(0) return variable_factory.build_segment(0)
case _: case _:
raise VariableAssignerNodeError(f"unsupported variable type: {t}") raise VariableOperatorNodeError(f"unsupported variable type: {t}")

View File

@ -1,6 +1,5 @@
from collections.abc import Sequence from collections.abc import Sequence
from enum import StrEnum from enum import StrEnum
from typing import Optional
from core.workflow.nodes.base import BaseNodeData from core.workflow.nodes.base import BaseNodeData
@ -12,8 +11,6 @@ class WriteMode(StrEnum):
class VariableAssignerData(BaseNodeData): class VariableAssignerData(BaseNodeData):
title: str = "Variable Assigner"
desc: Optional[str] = "Assign a value to a variable"
assigned_variable_selector: Sequence[str] assigned_variable_selector: Sequence[str]
write_mode: WriteMode write_mode: WriteMode
input_variable_selector: Sequence[str] input_variable_selector: Sequence[str]

View File

@ -0,0 +1,3 @@
from .node import VariableAssignerNode
__all__ = ["VariableAssignerNode"]

View File

@ -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: [],
}

View File

@ -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]

View File

@ -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"

View File

@ -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")

View File

@ -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

View File

@ -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)

View File

@ -2,7 +2,7 @@ import logging
import time import time
import uuid import uuid
from collections.abc import Generator, Mapping, Sequence from collections.abc import Generator, Mapping, Sequence
from typing import Any, Optional, cast from typing import Any, Optional
from configs import dify_config from configs import dify_config
from core.app.apps.base_app_queue_manager import GenerateTaskStoppedError 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 import NodeType
from core.workflow.nodes.base import BaseNode from core.workflow.nodes.base import BaseNode
from core.workflow.nodes.event import NodeEvent 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 factories import file_factory
from models.enums import UserFrom from models.enums import UserFrom
from models.workflow import ( from models.workflow import (
@ -145,11 +145,8 @@ class WorkflowEntry:
# Get node class # Get node class
node_type = NodeType(node_config.get("data", {}).get("type")) node_type = NodeType(node_config.get("data", {}).get("type"))
node_cls = node_type_classes_mapping.get(node_type) node_version = node_config.get("data", {}).get("version", "1")
node_cls = cast(type[BaseNode], node_cls) node_cls = NODE_TYPE_CLASSES_MAPPING[node_type][node_version]
if not node_cls:
raise ValueError(f"Node class not found for node type {node_type}")
# init variable pool # init variable pool
variable_pool = VariablePool(environment_variables=workflow.environment_variables) variable_pool = VariablePool(environment_variables=workflow.environment_variables)

View File

@ -36,6 +36,7 @@ from core.variables.variables import (
StringVariable, StringVariable,
Variable, Variable,
) )
from core.workflow.constants import CONVERSATION_VARIABLE_NODE_ID, ENVIRONMENT_VARIABLE_NODE_ID
class InvalidSelectorError(ValueError): class InvalidSelectorError(ValueError):
@ -62,11 +63,25 @@ SEGMENT_TO_VARIABLE_MAP = {
} }
def build_variable_from_mapping(mapping: Mapping[str, Any], /) -> Variable: def build_conversation_variable_from_mapping(mapping: Mapping[str, Any], /) -> Variable:
if (value_type := mapping.get("value_type")) is None:
raise VariableError("missing value type")
if not mapping.get("name"): if not mapping.get("name"):
raise VariableError("missing 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: if (value := mapping.get("value")) is None:
raise VariableError("missing value") raise VariableError("missing value")
match value_type: 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}") raise VariableError(f"not supported value type {value_type}")
if result.size > dify_config.MAX_VARIABLE_SIZE: if result.size > dify_config.MAX_VARIABLE_SIZE:
raise VariableError(f"variable size {result.size} exceeds limit {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 return result

View File

@ -238,7 +238,9 @@ class Workflow(db.Model):
tenant_id = contexts.tenant_id.get() tenant_id = contexts.tenant_id.get()
environment_variables_dict: dict[str, Any] = json.loads(self._environment_variables) 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 secret variables value
decrypt_func = ( decrypt_func = (
@ -303,7 +305,7 @@ class Workflow(db.Model):
self._conversation_variables = "{}" self._conversation_variables = "{}"
variables_dict: dict[str, Any] = json.loads(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 return results
@conversation_variables.setter @conversation_variables.setter
@ -793,4 +795,4 @@ class ConversationVariable(db.Model):
def to_variable(self) -> Variable: def to_variable(self) -> Variable:
mapping = json.loads(self.data) mapping = json.loads(self.data)
return variable_factory.build_variable_from_mapping(mapping) return variable_factory.build_conversation_variable_from_mapping(mapping)

View File

@ -387,11 +387,11 @@ class AppDslService:
environment_variables_list = workflow_data.get("environment_variables", []) environment_variables_list = workflow_data.get("environment_variables", [])
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_list = workflow_data.get("conversation_variables", [])
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() workflow_service = WorkflowService()

View File

@ -12,7 +12,7 @@ from core.workflow.entities.node_entities import NodeRunResult
from core.workflow.errors import WorkflowNodeRunFailedError from core.workflow.errors import WorkflowNodeRunFailedError
from core.workflow.nodes import NodeType from core.workflow.nodes import NodeType
from core.workflow.nodes.event import RunCompletedEvent 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 core.workflow.workflow_entry import WorkflowEntry
from events.app_event import app_draft_workflow_was_synced, app_published_workflow_was_updated from events.app_event import app_draft_workflow_was_synced, app_published_workflow_was_updated
from extensions.ext_database import db from extensions.ext_database import db
@ -176,7 +176,8 @@ class WorkflowService:
""" """
# return default block config # return default block config
default_block_configs = [] 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() default_config = node_class.get_default_config()
if default_config: if default_config:
default_block_configs.append(default_config) default_block_configs.append(default_config)
@ -190,13 +191,13 @@ class WorkflowService:
:param filters: filter by node config parameters. :param filters: filter by node config parameters.
:return: :return:
""" """
node_type_enum: NodeType = NodeType(node_type) node_type_enum = NodeType(node_type)
# return default block config # return default block config
node_class = node_type_classes_mapping.get(node_type_enum) if node_type_enum not in NODE_TYPE_CLASSES_MAPPING:
if not node_class:
return None return None
node_class = NODE_TYPE_CLASSES_MAPPING[node_type_enum][LATEST_VERSION]
default_config = node_class.get_default_config(filters=filters) default_config = node_class.get_default_config(filters=filters)
if not default_config: if not default_config:
return None return None

View File

@ -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_openapi import AnalyticdbVectorOpenAPIConfig
from core.rag.datasource.vdb.analyticdb.analyticdb_vector_sql import AnalyticdbVectorBySqlConfig from core.rag.datasource.vdb.analyticdb.analyticdb_vector_sql import AnalyticdbVectorBySqlConfig
from tests.integration_tests.vdb.test_vector_store import AbstractVectorTest, setup_mock_redis from tests.integration_tests.vdb.test_vector_store import AbstractVectorTest, setup_mock_redis

View File

@ -19,36 +19,36 @@ from factories import variable_factory
def test_string_variable(): def test_string_variable():
test_data = {"value_type": "string", "name": "test_text", "value": "Hello, World!"} 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) assert isinstance(result, StringVariable)
def test_integer_variable(): def test_integer_variable():
test_data = {"value_type": "number", "name": "test_int", "value": 42} 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) assert isinstance(result, IntegerVariable)
def test_float_variable(): def test_float_variable():
test_data = {"value_type": "number", "name": "test_float", "value": 3.14} 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) assert isinstance(result, FloatVariable)
def test_secret_variable(): def test_secret_variable():
test_data = {"value_type": "secret", "name": "test_secret", "value": "secret_value"} 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) assert isinstance(result, SecretVariable)
def test_invalid_value_type(): def test_invalid_value_type():
test_data = {"value_type": "unknown", "name": "test_invalid", "value": "value"} test_data = {"value_type": "unknown", "name": "test_invalid", "value": "value"}
with pytest.raises(VariableError): 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(): def test_build_a_blank_string():
result = variable_factory.build_variable_from_mapping( result = variable_factory.build_conversation_variable_from_mapping(
{ {
"value_type": "string", "value_type": "string",
"name": "blank", "name": "blank",
@ -80,7 +80,7 @@ def test_object_variable():
"key2": 2, "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, ObjectSegment)
assert isinstance(variable.value["key1"], str) assert isinstance(variable.value["key1"], str)
assert isinstance(variable.value["key2"], int) assert isinstance(variable.value["key2"], int)
@ -97,7 +97,7 @@ def test_array_string_variable():
"text", "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, ArrayStringVariable)
assert isinstance(variable.value[0], str) assert isinstance(variable.value[0], str)
assert isinstance(variable.value[1], str) assert isinstance(variable.value[1], str)
@ -114,7 +114,7 @@ def test_array_number_variable():
2.0, 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, ArrayNumberVariable)
assert isinstance(variable.value[0], int) assert isinstance(variable.value[0], int)
assert isinstance(variable.value[1], float) 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, ArrayObjectVariable)
assert isinstance(variable.value[0], dict) assert isinstance(variable.value[0], dict)
assert isinstance(variable.value[1], dict) assert isinstance(variable.value[1], dict)
@ -149,7 +149,7 @@ def test_array_object_variable():
def test_variable_cannot_large_than_200_kb(): def test_variable_cannot_large_than_200_kb():
with pytest.raises(VariableError): with pytest.raises(VariableError):
variable_factory.build_variable_from_mapping( variable_factory.build_conversation_variable_from_mapping(
{ {
"id": str(uuid4()), "id": str(uuid4()),
"value_type": "string", "value_type": "string",

View File

@ -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 import Graph
from core.workflow.graph_engine.entities.graph_init_params import GraphInitParams 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.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.enums import UserFrom
from models.workflow import WorkflowType from models.workflow import WorkflowType
@ -84,6 +85,7 @@ def test_overwrite_string_variable():
config={ config={
"id": "node_id", "id": "node_id",
"data": { "data": {
"title": "test",
"assigned_variable_selector": ["conversation", conversation_variable.name], "assigned_variable_selector": ["conversation", conversation_variable.name],
"write_mode": WriteMode.OVER_WRITE.value, "write_mode": WriteMode.OVER_WRITE.value,
"input_variable_selector": [DEFAULT_NODE_ID, input_variable.name], "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()) list(node.run())
mock_run.assert_called_once() mock_run.assert_called_once()
@ -166,6 +168,7 @@ def test_append_variable_to_array():
config={ config={
"id": "node_id", "id": "node_id",
"data": { "data": {
"title": "test",
"assigned_variable_selector": ["conversation", conversation_variable.name], "assigned_variable_selector": ["conversation", conversation_variable.name],
"write_mode": WriteMode.APPEND.value, "write_mode": WriteMode.APPEND.value,
"input_variable_selector": [DEFAULT_NODE_ID, input_variable.name], "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()) list(node.run())
mock_run.assert_called_once() mock_run.assert_called_once()
@ -237,6 +240,7 @@ def test_clear_array():
config={ config={
"id": "node_id", "id": "node_id",
"data": { "data": {
"title": "test",
"assigned_variable_selector": ["conversation", conversation_variable.name], "assigned_variable_selector": ["conversation", conversation_variable.name],
"write_mode": WriteMode.CLEAR.value, "write_mode": WriteMode.CLEAR.value,
"input_variable_selector": [], "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()) list(node.run())
mock_run.assert_called_once() mock_run.assert_called_once()

View File

@ -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"]
)

View File

@ -6,7 +6,7 @@ from models import ConversationVariable
def test_from_variable_and_to_variable(): 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()), "id": str(uuid4()),
"name": "name", "name": "name",

View File

@ -24,10 +24,18 @@ def test_environment_variables():
) )
# Create some EnvironmentVariable instances # Create some EnvironmentVariable instances
variable1 = StringVariable.model_validate({"name": "var1", "value": "value1", "id": str(uuid4())}) variable1 = StringVariable.model_validate(
variable2 = IntegerVariable.model_validate({"name": "var2", "value": 123, "id": str(uuid4())}) {"name": "var1", "value": "value1", "id": str(uuid4()), "selector": ["env", "var1"]}
variable3 = SecretVariable.model_validate({"name": "var3", "value": "secret", "id": str(uuid4())}) )
variable4 = FloatVariable.model_validate({"name": "var4", "value": 3.14, "id": str(uuid4())}) 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 ( with (
mock.patch("core.helper.encrypter.encrypt_token", return_value="encrypted_token"), mock.patch("core.helper.encrypter.encrypt_token", return_value="encrypted_token"),
@ -58,10 +66,18 @@ def test_update_environment_variables():
) )
# Create some EnvironmentVariable instances # Create some EnvironmentVariable instances
variable1 = StringVariable.model_validate({"name": "var1", "value": "value1", "id": str(uuid4())}) variable1 = StringVariable.model_validate(
variable2 = IntegerVariable.model_validate({"name": "var2", "value": 123, "id": str(uuid4())}) {"name": "var1", "value": "value1", "id": str(uuid4()), "selector": ["env", "var1"]}
variable3 = SecretVariable.model_validate({"name": "var3", "value": "secret", "id": str(uuid4())}) )
variable4 = FloatVariable.model_validate({"name": "var4", "value": 3.14, "id": str(uuid4())}) 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 ( with (
mock.patch("core.helper.encrypter.encrypt_token", return_value="encrypted_token"), mock.patch("core.helper.encrypter.encrypt_token", return_value="encrypted_token"),

View File

@ -15,7 +15,7 @@ const Badge = ({
return ( return (
<div <div
className={cn( className={cn(
'inline-flex items-center px-[5px] h-5 rounded-[5px] border border-divider-deep leading-3 text-text-tertiary', 'inline-flex items-center px-[5px] h-5 rounded-[5px] border border-divider-deep leading-3 text-text-tertiary',
uppercase ? 'system-2xs-medium-uppercase' : 'system-xs-medium', uppercase ? 'system-2xs-medium-uppercase' : 'system-xs-medium',
className, className,
)} )}

View File

@ -64,7 +64,9 @@ const Input = ({
destructive && 'bg-components-input-bg-destructive border-components-input-border-destructive text-components-input-text-filled hover:bg-components-input-bg-destructive hover:border-components-input-border-destructive focus:bg-components-input-bg-destructive focus:border-components-input-border-destructive', destructive && 'bg-components-input-bg-destructive border-components-input-border-destructive text-components-input-text-filled hover:bg-components-input-bg-destructive hover:border-components-input-border-destructive focus:bg-components-input-bg-destructive focus:border-components-input-border-destructive',
className, className,
)} )}
placeholder={placeholder ?? (showLeftIcon ? t('common.operation.search') ?? '' : t('common.placeholder.input'))} placeholder={placeholder ?? (showLeftIcon
? (t('common.operation.search') || '')
: (t('common.placeholder.input') || ''))}
value={value} value={value}
onChange={onChange} onChange={onChange}
disabled={disabled} disabled={disabled}

View File

@ -0,0 +1,21 @@
type HorizontalLineProps = {
className?: string
}
const HorizontalLine = ({
className,
}: HorizontalLineProps) => {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="240" height="2" viewBox="0 0 240 2" fill="none" className={className}>
<path d="M0 1H240" stroke="url(#paint0_linear_8619_59125)"/>
<defs>
<linearGradient id="paint0_linear_8619_59125" x1="240" y1="9.99584" x2="3.95539e-05" y2="9.88094" gradientUnits="userSpaceOnUse">
<stop stopColor="white" stopOpacity="0.01"/>
<stop offset="0.9031" stopColor="#101828" stopOpacity="0.04"/>
<stop offset="1" stopColor="white" stopOpacity="0.01"/>
</linearGradient>
</defs>
</svg>
)
}
export default HorizontalLine

View File

@ -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 (
<div className='flex w-[320px] p-4 flex-col items-start gap-2 rounded-[10px] bg-workflow-process-bg'>
<div className='flex w-10 h-10 justify-center items-center gap-2 rounded-[10px]'>
<div className='flex relative p-1 justify-center items-center gap-2 grow self-stretch rounded-[10px]
border-[0.5px] border-components-card-border bg-components-card-bg shadow-lg'>
<Variable02 className='w-5 h-5 shrink-0 text-text-accent' />
<VerticalLine className='absolute -right-[1px] top-1/2 -translate-y-1/4'/>
<VerticalLine className='absolute -left-[1px] top-1/2 -translate-y-1/4'/>
<HorizontalLine className='absolute top-0 left-3/4 -translate-x-1/4 -translate-y-1/2'/>
<HorizontalLine className='absolute top-full left-3/4 -translate-x-1/4 -translate-y-1/2' />
</div>
</div>
<div className='flex flex-col items-start gap-1 self-stretch'>
<div className='text-text-secondary system-sm-medium'>{title}</div>
{description}
</div>
</div>
)
}
export default ListEmpty

View File

@ -0,0 +1,21 @@
type VerticalLineProps = {
className?: string
}
const VerticalLine = ({
className,
}: VerticalLineProps) => {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="2" height="132" viewBox="0 0 2 132" fill="none" className={className}>
<path d="M1 0L1 132" stroke="url(#paint0_linear_8619_59128)"/>
<defs>
<linearGradient id="paint0_linear_8619_59128" x1="-7.99584" y1="132" x2="-7.96108" y2="6.4974e-07" gradientUnits="userSpaceOnUse">
<stop stopColor="white" stopOpacity="0.01"/>
<stop offset="0.877606" stopColor="#101828" stopOpacity="0.04"/>
<stop offset="1" stopColor="white" stopOpacity="0.01"/>
</linearGradient>
</defs>
</svg>
)
}
export default VerticalLine

View File

@ -48,6 +48,7 @@ const getIcon = (type: BlockEnum, className: string) => {
[BlockEnum.VariableAggregator]: <VariableX className={className} />, [BlockEnum.VariableAggregator]: <VariableX className={className} />,
[BlockEnum.Assigner]: <Assigner className={className} />, [BlockEnum.Assigner]: <Assigner className={className} />,
[BlockEnum.Tool]: <VariableX className={className} />, [BlockEnum.Tool]: <VariableX className={className} />,
[BlockEnum.IterationStart]: <VariableX className={className} />,
[BlockEnum.Iteration]: <Iteration className={className} />, [BlockEnum.Iteration]: <Iteration className={className} />,
[BlockEnum.ParameterExtractor]: <ParameterExtractor className={className} />, [BlockEnum.ParameterExtractor]: <ParameterExtractor className={className} />,
[BlockEnum.DocExtractor]: <DocsExtractor className={className} />, [BlockEnum.DocExtractor]: <DocsExtractor className={className} />,

View File

@ -33,6 +33,7 @@ export type Props = {
showFileList?: boolean showFileList?: boolean
onGenerated?: (value: string) => void onGenerated?: (value: string) => void
showCodeGenerator?: boolean showCodeGenerator?: boolean
className?: string
} }
export const languageMap = { export const languageMap = {
@ -67,6 +68,7 @@ const CodeEditor: FC<Props> = ({
showFileList, showFileList,
onGenerated, onGenerated,
showCodeGenerator = false, showCodeGenerator = false,
className,
}) => { }) => {
const [isFocus, setIsFocus] = React.useState(false) const [isFocus, setIsFocus] = React.useState(false)
const [isMounted, setIsMounted] = React.useState(false) const [isMounted, setIsMounted] = React.useState(false)
@ -187,7 +189,7 @@ const CodeEditor: FC<Props> = ({
) )
return ( return (
<div className={cn(isExpand && 'h-full')}> <div className={cn(isExpand && 'h-full', className)}>
{noWrapper {noWrapper
? <div className='relative no-wrapper' style={{ ? <div className='relative no-wrapper' style={{
height: isExpand ? '100%' : (editorContentHeight) / 2 + CODE_EDITOR_LINE_HEIGHT, // In IDE, the last line can always be in lop line. So there is some blank space in the bottom. height: isExpand ? '100%' : (editorContentHeight) / 2 + CODE_EDITOR_LINE_HEIGHT, // In IDE, the last line can always be in lop line. So there is some blank space in the bottom.

View File

@ -47,7 +47,6 @@ const Field: FC<Props> = ({
triggerClassName='w-4 h-4 ml-1' triggerClassName='w-4 h-4 ml-1'
/> />
)} )}
</div> </div>
<div className='flex'> <div className='flex'>
{operations && <div>{operations}</div>} {operations && <div>{operations}</div>}

View File

@ -10,7 +10,7 @@ const ListNoDataPlaceholder: FC<Props> = ({
children, children,
}) => { }) => {
return ( return (
<div className='flex rounded-md bg-gray-50 items-center min-h-[42px] justify-center leading-[18px] text-xs font-normal text-gray-500'> <div className='flex w-full rounded-[10px] bg-background-section items-center min-h-[42px] justify-center system-xs-regular text-text-tertiary'>
{children} {children}
</div> </div>
) )

View File

@ -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<Props> = ({
vars,
onChange,
itemWidth,
}) => {
const { t } = useTranslation()
// max-h-[300px] overflow-y-auto todo: use portal to handle long list
return (
<div className='p-1 bg-components-panel-bg-bur rounded-lg border-[0.5px] border-components-panel-border shadow-lg w-[352px]' >
{(!vars || vars.length === 0)
? <ListEmpty
title={t('workflow.nodes.assigner.noAssignedVars') || ''}
description={t('workflow.nodes.assigner.assignedVarsDescription')}
/>
: <VarReferenceVars
searchBoxClassName='mt-1'
vars={vars}
onChange={onChange}
itemWidth={itemWidth}
isSupportFileVar
/>
}
</div >
)
}
export default React.memo(AssignedVarReferencePopup)

View File

@ -60,6 +60,9 @@ type Props = {
onRemove?: () => void onRemove?: () => void
typePlaceHolder?: string typePlaceHolder?: string
isSupportFileVar?: boolean isSupportFileVar?: boolean
placeholder?: string
minWidth?: number
popupFor?: 'assigned' | 'toAssigned'
} }
const VarReferencePicker: FC<Props> = ({ const VarReferencePicker: FC<Props> = ({
@ -83,6 +86,9 @@ const VarReferencePicker: FC<Props> = ({
onRemove, onRemove,
typePlaceHolder, typePlaceHolder,
isSupportFileVar = true, isSupportFileVar = true,
placeholder,
minWidth,
popupFor,
}) => { }) => {
const { t } = useTranslation() const { t } = useTranslation()
const store = useStoreApi() const store = useStoreApi()
@ -261,7 +267,7 @@ const VarReferencePicker: FC<Props> = ({
<AddButton onClick={() => { }}></AddButton> <AddButton onClick={() => { }}></AddButton>
</div> </div>
) )
: (<div ref={!isSupportConstantValue ? triggerRef : null} className={cn((open || isFocus) ? 'border-gray-300' : 'border-gray-100', 'relative group/wrap flex items-center w-full h-8', !isSupportConstantValue && 'p-1 rounded-lg bg-gray-100 border', isInTable && 'bg-transparent border-none')}> : (<div ref={!isSupportConstantValue ? triggerRef : null} className={cn((open || isFocus) ? 'border-gray-300' : 'border-gray-100', 'relative group/wrap flex items-center w-full h-8', !isSupportConstantValue && 'p-1 rounded-lg bg-gray-100 border', isInTable && 'bg-transparent border-none', readonly && 'bg-components-input-bg-disabled')}>
{isSupportConstantValue {isSupportConstantValue
? <div onClick={(e) => { ? <div onClick={(e) => {
e.stopPropagation() e.stopPropagation()
@ -285,7 +291,7 @@ const VarReferencePicker: FC<Props> = ({
/> />
</div> </div>
: (!hasValue && <div className='ml-1.5 mr-1'> : (!hasValue && <div className='ml-1.5 mr-1'>
<Variable02 className='w-3.5 h-3.5 text-gray-400' /> <Variable02 className={`w-4 h-4 ${readonly ? 'text-components-input-text-disabled' : 'text-components-input-text-placeholder'}`} />
</div>)} </div>)}
{isConstant {isConstant
? ( ? (
@ -329,17 +335,17 @@ const VarReferencePicker: FC<Props> = ({
{!hasValue && <Variable02 className='w-3.5 h-3.5' />} {!hasValue && <Variable02 className='w-3.5 h-3.5' />}
{isEnv && <Env className='w-3.5 h-3.5 text-util-colors-violet-violet-600' />} {isEnv && <Env className='w-3.5 h-3.5 text-util-colors-violet-violet-600' />}
{isChatVar && <BubbleX className='w-3.5 h-3.5 text-util-colors-teal-teal-700' />} {isChatVar && <BubbleX className='w-3.5 h-3.5 text-util-colors-teal-teal-700' />}
<div className={cn('ml-0.5 text-xs font-medium truncate', (isEnv || isChatVar) && '!text-text-secondary')} title={varName} style={{ <div className={cn('ml-0.5 text-xs font-medium truncate', isEnv && '!text-text-secondary', isChatVar && 'text-util-colors-teal-teal-700')} title={varName} style={{
maxWidth: maxVarNameWidth, maxWidth: maxVarNameWidth,
}}>{varName}</div> }}>{varName}</div>
</div> </div>
<div className='ml-0.5 text-xs font-normal text-gray-500 capitalize truncate' title={type} style={{ <div className='ml-0.5 capitalize truncate text-text-tertiary text-center system-xs-regular' title={type} style={{
maxWidth: maxTypeWidth, maxWidth: maxTypeWidth,
}}>{type}</div> }}>{type}</div>
{!isValidVar && <RiErrorWarningFill className='ml-0.5 w-3 h-3 text-[#D92D20]' />} {!isValidVar && <RiErrorWarningFill className='ml-0.5 w-3 h-3 text-[#D92D20]' />}
</> </>
) )
: <div className='text-[13px] font-normal text-gray-400'>{t('workflow.common.setVarValuePlaceholder')}</div>} : <div className={`overflow-hidden ${readonly ? 'text-components-input-text-disabled' : 'text-components-input-text-placeholder'} text-ellipsis system-sm-regular`}>{placeholder ?? t('workflow.common.setVarValuePlaceholder')}</div>}
</div> </div>
</Tooltip> </Tooltip>
</div> </div>
@ -378,12 +384,13 @@ const VarReferencePicker: FC<Props> = ({
</WrapElem> </WrapElem>
<PortalToFollowElemContent style={{ <PortalToFollowElemContent style={{
zIndex: 100, zIndex: 100,
}}> }} className='mt-1'>
{!isConstant && ( {!isConstant && (
<VarReferencePopup <VarReferencePopup
vars={outputVars} vars={outputVars}
popupFor={popupFor}
onChange={handleVarReferenceChange} onChange={handleVarReferenceChange}
itemWidth={isAddBtnTrigger ? 260 : triggerWidth} itemWidth={isAddBtnTrigger ? 260 : (minWidth || triggerWidth)}
isSupportFileVar={isSupportFileVar} isSupportFileVar={isSupportFileVar}
/> />
)} )}

View File

@ -1,33 +1,64 @@
'use client' 'use client'
import type { FC } from 'react' import type { FC } from 'react'
import React from 'react' import React from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import VarReferenceVars from './var-reference-vars' import VarReferenceVars from './var-reference-vars'
import type { NodeOutPutVar, ValueSelector, Var } from '@/app/components/workflow/types' 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 = { type Props = {
vars: NodeOutPutVar[] vars: NodeOutPutVar[]
popupFor?: 'assigned' | 'toAssigned'
onChange: (value: ValueSelector, varDetail: Var) => void onChange: (value: ValueSelector, varDetail: Var) => void
itemWidth?: number itemWidth?: number
isSupportFileVar?: boolean isSupportFileVar?: boolean
} }
const VarReferencePopup: FC<Props> = ({ const VarReferencePopup: FC<Props> = ({
vars, vars,
popupFor,
onChange, onChange,
itemWidth, itemWidth,
isSupportFileVar = true, isSupportFileVar = true,
}) => { }) => {
const { t } = useTranslation()
const { locale } = useContext(I18n)
// max-h-[300px] overflow-y-auto todo: use portal to handle long list // max-h-[300px] overflow-y-auto todo: use portal to handle long list
return ( return (
<div className='p-1 bg-white rounded-lg border border-gray-200 shadow-lg space-y-1' style={{ <div className='p-1 bg-white rounded-lg border border-gray-200 shadow-lg space-y-1' style={{
width: itemWidth || 228, width: itemWidth || 228,
}}> }}>
<VarReferenceVars {((!vars || vars.length === 0) && popupFor)
searchBoxClassName='mt-1' ? (popupFor === 'toAssigned'
vars={vars} ? (
onChange={onChange} <ListEmpty
itemWidth={itemWidth} title={t('workflow.variableReference.noAvailableVars') || ''}
isSupportFileVar={isSupportFileVar} description={<div className='text-text-tertiary system-xs-regular'>
/> {t('workflow.variableReference.noVarsForOperation')}
</div>}
/>
)
: (
<ListEmpty
title={t('workflow.variableReference.noAssignedVars') || ''}
description={<div className='text-text-tertiary system-xs-regular'>
{t('workflow.variableReference.assignedVarsDescription')}
<a target='_blank' rel='noopener noreferrer'
className='text-text-accent-secondary'
href={locale !== LanguagesSupported[1] ? 'https://docs.dify.ai/guides/workflow/variables#conversation-variables' : `https://docs.dify.ai/${locale.toLowerCase()}/guides/workflow/variables#hui-hua-bian-liang`}>{t('workflow.variableReference.conversationVars')}</a>
</div>}
/>
))
: <VarReferenceVars
searchBoxClassName='mt-1'
vars={vars}
onChange={onChange}
itemWidth={itemWidth}
isSupportFileVar={isSupportFileVar}
/>
}
</div > </div >
) )
} }

View File

@ -24,6 +24,7 @@ import QuestionClassifyDefault from '@/app/components/workflow/nodes/question-cl
import HTTPDefault from '@/app/components/workflow/nodes/http/default' import HTTPDefault from '@/app/components/workflow/nodes/http/default'
import ToolDefault from '@/app/components/workflow/nodes/tool/default' import ToolDefault from '@/app/components/workflow/nodes/tool/default'
import VariableAssigner from '@/app/components/workflow/nodes/variable-assigner/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 ParameterExtractorDefault from '@/app/components/workflow/nodes/parameter-extractor/default'
import IterationDefault from '@/app/components/workflow/nodes/iteration/default' import IterationDefault from '@/app/components/workflow/nodes/iteration/default'
import { ssePost } from '@/service/base' import { ssePost } from '@/service/base'
@ -39,6 +40,7 @@ const { checkValid: checkQuestionClassifyValid } = QuestionClassifyDefault
const { checkValid: checkHttpValid } = HTTPDefault const { checkValid: checkHttpValid } = HTTPDefault
const { checkValid: checkToolValid } = ToolDefault const { checkValid: checkToolValid } = ToolDefault
const { checkValid: checkVariableAssignerValid } = VariableAssigner const { checkValid: checkVariableAssignerValid } = VariableAssigner
const { checkValid: checkAssignerValid } = Assigner
const { checkValid: checkParameterExtractorValid } = ParameterExtractorDefault const { checkValid: checkParameterExtractorValid } = ParameterExtractorDefault
const { checkValid: checkIterationValid } = IterationDefault const { checkValid: checkIterationValid } = IterationDefault
@ -51,7 +53,7 @@ const checkValidFns: Record<BlockEnum, Function> = {
[BlockEnum.QuestionClassifier]: checkQuestionClassifyValid, [BlockEnum.QuestionClassifier]: checkQuestionClassifyValid,
[BlockEnum.HttpRequest]: checkHttpValid, [BlockEnum.HttpRequest]: checkHttpValid,
[BlockEnum.Tool]: checkToolValid, [BlockEnum.Tool]: checkToolValid,
[BlockEnum.VariableAssigner]: checkVariableAssignerValid, [BlockEnum.VariableAssigner]: checkAssignerValid,
[BlockEnum.VariableAggregator]: checkVariableAssignerValid, [BlockEnum.VariableAggregator]: checkVariableAssignerValid,
[BlockEnum.ParameterExtractor]: checkParameterExtractorValid, [BlockEnum.ParameterExtractor]: checkParameterExtractorValid,
[BlockEnum.Iteration]: checkIterationValid, [BlockEnum.Iteration]: checkIterationValid,

View File

@ -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<OperationSelectorProps> = ({
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 (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement='bottom-start'
offset={4}
>
<PortalToFollowElemTrigger
onClick={() => !disabled && setOpen(v => !v)}
>
<div
className={classNames(
'flex items-center px-2 py-1 gap-0.5 rounded-lg bg-components-input-bg-normal',
disabled ? 'cursor-not-allowed !bg-components-input-bg-disabled' : 'cursor-pointer hover:bg-state-base-hover-alt',
open && 'bg-state-base-hover-alt',
className,
)}
>
<div className='flex p-1 items-center'>
<span
className={`truncate overflow-hidden text-ellipsis system-sm-regular
${selectedItem ? 'text-components-input-text-filled' : 'text-components-input-text-disabled'}`}
>
{selectedItem?.name ? t(`${i18nPrefix}.operations.${selectedItem?.name}`) : t(`${i18nPrefix}.operations.title`)}
</span>
</div>
<RiArrowDownSLine className={`h-4 w-4 text-text-quaternary ${disabled && 'text-components-input-text-placeholder'} ${open && 'text-text-secondary'}`} />
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className={`z-20 ${popupClassName}`}>
<div className='flex w-[140px] flex-col items-start rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg'>
<div className='flex p-1 flex-col items-start self-stretch'>
<div className='flex px-3 pt-1 pb-0.5 items-start self-stretch'>
<div className='flex grow text-text-tertiary system-xs-medium-uppercase'>{t(`${i18nPrefix}.operations.title`)}</div>
</div>
{items.map(item => (
item.value === 'divider'
? (
<Divider key="divider" className="my-1" />
)
: (
<div
key={item.value}
className={classNames(
'flex items-center px-2 py-1 gap-1 self-stretch rounded-lg',
'cursor-pointer hover:bg-state-base-hover',
)}
onClick={() => {
onSelect(item)
setOpen(false)
}}
>
<div className='flex min-h-5 px-1 items-center gap-1 grow'>
<span className={'flex flex-grow text-text-secondary system-sm-medium'}>{t(`${i18nPrefix}.operations.${item.name}`)}</span>
</div>
{item.value === value && (
<div className='flex justify-center items-center'>
<RiCheckLine className='h-4 w-4 text-text-accent' />
</div>
)}
</div>
)
))}
</div>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default OperationSelector

View File

@ -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<Props> = ({
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 (
<ListNoDataPlaceholder>
{t('workflow.nodes.assigner.noVarTip')}
</ListNoDataPlaceholder>
)
}
return (
<div className='flex flex-col items-start gap-4 self-stretch'>
{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 (
<div className='flex items-start gap-1 self-stretch' key={index}>
<div className='flex flex-col items-start gap-1 flex-grow'>
<div className='flex items-center gap-1 self-stretch'>
<VarReferencePicker
readonly={readonly}
nodeId={nodeId}
isShowNodeName
value={item.variable_selector || []}
onChange={handleAssignedVarChange(index)}
onOpen={handleOpen(index)}
filterVar={filterVar}
placeholder={t('workflow.nodes.assigner.selectAssignedVariable') as string}
minWidth={352}
popupFor='assigned'
className='w-full'
/>
<OperationSelector
value={item.operation}
placeholder='Operation'
disabled={!item.variable_selector || item.variable_selector.length === 0}
onSelect={handleOperationChange(index)}
assignedVarType={assignedVarType}
writeModeTypes={writeModeTypes}
writeModeTypesArr={writeModeTypesArr}
writeModeTypesNum={writeModeTypesNum}
/>
</div>
{item.operation !== WriteMode.clear && item.operation !== WriteMode.set
&& !writeModeTypesNum?.includes(item.operation)
&& (
<VarReferencePicker
readonly={readonly || !item.variable_selector || !item.operation}
nodeId={nodeId}
isShowNodeName
value={item.value}
onChange={handleToAssignedVarChange(index)}
filterVar={handleFilterToAssignedVar(index)}
valueTypePlaceHolder={toAssignedVarType}
placeholder={t('workflow.nodes.assigner.setParameter') as string}
minWidth={352}
popupFor='toAssigned'
className='w-full'
/>
)
}
{item.operation === WriteMode.set && assignedVarType && (
<>
{assignedVarType === 'number' && (
<Input
type="number"
value={item.value as number}
onChange={e => handleToAssignedVarChange(index)(Number(e.target.value))}
className='w-full'
/>
)}
{assignedVarType === 'string' && (
<Textarea
value={item.value as string}
onChange={e => handleToAssignedVarChange(index)(e.target.value)}
className='w-full'
/>
)}
{assignedVarType === 'object' && (
<CodeEditor
value={item.value as string}
language={CodeLanguage.json}
onChange={value => handleToAssignedVarChange(index)(value)}
className='w-full'
readOnly={readonly}
/>
)}
</>
)}
{writeModeTypesNum?.includes(item.operation)
&& <Input
type="number"
value={item.value as number}
onChange={e => handleToAssignedVarChange(index)(Number(e.target.value))}
placeholder="Enter number value..."
className='w-full'
/>
}
</div>
<ActionButton
size='l'
className='flex-shrink-0 group hover:!bg-state-destructive-hover'
onClick={handleVarRemove(index)}
>
<RiDeleteBinLine className='text-text-tertiary w-4 h-4 group-hover:text-text-destructive' />
</ActionButton>
</div>
)
},
)}
</div>
)
}
export default React.memo(VarList)

View File

@ -0,0 +1,39 @@
import { useCallback } from 'react'
import produce from 'immer'
import type { AssignerNodeOperation, AssignerNodeType } from '../../types'
import { AssignerNodeInputType, WriteMode } from '../../types'
type Params = {
id: string
inputs: AssignerNodeType
setInputs: (newInputs: AssignerNodeType) => void
}
function useVarList({
inputs,
setInputs,
}: Params) {
const handleVarListChange = useCallback((newList: AssignerNodeOperation[]) => {
const newInputs = produce(inputs, (draft) => {
draft.items = newList
})
setInputs(newInputs)
}, [inputs, setInputs])
const handleAddVariable = useCallback(() => {
const newInputs = produce(inputs, (draft) => {
draft.items.push({
variable_selector: [],
input_type: AssignerNodeInputType.constant,
operation: WriteMode.overwrite,
value: '',
})
})
setInputs(newInputs)
}, [inputs, setInputs])
return {
handleVarListChange,
handleAddVariable,
}
}
export default useVarList

View File

@ -6,9 +6,8 @@ const i18nPrefix = 'workflow.errorMsg'
const nodeDefault: NodeDefault<AssignerNodeType> = { const nodeDefault: NodeDefault<AssignerNodeType> = {
defaultValue: { defaultValue: {
assigned_variable_selector: [], version: '2',
write_mode: WriteMode.Overwrite, items: [],
input_variable_selector: [],
}, },
getAvailablePrevNodes(isChatMode: boolean) { getAvailablePrevNodes(isChatMode: boolean) {
const nodes = isChatMode const nodes = isChatMode
@ -23,18 +22,23 @@ const nodeDefault: NodeDefault<AssignerNodeType> = {
checkValid(payload: AssignerNodeType, t: any) { checkValid(payload: AssignerNodeType, t: any) {
let errorMessages = '' let errorMessages = ''
const { const {
assigned_variable_selector: assignedVarSelector, items: operationItems,
write_mode: writeMode,
input_variable_selector: toAssignerVarSelector,
} = payload } = payload
if (!errorMessages && !assignedVarSelector?.length) operationItems?.forEach((value) => {
errorMessages = t(`${i18nPrefix}.fieldRequired`, { field: t('workflow.nodes.assigner.assignedVariable') }) if (!errorMessages && !value.variable_selector?.length)
errorMessages = t(`${i18nPrefix}.fieldRequired`, { field: t('workflow.nodes.assigner.assignedVariable') })
if (!errorMessages && writeMode !== WriteMode.Clear) { if (!errorMessages && value.operation !== WriteMode.clear) {
if (!toAssignerVarSelector?.length) if (value.operation === WriteMode.set) {
errorMessages = t(`${i18nPrefix}.fieldRequired`, { field: t('workflow.nodes.assigner.variable') }) if (!value.value && typeof value.value !== 'number')
} errorMessages = t(`${i18nPrefix}.fieldRequired`, { field: t('workflow.nodes.assigner.variable') })
}
else if (!value.value?.length) {
errorMessages = t(`${i18nPrefix}.fieldRequired`, { field: t('workflow.nodes.assigner.variable') })
}
}
})
return { return {
isValid: !errorMessages, isValid: !errorMessages,

View File

@ -0,0 +1,70 @@
import { useCallback } from 'react'
import {
useNodes,
} from 'reactflow'
import { uniqBy } from 'lodash-es'
import {
useIsChatMode,
useWorkflow,
useWorkflowVariables,
} from '../../hooks'
import type {
Node,
Var,
} from '../../types'
import { AssignerNodeInputType, WriteMode } from './types'
export const useGetAvailableVars = () => {
const nodes: Node[] = useNodes()
const { getBeforeNodesInSameBranchIncludeParent } = useWorkflow()
const { getNodeAvailableVars } = useWorkflowVariables()
const isChatMode = useIsChatMode()
const getAvailableVars = useCallback((nodeId: string, handleId: string, filterVar: (v: Var) => boolean, hideEnv = false) => {
const availableNodes: Node[] = []
const currentNode = nodes.find(node => node.id === nodeId)!
if (!currentNode)
return []
const beforeNodes = getBeforeNodesInSameBranchIncludeParent(nodeId)
availableNodes.push(...beforeNodes)
const parentNode = nodes.find(node => node.id === currentNode.parentId)
if (hideEnv) {
return getNodeAvailableVars({
parentNode,
beforeNodes: uniqBy(availableNodes, 'id').filter(node => node.id !== nodeId),
isChatMode,
hideEnv,
hideChatVar: hideEnv,
filterVar,
})
.map(node => ({
...node,
vars: node.isStartNode ? node.vars.filter(v => !v.variable.startsWith('sys.')) : node.vars,
}))
.filter(item => item.vars.length > 0)
}
return getNodeAvailableVars({
parentNode,
beforeNodes: uniqBy(availableNodes, 'id').filter(node => node.id !== nodeId),
isChatMode,
filterVar,
})
}, [nodes, getBeforeNodesInSameBranchIncludeParent, getNodeAvailableVars, isChatMode])
return getAvailableVars
}
export const useHandleAddOperationItem = () => {
return useCallback((list: any[]) => {
const newItem = {
variable_selector: [],
write_mode: WriteMode.overwrite,
input_type: AssignerNodeInputType.variable,
value: '',
}
return [...list, newItem]
}, [])
}

View File

@ -15,31 +15,71 @@ const NodeComponent: FC<NodeProps<AssignerNodeType>> = ({
const { t } = useTranslation() const { t } = useTranslation()
const nodes: Node[] = useNodes() const nodes: Node[] = useNodes()
const { assigned_variable_selector: variable, write_mode: writeMode } = data if (data.version === '2') {
const { items: operationItems } = data
const validOperationItems = operationItems?.filter(item =>
item.variable_selector && item.variable_selector.length > 0,
) || []
if (validOperationItems.length === 0) {
return (
<div className='relative flex flex-col px-3 py-1 gap-0.5 items-start self-stretch'>
<div className='flex flex-col items-start gap-1 self-stretch'>
<div className='flex px-[5px] py-1 items-center gap-1 self-stretch rounded-md bg-workflow-block-parma-bg'>
<div className='flex-1 text-text-tertiary system-xs-medium'>{t(`${i18nPrefix}.varNotSet`)}</div>
</div>
</div>
</div>
)
}
return (
<div className='relative flex flex-col px-3 py-1 gap-0.5 items-start self-stretch'>
{operationItems.map((value, index) => {
const variable = value.variable_selector
if (!variable || variable.length === 0)
return null
const isSystem = isSystemVar(variable)
const isEnv = isENV(variable)
const isChatVar = isConversationVar(variable)
const node = isSystem ? nodes.find(node => node.data.type === BlockEnum.Start) : nodes.find(node => node.id === variable[0])
const varName = isSystem ? `sys.${variable[variable.length - 1]}` : variable.slice(1).join('.')
return (
<NodeVariableItem
key={index}
node={node as Node}
isEnv={isEnv}
isChatVar={isChatVar}
writeMode={value.operation}
varName={varName}
className='bg-workflow-block-parma-bg'
/>
)
})}
</div>
)
}
// Legacy version
const { assigned_variable_selector: variable, write_mode: writeMode } = data as any
if (!variable || variable.length === 0) if (!variable || variable.length === 0)
return null return null
const isSystem = isSystemVar(variable) const isSystem = isSystemVar(variable)
const isEnv = isENV(variable) const isEnv = isENV(variable)
const isChatVar = isConversationVar(variable) const isChatVar = isConversationVar(variable)
const node = isSystem ? nodes.find(node => node.data.type === BlockEnum.Start) : nodes.find(node => node.id === variable[0]) const node = isSystem ? nodes.find(node => node.data.type === BlockEnum.Start) : nodes.find(node => node.id === variable[0])
const varName = isSystem ? `sys.${variable[variable.length - 1]}` : variable.slice(1).join('.') const varName = isSystem ? `sys.${variable[variable.length - 1]}` : variable.slice(1).join('.')
return ( return (
<div className='relative px-3'> <div className='relative flex flex-col px-3 py-1 gap-0.5 items-start self-stretch'>
<div className='mb-1 system-2xs-medium-uppercase text-text-tertiary'>{t(`${i18nPrefix}.assignedVariable`)}</div>
<NodeVariableItem <NodeVariableItem
node={node as Node} node={node as Node}
isEnv={isEnv} isEnv={isEnv}
isChatVar={isChatVar} isChatVar={isChatVar}
varName={varName} varName={varName}
writeMode={writeMode}
className='bg-workflow-block-parma-bg' className='bg-workflow-block-parma-bg'
/> />
<div className='my-2 flex justify-between items-center h-[22px] px-[5px] bg-workflow-block-parma-bg radius-sm'>
<div className='system-xs-medium-uppercase text-text-tertiary'>{t(`${i18nPrefix}.writeMode`)}</div>
<div className='system-xs-medium text-text-secondary'>{t(`${i18nPrefix}.${writeMode}`)}</div>
</div>
</div> </div>
) )
} }

View File

@ -1,15 +1,15 @@
import type { FC } from 'react' import type { FC } from 'react'
import React from 'react' import React from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import {
import VarReferencePicker from '../_base/components/variable/var-reference-picker' RiAddLine,
import OptionCard from '../_base/components/option-card' } from '@remixicon/react'
import VarList from './components/var-list'
import useConfig from './use-config' import useConfig from './use-config'
import { WriteMode } from './types'
import type { AssignerNodeType } from './types' import type { AssignerNodeType } from './types'
import Field from '@/app/components/workflow/nodes/_base/components/field' import { useHandleAddOperationItem } from './hooks'
import ActionButton from '@/app/components/base/action-button'
import { type NodePanelProps } from '@/app/components/workflow/types' import { type NodePanelProps } from '@/app/components/workflow/types'
import cn from '@/utils/classnames'
const i18nPrefix = 'workflow.nodes.assigner' const i18nPrefix = 'workflow.nodes.assigner'
@ -18,67 +18,48 @@ const Panel: FC<NodePanelProps<AssignerNodeType>> = ({
data, data,
}) => { }) => {
const { t } = useTranslation() const { t } = useTranslation()
const handleAddOperationItem = useHandleAddOperationItem()
const { const {
readOnly, readOnly,
inputs, inputs,
handleAssignedVarChanges, handleOperationListChanges,
isSupportAppend, getAssignedVarType,
getToAssignedVarType,
writeModeTypesNum,
writeModeTypesArr,
writeModeTypes, writeModeTypes,
handleWriteModeChange,
filterAssignedVar, filterAssignedVar,
filterToAssignedVar, filterToAssignedVar,
handleToAssignedVarChange,
toAssignedVarType,
} = useConfig(id, data) } = useConfig(id, data)
const handleAddOperation = () => {
const newList = handleAddOperationItem(inputs.items || [])
handleOperationListChanges(newList)
}
return ( return (
<div className='mt-2'> <div className='flex py-2 flex-col items-start self-stretch'>
<div className='px-4 pb-4 space-y-4'> <div className='flex flex-col justify-center items-start gap-1 px-4 py-2 w-full self-stretch'>
<Field <div className='flex items-start gap-2 self-stretch'>
title={t(`${i18nPrefix}.assignedVariable`)} <div className='flex flex-col justify-center items-start flex-grow text-text-secondary system-sm-semibold-uppercase'>{t(`${i18nPrefix}.variables`)}</div>
> <ActionButton onClick={handleAddOperation}>
<VarReferencePicker <RiAddLine className='w-4 h-4 shrink-0 text-text-tertiary' />
readonly={readOnly} </ActionButton>
nodeId={id} </div>
isShowNodeName <VarList
value={inputs.assigned_variable_selector || []} readonly={readOnly}
onChange={handleAssignedVarChanges} nodeId={id}
filterVar={filterAssignedVar} list={inputs.items || []}
/> onChange={(newList) => {
</Field> handleOperationListChanges(newList)
<Field }}
title={t(`${i18nPrefix}.writeMode`)} filterVar={filterAssignedVar}
> filterToAssignedVar={filterToAssignedVar}
<div className={cn('grid gap-2 grid-cols-3')}> getAssignedVarType={getAssignedVarType}
{writeModeTypes.map(type => ( writeModeTypes={writeModeTypes}
<OptionCard writeModeTypesArr={writeModeTypesArr}
key={type} writeModeTypesNum={writeModeTypesNum}
title={t(`${i18nPrefix}.${type}`)} getToAssignedVarType={getToAssignedVarType}
onSelect={handleWriteModeChange(type)} />
selected={inputs.write_mode === type}
disabled={!isSupportAppend && type === WriteMode.Append}
tooltip={type === WriteMode.Append ? t(`${i18nPrefix}.writeModeTip`)! : undefined}
/>
))}
</div>
</Field>
{inputs.write_mode !== WriteMode.Clear && (
<Field
title={t(`${i18nPrefix}.setVariable`)}
>
<VarReferencePicker
readonly={readOnly}
nodeId={id}
isShowNodeName
value={inputs.input_variable_selector || []}
onChange={handleToAssignedVarChange}
filterVar={filterToAssignedVar}
valueTypePlaceHolder={toAssignedVarType}
/>
</Field>
)}
</div> </div>
</div> </div>
) )

View File

@ -1,13 +1,30 @@
import type { CommonNodeType, ValueSelector } from '@/app/components/workflow/types' import type { CommonNodeType, ValueSelector } from '@/app/components/workflow/types'
export enum WriteMode { export enum WriteMode {
Overwrite = 'over-write', overwrite = 'over-write',
Append = 'append', clear = 'clear',
Clear = 'clear', append = 'append',
extend = 'extend',
set = 'set',
increment = '+=',
decrement = '-=',
multiply = '*=',
divide = '/=',
}
export enum AssignerNodeInputType {
variable = 'variable',
constant = 'constant',
}
export type AssignerNodeOperation = {
variable_selector: ValueSelector
input_type: AssignerNodeInputType
operation: WriteMode
value: any
} }
export type AssignerNodeType = CommonNodeType & { export type AssignerNodeType = CommonNodeType & {
assigned_variable_selector: ValueSelector version?: '1' | '2'
write_mode: WriteMode items: AssignerNodeOperation[]
input_variable_selector: ValueSelector
} }

View File

@ -1,10 +1,12 @@
import { useCallback, useMemo } from 'react' import { useCallback, useMemo } from 'react'
import produce from 'immer' import produce from 'immer'
import { useStoreApi } from 'reactflow' import { useStoreApi } from 'reactflow'
import { isEqual } from 'lodash-es'
import { VarType } from '../../types' import { VarType } from '../../types'
import type { ValueSelector, Var } from '../../types' import type { ValueSelector, Var } from '../../types'
import { type AssignerNodeType, WriteMode } from './types' import { WriteMode } from './types'
import type { AssignerNodeOperation, AssignerNodeType } from './types'
import { useGetAvailableVars } from './hooks'
import { convertV1ToV2 } from './utils'
import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud'
import { import {
useIsChatMode, useIsChatMode,
@ -13,9 +15,20 @@ import {
useWorkflowVariables, useWorkflowVariables,
} from '@/app/components/workflow/hooks' } from '@/app/components/workflow/hooks'
const useConfig = (id: string, payload: AssignerNodeType) => { const useConfig = (id: string, rawPayload: AssignerNodeType) => {
const payload = useMemo(() => convertV1ToV2(rawPayload), [rawPayload])
const { nodesReadOnly: readOnly } = useNodesReadOnly() const { nodesReadOnly: readOnly } = useNodesReadOnly()
const isChatMode = useIsChatMode() const isChatMode = useIsChatMode()
const getAvailableVars = useGetAvailableVars()
const filterVar = (varType: VarType) => {
return (v: Var) => {
if (varType === VarType.any)
return true
if (v.type === VarType.any)
return true
return v.type === varType
}
}
const store = useStoreApi() const store = useStoreApi()
const { getBeforeNodesInSameBranch } = useWorkflow() const { getBeforeNodesInSameBranch } = useWorkflow()
@ -30,59 +43,41 @@ const useConfig = (id: string, payload: AssignerNodeType) => {
return getBeforeNodesInSameBranch(id) return getBeforeNodesInSameBranch(id)
}, [getBeforeNodesInSameBranch, id]) }, [getBeforeNodesInSameBranch, id])
const { inputs, setInputs } = useNodeCrud<AssignerNodeType>(id, payload) const { inputs, setInputs } = useNodeCrud<AssignerNodeType>(id, payload)
const newSetInputs = useCallback((newInputs: AssignerNodeType) => {
const finalInputs = produce(newInputs, (draft) => {
if (draft.version !== '2')
draft.version = '2'
})
setInputs(finalInputs)
}, [setInputs])
const { getCurrentVariableType } = useWorkflowVariables() const { getCurrentVariableType } = useWorkflowVariables()
const assignedVarType = getCurrentVariableType({ const getAssignedVarType = useCallback((valueSelector: ValueSelector) => {
parentNode: iterationNode, return getCurrentVariableType({
valueSelector: inputs.assigned_variable_selector || [], parentNode: iterationNode,
availableNodes, valueSelector: valueSelector || [],
isChatMode, availableNodes,
isConstant: false, isChatMode,
}) isConstant: false,
const isSupportAppend = useCallback((varType: VarType) => {
return [VarType.arrayString, VarType.arrayNumber, VarType.arrayObject].includes(varType)
}, [])
const isCurrSupportAppend = useMemo(() => isSupportAppend(assignedVarType), [assignedVarType, isSupportAppend])
const handleAssignedVarChanges = useCallback((variable: ValueSelector | string) => {
const newInputs = produce(inputs, (draft) => {
draft.assigned_variable_selector = variable as ValueSelector
draft.input_variable_selector = []
const newVarType = getCurrentVariableType({
parentNode: iterationNode,
valueSelector: draft.assigned_variable_selector || [],
availableNodes,
isChatMode,
isConstant: false,
})
if (inputs.write_mode === WriteMode.Append && !isSupportAppend(newVarType))
draft.write_mode = WriteMode.Overwrite
}) })
setInputs(newInputs) }, [getCurrentVariableType, iterationNode, availableNodes, isChatMode])
}, [inputs, setInputs, getCurrentVariableType, iterationNode, availableNodes, isChatMode, isSupportAppend])
const writeModeTypes = [WriteMode.Overwrite, WriteMode.Append, WriteMode.Clear] const handleOperationListChanges = useCallback((items: AssignerNodeOperation[]) => {
const newInputs = produce(inputs, (draft) => {
draft.items = [...items]
})
newSetInputs(newInputs)
}, [inputs, newSetInputs])
const handleWriteModeChange = useCallback((writeMode: WriteMode) => { const writeModeTypesArr = [WriteMode.overwrite, WriteMode.clear, WriteMode.append, WriteMode.extend]
return () => { const writeModeTypes = [WriteMode.overwrite, WriteMode.clear, WriteMode.set]
const newInputs = produce(inputs, (draft) => { const writeModeTypesNum = [WriteMode.increment, WriteMode.decrement, WriteMode.multiply, WriteMode.divide]
draft.write_mode = writeMode
if (inputs.write_mode === WriteMode.Clear)
draft.input_variable_selector = []
})
setInputs(newInputs)
}
}, [inputs, setInputs])
const toAssignedVarType = useMemo(() => { const getToAssignedVarType = useCallback((assignedVarType: VarType, write_mode: WriteMode) => {
const { write_mode } = inputs if (write_mode === WriteMode.overwrite || write_mode === WriteMode.increment || write_mode === WriteMode.decrement
if (write_mode === WriteMode.Overwrite) || write_mode === WriteMode.multiply || write_mode === WriteMode.divide || write_mode === WriteMode.extend)
return assignedVarType return assignedVarType
if (write_mode === WriteMode.Append) { if (write_mode === WriteMode.append) {
if (assignedVarType === VarType.arrayString) if (assignedVarType === VarType.arrayString)
return VarType.string return VarType.string
if (assignedVarType === VarType.arrayNumber) if (assignedVarType === VarType.arrayNumber)
@ -91,20 +86,18 @@ const useConfig = (id: string, payload: AssignerNodeType) => {
return VarType.object return VarType.object
} }
return VarType.string return VarType.string
}, [assignedVarType, inputs]) }, [])
const filterAssignedVar = useCallback((varPayload: Var, selector: ValueSelector) => { const filterAssignedVar = useCallback((varPayload: Var, selector: ValueSelector) => {
return selector.join('.').startsWith('conversation') return selector.join('.').startsWith('conversation')
}, []) }, [])
const filterToAssignedVar = useCallback((varPayload: Var, selector: ValueSelector) => { const filterToAssignedVar = useCallback((varPayload: Var, assignedVarType: VarType, write_mode: WriteMode) => {
if (isEqual(selector, inputs.assigned_variable_selector)) if (write_mode === WriteMode.overwrite || write_mode === WriteMode.extend || write_mode === WriteMode.increment
return false || write_mode === WriteMode.decrement || write_mode === WriteMode.multiply || write_mode === WriteMode.divide) {
if (inputs.write_mode === WriteMode.Overwrite) {
return varPayload.type === assignedVarType return varPayload.type === assignedVarType
} }
else if (inputs.write_mode === WriteMode.Append) { else if (write_mode === WriteMode.append) {
switch (assignedVarType) { switch (assignedVarType) {
case VarType.arrayString: case VarType.arrayString:
return varPayload.type === VarType.string return varPayload.type === VarType.string
@ -117,27 +110,21 @@ const useConfig = (id: string, payload: AssignerNodeType) => {
} }
} }
return true return true
}, [inputs.assigned_variable_selector, inputs.write_mode, assignedVarType]) }, [])
const handleToAssignedVarChange = useCallback((value: ValueSelector | string) => {
const newInputs = produce(inputs, (draft) => {
draft.input_variable_selector = value as ValueSelector
})
setInputs(newInputs)
}, [inputs, setInputs])
return { return {
readOnly, readOnly,
inputs, inputs,
handleAssignedVarChanges, handleOperationListChanges,
assignedVarType, getAssignedVarType,
isSupportAppend: isCurrSupportAppend, getToAssignedVarType,
writeModeTypes, writeModeTypes,
handleWriteModeChange, writeModeTypesArr,
writeModeTypesNum,
filterAssignedVar, filterAssignedVar,
filterToAssignedVar, filterToAssignedVar,
handleToAssignedVarChange, getAvailableVars,
toAssignedVarType, filterVar,
} }
} }

View File

@ -1,5 +1,83 @@
import type { AssignerNodeType } from './types' import type { AssignerNodeType } from './types'
import { AssignerNodeInputType, WriteMode } from './types'
export const checkNodeValid = (payload: AssignerNodeType) => { export const checkNodeValid = (payload: AssignerNodeType) => {
return true return true
} }
export const formatOperationName = (type: string) => {
if (type === 'over-write')
return 'Overwrite'
return type.charAt(0).toUpperCase() + type.slice(1)
}
type Item = {
value: string | number
name: string
}
export const getOperationItems = (
assignedVarType?: string,
writeModeTypes?: WriteMode[],
writeModeTypesArr?: WriteMode[],
writeModeTypesNum?: WriteMode[],
): Item[] => {
if (assignedVarType?.startsWith('array') && writeModeTypesArr) {
return writeModeTypesArr.map(type => ({
value: type,
name: type,
}))
}
if (assignedVarType === 'number' && writeModeTypes && writeModeTypesNum) {
return [
...writeModeTypes.map(type => ({
value: type,
name: type,
})),
{ value: 'divider', name: 'divider' } as Item,
...writeModeTypesNum.map(type => ({
value: type,
name: type,
})),
]
}
if (writeModeTypes && ['string', 'object'].includes(assignedVarType || '')) {
return writeModeTypes.map(type => ({
value: type,
name: type,
}))
}
return []
}
const convertOldWriteMode = (oldMode: string): WriteMode => {
switch (oldMode) {
case 'over-write':
return WriteMode.overwrite
case 'append':
return WriteMode.append
case 'clear':
return WriteMode.clear
default:
return WriteMode.overwrite
}
}
export const convertV1ToV2 = (payload: any): AssignerNodeType => {
if (payload.version === '2' && payload.items)
return payload as AssignerNodeType
return {
version: '2',
items: [{
variable_selector: payload.assigned_variable_selector || [],
input_type: AssignerNodeInputType.variable,
operation: convertOldWriteMode(payload.write_mode),
value: payload.input_variable_selector || [],
}],
...payload,
}
}

View File

@ -1,9 +1,11 @@
import { memo } from 'react' import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import cn from '@/utils/classnames' import cn from '@/utils/classnames'
import { VarBlockIcon } from '@/app/components/workflow/block-icon' import { VarBlockIcon } from '@/app/components/workflow/block-icon'
import { Line3 } from '@/app/components/base/icons/src/public/common' import { Line3 } from '@/app/components/base/icons/src/public/common'
import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development' import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development'
import { BubbleX, Env } from '@/app/components/base/icons/src/vender/line/others' import { BubbleX, Env } from '@/app/components/base/icons/src/vender/line/others'
import Badge from '@/app/components/base/badge'
import type { Node } from '@/app/components/workflow/types' import type { Node } from '@/app/components/workflow/types'
import { BlockEnum } from '@/app/components/workflow/types' import { BlockEnum } from '@/app/components/workflow/types'
@ -12,20 +14,26 @@ type NodeVariableItemProps = {
isChatVar: boolean isChatVar: boolean
node: Node node: Node
varName: string varName: string
writeMode?: string
showBorder?: boolean showBorder?: boolean
className?: string className?: string
} }
const i18nPrefix = 'workflow.nodes.assigner'
const NodeVariableItem = ({ const NodeVariableItem = ({
isEnv, isEnv,
isChatVar, isChatVar,
node, node,
varName, varName,
writeMode,
showBorder, showBorder,
className, className,
}: NodeVariableItemProps) => { }: NodeVariableItemProps) => {
const { t } = useTranslation()
return ( return (
<div className={cn( <div className={cn(
'relative flex items-center mt-0.5 h-6 bg-gray-100 rounded-md px-1 text-xs font-normal text-gray-700', 'relative flex items-center p-[3px] pl-[5px] gap-1 self-stretch rounded-md bg-workflow-block-param-bg',
showBorder && '!bg-black/[0.02]', showBorder && '!bg-black/[0.02]',
className, className,
)}> )}>
@ -41,11 +49,19 @@ const NodeVariableItem = ({
<Line3 className='mr-0.5'></Line3> <Line3 className='mr-0.5'></Line3>
</div> </div>
)} )}
<div className='flex items-center text-primary-600'> <div className='flex items-center text-primary-600 w-full'>
{!isEnv && !isChatVar && <Variable02 className='shrink-0 w-3.5 h-3.5 text-primary-500' />} {!isEnv && !isChatVar && <Variable02 className='shrink-0 w-3.5 h-3.5 text-primary-500' />}
{isEnv && <Env className='shrink-0 w-3.5 h-3.5 text-util-colors-violet-violet-600' />} {isEnv && <Env className='shrink-0 w-3.5 h-3.5 text-util-colors-violet-violet-600' />}
{isChatVar && <BubbleX className='w-3.5 h-3.5 text-util-colors-teal-teal-700' />} {!isChatVar && <div className={cn('max-w-[75px] truncate ml-0.5 system-xs-medium overflow-hidden text-ellipsis', isEnv && 'text-gray-900')} title={varName}>{varName}</div>}
<div className={cn('max-w-[75px] truncate ml-0.5 text-xs font-medium', (isEnv || isChatVar) && 'text-gray-900')} title={varName}>{varName}</div> {isChatVar
&& <div className='flex items-center w-full gap-1'>
<div className='flex h-[18px] min-w-[18px] items-center gap-0.5 flex-1'>
<BubbleX className='w-3.5 h-3.5 text-util-colors-teal-teal-700' />
<div className='max-w-[75px] truncate ml-0.5 system-xs-medium overflow-hidden text-ellipsis text-util-colors-teal-teal-700'>{varName}</div>
</div>
{writeMode && <Badge className='shrink-0' text={t(`${i18nPrefix}.operations.${writeMode}`)} />}
</div>
}
</div> </div>
</div> </div>
) )

View File

@ -260,6 +260,13 @@ const translation = {
zoomTo100: 'Zoom to 100%', zoomTo100: 'Zoom to 100%',
zoomToFit: 'Zoom to Fit', zoomToFit: 'Zoom to Fit',
}, },
variableReference: {
noAvailableVars: 'No available variables',
noVarsForOperation: 'There are no variables available for assignment with the selected operation.',
noAssignedVars: 'No available assigned variables',
assignedVarsDescription: 'Assigned variables must be writable variables, such as ',
conversationVars: 'conversation variables',
},
panel: { panel: {
userInputField: 'User Input Field', userInputField: 'User Input Field',
changeBlock: 'Change Block', changeBlock: 'Change Block',
@ -491,6 +498,9 @@ const translation = {
}, },
assigner: { assigner: {
'assignedVariable': 'Assigned Variable', 'assignedVariable': 'Assigned Variable',
'varNotSet': 'Variable NOT Set',
'variables': 'Variables',
'noVarTip': 'Click the "+" button to add variables',
'writeMode': 'Write Mode', 'writeMode': 'Write Mode',
'writeModeTip': 'Append mode: Available for array variables only.', 'writeModeTip': 'Append mode: Available for array variables only.',
'over-write': 'Overwrite', 'over-write': 'Overwrite',
@ -498,7 +508,24 @@ const translation = {
'plus': 'Plus', 'plus': 'Plus',
'clear': 'Clear', 'clear': 'Clear',
'setVariable': 'Set Variable', 'setVariable': 'Set Variable',
'selectAssignedVariable': 'Select assigned variable...',
'setParameter': 'Set parameter...',
'operations': {
'title': 'Operation',
'over-write': 'Overwrite',
'overwrite': 'Overwrite',
'set': 'Set',
'clear': 'Clear',
'extend': 'Extend',
'append': 'Append',
'+=': '+=',
'-=': '-=',
'*=': '*=',
'/=': '/=',
},
'variable': 'Variable', 'variable': 'Variable',
'noAssignedVars': 'No available assigned variables',
'assignedVarsDescription': 'Assigned variables must be writable variables, such as conversation variables.',
}, },
tool: { tool: {
toAuthorize: 'To authorize', toAuthorize: 'To authorize',

View File

@ -225,7 +225,7 @@ const translation = {
'code': '代码执行', 'code': '代码执行',
'template-transform': '模板转换', 'template-transform': '模板转换',
'http-request': 'HTTP 请求', 'http-request': 'HTTP 请求',
'variable-assigner': '变量聚合器', 'variable-assigner': '变量赋值器',
'variable-aggregator': '变量聚合器', 'variable-aggregator': '变量聚合器',
'assigner': '变量赋值', 'assigner': '变量赋值',
'iteration-start': '迭代开始', 'iteration-start': '迭代开始',
@ -260,6 +260,13 @@ const translation = {
zoomTo100: '放大到 100%', zoomTo100: '放大到 100%',
zoomToFit: '自适应视图', zoomToFit: '自适应视图',
}, },
variableReference: {
noAvailableVars: '没有可用变量',
noVarsForOperation: '当前选择的操作没有可用的变量进行赋值。',
noAssignedVars: '没有可用的赋值变量',
assignedVarsDescription: '赋值变量必须是可写入的变量,例如:',
conversationVars: '会话变量',
},
panel: { panel: {
userInputField: '用户输入字段', userInputField: '用户输入字段',
changeBlock: '更改节点', changeBlock: '更改节点',
@ -491,6 +498,8 @@ const translation = {
}, },
assigner: { assigner: {
'assignedVariable': '赋值的变量', 'assignedVariable': '赋值的变量',
'varNotSet': '未设置变量',
'noVarTip': '点击 "+" 按钮添加变量',
'writeMode': '写入模式', 'writeMode': '写入模式',
'writeModeTip': '使用追加模式时,赋值的变量必须是数组类型。', 'writeModeTip': '使用追加模式时,赋值的变量必须是数组类型。',
'over-write': '覆盖', 'over-write': '覆盖',
@ -498,7 +507,25 @@ const translation = {
'plus': '加', 'plus': '加',
'clear': '清空', 'clear': '清空',
'setVariable': '设置变量', 'setVariable': '设置变量',
'selectAssignedVariable': '选择要赋值的变量...',
'setParameter': '设置参数...',
'operations': {
'title': '操作',
'over-write': '覆盖',
'overwrite': '覆盖',
'set': '设置',
'clear': '清空',
'extend': '扩展',
'append': '追加',
'+=': '+=',
'-=': '-=',
'*=': '*=',
'/=': '/=',
},
'variable': '变量', 'variable': '变量',
'variables': '变量',
'noAssignedVars': '没有可用的赋值变量',
'assignedVarsDescription': '赋值变量必须是可写入的变量,例如会话变量。',
}, },
tool: { tool: {
toAuthorize: '授权', toAuthorize: '授权',