Co-authored-by: QuantumGhost <obelisk.reg+git@gmail.com>tags/1.8.0
| from tests.integration_tests.utils.parent_class import ParentClass | |||||
| class ChildClass(ParentClass): | |||||
| """Test child class for module import helper tests""" | |||||
| def __init__(self, name): | |||||
| super().__init__(name) | |||||
| def get_name(self): | |||||
| return f"Child: {self.name}" |
| from core.app.app_config.entities import ExternalDataVariableEntity, VariableEntity, VariableEntityType | from core.app.app_config.entities import ExternalDataVariableEntity, VariableEntity, VariableEntityType | ||||
| from core.external_data_tool.factory import ExternalDataToolFactory | from core.external_data_tool.factory import ExternalDataToolFactory | ||||
| _ALLOWED_VARIABLE_ENTITY_TYPE = frozenset( | |||||
| [ | |||||
| VariableEntityType.TEXT_INPUT, | |||||
| VariableEntityType.SELECT, | |||||
| VariableEntityType.PARAGRAPH, | |||||
| VariableEntityType.NUMBER, | |||||
| VariableEntityType.EXTERNAL_DATA_TOOL, | |||||
| VariableEntityType.CHECKBOX, | |||||
| ] | |||||
| ) | |||||
| class BasicVariablesConfigManager: | class BasicVariablesConfigManager: | ||||
| @classmethod | @classmethod | ||||
| VariableEntityType.PARAGRAPH, | VariableEntityType.PARAGRAPH, | ||||
| VariableEntityType.NUMBER, | VariableEntityType.NUMBER, | ||||
| VariableEntityType.SELECT, | VariableEntityType.SELECT, | ||||
| VariableEntityType.CHECKBOX, | |||||
| }: | }: | ||||
| variable = variables[variable_type] | variable = variables[variable_type] | ||||
| variable_entities.append( | variable_entities.append( | ||||
| variables = [] | variables = [] | ||||
| for item in config["user_input_form"]: | for item in config["user_input_form"]: | ||||
| key = list(item.keys())[0] | key = list(item.keys())[0] | ||||
| if key not in {"text-input", "select", "paragraph", "number", "external_data_tool"}: | |||||
| raise ValueError("Keys in user_input_form list can only be 'text-input', 'paragraph' or 'select'") | |||||
| # if key not in {"text-input", "select", "paragraph", "number", "external_data_tool"}: | |||||
| if key not in { | |||||
| VariableEntityType.TEXT_INPUT, | |||||
| VariableEntityType.SELECT, | |||||
| VariableEntityType.PARAGRAPH, | |||||
| VariableEntityType.NUMBER, | |||||
| VariableEntityType.EXTERNAL_DATA_TOOL, | |||||
| VariableEntityType.CHECKBOX, | |||||
| }: | |||||
| allowed_keys = ", ".join(i.value for i in _ALLOWED_VARIABLE_ENTITY_TYPE) | |||||
| raise ValueError(f"Keys in user_input_form list can only be {allowed_keys}") | |||||
| form_item = item[key] | form_item = item[key] | ||||
| if "label" not in form_item: | if "label" not in form_item: |
| EXTERNAL_DATA_TOOL = "external_data_tool" | EXTERNAL_DATA_TOOL = "external_data_tool" | ||||
| FILE = "file" | FILE = "file" | ||||
| FILE_LIST = "file-list" | FILE_LIST = "file-list" | ||||
| CHECKBOX = "checkbox" | |||||
| class VariableEntity(BaseModel): | class VariableEntity(BaseModel): |
| f"(type '{variable_entity.type}') {variable_entity.variable} in input form must be a string" | f"(type '{variable_entity.type}') {variable_entity.variable} in input form must be a string" | ||||
| ) | ) | ||||
| if variable_entity.type == VariableEntityType.NUMBER and isinstance(value, str): | |||||
| # handle empty string case | |||||
| if not value.strip(): | |||||
| return None | |||||
| # may raise ValueError if user_input_value is not a valid number | |||||
| try: | |||||
| if "." in value: | |||||
| return float(value) | |||||
| else: | |||||
| return int(value) | |||||
| except ValueError: | |||||
| raise ValueError(f"{variable_entity.variable} in input form must be a valid number") | |||||
| if variable_entity.type == VariableEntityType.NUMBER: | |||||
| if isinstance(value, (int, float)): | |||||
| return value | |||||
| elif isinstance(value, str): | |||||
| # handle empty string case | |||||
| if not value.strip(): | |||||
| return None | |||||
| # may raise ValueError if user_input_value is not a valid number | |||||
| try: | |||||
| if "." in value: | |||||
| return float(value) | |||||
| else: | |||||
| return int(value) | |||||
| except ValueError: | |||||
| raise ValueError(f"{variable_entity.variable} in input form must be a valid number") | |||||
| else: | |||||
| raise TypeError(f"expected value type int, float or str, got {type(value)}, value: {value}") | |||||
| match variable_entity.type: | match variable_entity.type: | ||||
| case VariableEntityType.SELECT: | case VariableEntityType.SELECT: | ||||
| raise ValueError( | raise ValueError( | ||||
| f"{variable_entity.variable} in input form must be less than {variable_entity.max_length} files" | f"{variable_entity.variable} in input form must be less than {variable_entity.max_length} files" | ||||
| ) | ) | ||||
| case VariableEntityType.CHECKBOX: | |||||
| if not isinstance(value, bool): | |||||
| raise ValueError(f"{variable_entity.variable} in input form must be a valid boolean value") | |||||
| case _: | |||||
| raise AssertionError("this statement should be unreachable.") | |||||
| return value | return value | ||||
| return "" | return "" | ||||
| class BooleanSegment(Segment): | |||||
| value_type: SegmentType = SegmentType.BOOLEAN | |||||
| value: bool | |||||
| class ArrayAnySegment(ArraySegment): | class ArrayAnySegment(ArraySegment): | ||||
| value_type: SegmentType = SegmentType.ARRAY_ANY | value_type: SegmentType = SegmentType.ARRAY_ANY | ||||
| value: Sequence[Any] | value: Sequence[Any] | ||||
| return "" | return "" | ||||
| class ArrayBooleanSegment(ArraySegment): | |||||
| value_type: SegmentType = SegmentType.ARRAY_BOOLEAN | |||||
| value: Sequence[bool] | |||||
| def get_segment_discriminator(v: Any) -> SegmentType | None: | def get_segment_discriminator(v: Any) -> SegmentType | None: | ||||
| if isinstance(v, Segment): | if isinstance(v, Segment): | ||||
| return v.value_type | return v.value_type | ||||
| | Annotated[IntegerSegment, Tag(SegmentType.INTEGER)] | | Annotated[IntegerSegment, Tag(SegmentType.INTEGER)] | ||||
| | Annotated[ObjectSegment, Tag(SegmentType.OBJECT)] | | Annotated[ObjectSegment, Tag(SegmentType.OBJECT)] | ||||
| | Annotated[FileSegment, Tag(SegmentType.FILE)] | | Annotated[FileSegment, Tag(SegmentType.FILE)] | ||||
| | Annotated[BooleanSegment, Tag(SegmentType.BOOLEAN)] | |||||
| | Annotated[ArrayAnySegment, Tag(SegmentType.ARRAY_ANY)] | | Annotated[ArrayAnySegment, Tag(SegmentType.ARRAY_ANY)] | ||||
| | Annotated[ArrayStringSegment, Tag(SegmentType.ARRAY_STRING)] | | Annotated[ArrayStringSegment, Tag(SegmentType.ARRAY_STRING)] | ||||
| | Annotated[ArrayNumberSegment, Tag(SegmentType.ARRAY_NUMBER)] | | Annotated[ArrayNumberSegment, Tag(SegmentType.ARRAY_NUMBER)] | ||||
| | Annotated[ArrayObjectSegment, Tag(SegmentType.ARRAY_OBJECT)] | | Annotated[ArrayObjectSegment, Tag(SegmentType.ARRAY_OBJECT)] | ||||
| | Annotated[ArrayFileSegment, Tag(SegmentType.ARRAY_FILE)] | | Annotated[ArrayFileSegment, Tag(SegmentType.ARRAY_FILE)] | ||||
| | Annotated[ArrayBooleanSegment, Tag(SegmentType.ARRAY_BOOLEAN)] | |||||
| ), | ), | ||||
| Discriminator(get_segment_discriminator), | Discriminator(get_segment_discriminator), | ||||
| ] | ] |
| class ArrayValidation(StrEnum): | class ArrayValidation(StrEnum): | ||||
| """Strategy for validating array elements""" | |||||
| """Strategy for validating array elements. | |||||
| Note: | |||||
| The `NONE` and `FIRST` strategies are primarily for compatibility purposes. | |||||
| Avoid using them in new code whenever possible. | |||||
| """ | |||||
| # Skip element validation (only check array container) | # Skip element validation (only check array container) | ||||
| NONE = "none" | NONE = "none" | ||||
| SECRET = "secret" | SECRET = "secret" | ||||
| FILE = "file" | FILE = "file" | ||||
| BOOLEAN = "boolean" | |||||
| 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]" | ||||
| ARRAY_FILE = "array[file]" | ARRAY_FILE = "array[file]" | ||||
| ARRAY_BOOLEAN = "array[boolean]" | |||||
| NONE = "none" | NONE = "none" | ||||
| return SegmentType.ARRAY_FILE | return SegmentType.ARRAY_FILE | ||||
| case SegmentType.NONE: | case SegmentType.NONE: | ||||
| return SegmentType.ARRAY_ANY | return SegmentType.ARRAY_ANY | ||||
| case SegmentType.BOOLEAN: | |||||
| return SegmentType.ARRAY_BOOLEAN | |||||
| case _: | case _: | ||||
| # This should be unreachable. | # This should be unreachable. | ||||
| raise ValueError(f"not supported value {value}") | raise ValueError(f"not supported value {value}") | ||||
| if value is None: | if value is None: | ||||
| return SegmentType.NONE | return SegmentType.NONE | ||||
| elif isinstance(value, int) and not isinstance(value, bool): | |||||
| # Important: The check for `bool` must precede the check for `int`, | |||||
| # as `bool` is a subclass of `int` in Python's type hierarchy. | |||||
| elif isinstance(value, bool): | |||||
| return SegmentType.BOOLEAN | |||||
| elif isinstance(value, int): | |||||
| return SegmentType.INTEGER | return SegmentType.INTEGER | ||||
| elif isinstance(value, float): | elif isinstance(value, float): | ||||
| return SegmentType.FLOAT | return SegmentType.FLOAT | ||||
| else: | else: | ||||
| return all(element_type.is_valid(i, array_validation=ArrayValidation.NONE) for i in value) | return all(element_type.is_valid(i, array_validation=ArrayValidation.NONE) for i in value) | ||||
| def is_valid(self, value: Any, array_validation: ArrayValidation = ArrayValidation.FIRST) -> bool: | |||||
| def is_valid(self, value: Any, array_validation: ArrayValidation = ArrayValidation.ALL) -> bool: | |||||
| """ | """ | ||||
| Check if a value matches the segment type. | Check if a value matches the segment type. | ||||
| Users of `SegmentType` should call this method, instead of using | Users of `SegmentType` should call this method, instead of using | ||||
| """ | """ | ||||
| if self.is_array_type(): | if self.is_array_type(): | ||||
| return self._validate_array(value, array_validation) | return self._validate_array(value, array_validation) | ||||
| # Important: The check for `bool` must precede the check for `int`, | |||||
| # as `bool` is a subclass of `int` in Python's type hierarchy. | |||||
| elif self == SegmentType.BOOLEAN: | |||||
| return isinstance(value, bool) | |||||
| elif self in [SegmentType.INTEGER, SegmentType.FLOAT, SegmentType.NUMBER]: | elif self in [SegmentType.INTEGER, SegmentType.FLOAT, SegmentType.NUMBER]: | ||||
| return isinstance(value, (int, float)) | return isinstance(value, (int, float)) | ||||
| elif self == SegmentType.STRING: | elif self == SegmentType.STRING: | ||||
| else: | else: | ||||
| raise AssertionError("this statement should be unreachable.") | raise AssertionError("this statement should be unreachable.") | ||||
| @staticmethod | |||||
| def cast_value(value: Any, type_: "SegmentType") -> Any: | |||||
| # Cast Python's `bool` type to `int` when the runtime type requires | |||||
| # an integer or number. | |||||
| # | |||||
| # This ensures compatibility with existing workflows that may use `bool` as | |||||
| # `int`, since in Python's type system, `bool` is a subtype of `int`. | |||||
| # | |||||
| # This function exists solely to maintain compatibility with existing workflows. | |||||
| # It should not be used to compromise the integrity of the runtime type system. | |||||
| # No additional casting rules should be introduced to this function. | |||||
| if type_ in ( | |||||
| SegmentType.INTEGER, | |||||
| SegmentType.NUMBER, | |||||
| ) and isinstance(value, bool): | |||||
| return int(value) | |||||
| if type_ == SegmentType.ARRAY_NUMBER and all(isinstance(i, bool) for i in value): | |||||
| return [int(i) for i in value] | |||||
| return value | |||||
| def exposed_type(self) -> "SegmentType": | def exposed_type(self) -> "SegmentType": | ||||
| """Returns the type exposed to the frontend. | """Returns the type exposed to the frontend. | ||||
| return SegmentType.NUMBER | return SegmentType.NUMBER | ||||
| return self | return self | ||||
| def element_type(self) -> "SegmentType | None": | |||||
| """Return the element type of the current segment type, or `None` if the element type is undefined. | |||||
| Raises: | |||||
| ValueError: If the current segment type is not an array type. | |||||
| Note: | |||||
| For certain array types, such as `SegmentType.ARRAY_ANY`, their element types are not defined | |||||
| by the runtime system. In such cases, this method will return `None`. | |||||
| """ | |||||
| if not self.is_array_type(): | |||||
| raise ValueError(f"element_type is only supported by array type, got {self}") | |||||
| return _ARRAY_ELEMENT_TYPES_MAPPING.get(self) | |||||
| _ARRAY_ELEMENT_TYPES_MAPPING: Mapping[SegmentType, SegmentType] = { | _ARRAY_ELEMENT_TYPES_MAPPING: Mapping[SegmentType, SegmentType] = { | ||||
| # ARRAY_ANY does not have corresponding element type. | # ARRAY_ANY does not have corresponding element type. | ||||
| SegmentType.ARRAY_NUMBER: SegmentType.NUMBER, | SegmentType.ARRAY_NUMBER: SegmentType.NUMBER, | ||||
| SegmentType.ARRAY_OBJECT: SegmentType.OBJECT, | SegmentType.ARRAY_OBJECT: SegmentType.OBJECT, | ||||
| SegmentType.ARRAY_FILE: SegmentType.FILE, | SegmentType.ARRAY_FILE: SegmentType.FILE, | ||||
| SegmentType.ARRAY_BOOLEAN: SegmentType.BOOLEAN, | |||||
| } | } | ||||
| _ARRAY_TYPES = frozenset( | _ARRAY_TYPES = frozenset( |
| from .segments import ( | from .segments import ( | ||||
| ArrayAnySegment, | ArrayAnySegment, | ||||
| ArrayBooleanSegment, | |||||
| ArrayFileSegment, | ArrayFileSegment, | ||||
| ArrayNumberSegment, | ArrayNumberSegment, | ||||
| ArrayObjectSegment, | ArrayObjectSegment, | ||||
| ArraySegment, | ArraySegment, | ||||
| ArrayStringSegment, | ArrayStringSegment, | ||||
| BooleanSegment, | |||||
| FileSegment, | FileSegment, | ||||
| FloatSegment, | FloatSegment, | ||||
| IntegerSegment, | IntegerSegment, | ||||
| pass | pass | ||||
| class BooleanVariable(BooleanSegment, Variable): | |||||
| pass | |||||
| class ArrayFileVariable(ArrayFileSegment, ArrayVariable): | class ArrayFileVariable(ArrayFileSegment, ArrayVariable): | ||||
| pass | pass | ||||
| class ArrayBooleanVariable(ArrayBooleanSegment, ArrayVariable): | |||||
| pass | |||||
| # The `VariableUnion`` type is used to enable serialization and deserialization with Pydantic. | # The `VariableUnion`` type is used to enable serialization and deserialization with Pydantic. | ||||
| # Use `Variable` for type hinting when serialization is not required. | # Use `Variable` for type hinting when serialization is not required. | ||||
| # | # | ||||
| | Annotated[IntegerVariable, Tag(SegmentType.INTEGER)] | | Annotated[IntegerVariable, Tag(SegmentType.INTEGER)] | ||||
| | Annotated[ObjectVariable, Tag(SegmentType.OBJECT)] | | Annotated[ObjectVariable, Tag(SegmentType.OBJECT)] | ||||
| | Annotated[FileVariable, Tag(SegmentType.FILE)] | | Annotated[FileVariable, Tag(SegmentType.FILE)] | ||||
| | Annotated[BooleanVariable, Tag(SegmentType.BOOLEAN)] | |||||
| | Annotated[ArrayAnyVariable, Tag(SegmentType.ARRAY_ANY)] | | Annotated[ArrayAnyVariable, Tag(SegmentType.ARRAY_ANY)] | ||||
| | Annotated[ArrayStringVariable, Tag(SegmentType.ARRAY_STRING)] | | Annotated[ArrayStringVariable, Tag(SegmentType.ARRAY_STRING)] | ||||
| | Annotated[ArrayNumberVariable, Tag(SegmentType.ARRAY_NUMBER)] | | Annotated[ArrayNumberVariable, Tag(SegmentType.ARRAY_NUMBER)] | ||||
| | Annotated[ArrayObjectVariable, Tag(SegmentType.ARRAY_OBJECT)] | | Annotated[ArrayObjectVariable, Tag(SegmentType.ARRAY_OBJECT)] | ||||
| | Annotated[ArrayFileVariable, Tag(SegmentType.ARRAY_FILE)] | | Annotated[ArrayFileVariable, Tag(SegmentType.ARRAY_FILE)] | ||||
| | Annotated[ArrayBooleanVariable, Tag(SegmentType.ARRAY_BOOLEAN)] | |||||
| | Annotated[SecretVariable, Tag(SegmentType.SECRET)] | | Annotated[SecretVariable, Tag(SegmentType.SECRET)] | ||||
| ), | ), | ||||
| Discriminator(get_segment_discriminator), | Discriminator(get_segment_discriminator), |
| from core.helper.code_executor.javascript.javascript_code_provider import JavascriptCodeProvider | from core.helper.code_executor.javascript.javascript_code_provider import JavascriptCodeProvider | ||||
| from core.helper.code_executor.python3.python3_code_provider import Python3CodeProvider | from core.helper.code_executor.python3.python3_code_provider import Python3CodeProvider | ||||
| from core.variables.segments import ArrayFileSegment | from core.variables.segments import ArrayFileSegment | ||||
| from core.variables.types import SegmentType | |||||
| from core.workflow.entities.node_entities import NodeRunResult | from core.workflow.entities.node_entities import NodeRunResult | ||||
| from core.workflow.entities.workflow_node_execution import WorkflowNodeExecutionStatus | from core.workflow.entities.workflow_node_execution import WorkflowNodeExecutionStatus | ||||
| from core.workflow.nodes.base import BaseNode | from core.workflow.nodes.base import BaseNode | ||||
| return value.replace("\x00", "") | return value.replace("\x00", "") | ||||
| def _check_boolean(self, value: bool | None, variable: str) -> bool | None: | |||||
| if value is None: | |||||
| return None | |||||
| if not isinstance(value, bool): | |||||
| raise OutputValidationError(f"Output variable `{variable}` must be a boolean") | |||||
| return value | |||||
| def _check_number(self, value: int | float | None, variable: str) -> int | float | None: | def _check_number(self, value: int | float | None, variable: str) -> int | float | None: | ||||
| """ | """ | ||||
| Check number | Check number | ||||
| prefix=f"{prefix}.{output_name}" if prefix else output_name, | prefix=f"{prefix}.{output_name}" if prefix else output_name, | ||||
| depth=depth + 1, | depth=depth + 1, | ||||
| ) | ) | ||||
| elif isinstance(output_value, bool): | |||||
| self._check_boolean(output_value, variable=f"{prefix}.{output_name}" if prefix else output_name) | |||||
| elif isinstance(output_value, int | float): | elif isinstance(output_value, int | float): | ||||
| self._check_number( | self._check_number( | ||||
| value=output_value, variable=f"{prefix}.{output_name}" if prefix else output_name | value=output_value, variable=f"{prefix}.{output_name}" if prefix else output_name | ||||
| if output_name not in result: | if output_name not in result: | ||||
| raise OutputValidationError(f"Output {prefix}{dot}{output_name} is missing.") | raise OutputValidationError(f"Output {prefix}{dot}{output_name} is missing.") | ||||
| if output_config.type == "object": | |||||
| if output_config.type == SegmentType.OBJECT: | |||||
| # check if output is object | # check if output is object | ||||
| if not isinstance(result.get(output_name), dict): | if not isinstance(result.get(output_name), dict): | ||||
| if result[output_name] is None: | if result[output_name] is None: | ||||
| prefix=f"{prefix}.{output_name}", | prefix=f"{prefix}.{output_name}", | ||||
| depth=depth + 1, | depth=depth + 1, | ||||
| ) | ) | ||||
| elif output_config.type == "number": | |||||
| elif output_config.type == SegmentType.NUMBER: | |||||
| # check if number available | # check if number available | ||||
| transformed_result[output_name] = self._check_number( | |||||
| value=result[output_name], variable=f"{prefix}{dot}{output_name}" | |||||
| ) | |||||
| elif output_config.type == "string": | |||||
| checked = self._check_number(value=result[output_name], variable=f"{prefix}{dot}{output_name}") | |||||
| # If the output is a boolean and the output schema specifies a NUMBER type, | |||||
| # convert the boolean value to an integer. | |||||
| # | |||||
| # This ensures compatibility with existing workflows that may use | |||||
| # `True` and `False` as values for NUMBER type outputs. | |||||
| transformed_result[output_name] = self._convert_boolean_to_int(checked) | |||||
| elif output_config.type == SegmentType.STRING: | |||||
| # check if string available | # check if string available | ||||
| transformed_result[output_name] = self._check_string( | transformed_result[output_name] = self._check_string( | ||||
| value=result[output_name], | value=result[output_name], | ||||
| variable=f"{prefix}{dot}{output_name}", | variable=f"{prefix}{dot}{output_name}", | ||||
| ) | ) | ||||
| elif output_config.type == "array[number]": | |||||
| elif output_config.type == SegmentType.BOOLEAN: | |||||
| transformed_result[output_name] = self._check_boolean( | |||||
| value=result[output_name], | |||||
| variable=f"{prefix}{dot}{output_name}", | |||||
| ) | |||||
| elif output_config.type == SegmentType.ARRAY_NUMBER: | |||||
| # check if array of number available | # check if array of number available | ||||
| if not isinstance(result[output_name], list): | if not isinstance(result[output_name], list): | ||||
| if result[output_name] is None: | if result[output_name] is None: | ||||
| ) | ) | ||||
| transformed_result[output_name] = [ | transformed_result[output_name] = [ | ||||
| self._check_number(value=value, variable=f"{prefix}{dot}{output_name}[{i}]") | |||||
| # If the element is a boolean and the output schema specifies a `array[number]` type, | |||||
| # convert the boolean value to an integer. | |||||
| # | |||||
| # This ensures compatibility with existing workflows that may use | |||||
| # `True` and `False` as values for NUMBER type outputs. | |||||
| self._convert_boolean_to_int( | |||||
| self._check_number(value=value, variable=f"{prefix}{dot}{output_name}[{i}]"), | |||||
| ) | |||||
| for i, value in enumerate(result[output_name]) | for i, value in enumerate(result[output_name]) | ||||
| ] | ] | ||||
| elif output_config.type == "array[string]": | |||||
| elif output_config.type == SegmentType.ARRAY_STRING: | |||||
| # check if array of string available | # check if array of string available | ||||
| if not isinstance(result[output_name], list): | if not isinstance(result[output_name], list): | ||||
| if result[output_name] is None: | if result[output_name] is None: | ||||
| self._check_string(value=value, variable=f"{prefix}{dot}{output_name}[{i}]") | self._check_string(value=value, variable=f"{prefix}{dot}{output_name}[{i}]") | ||||
| for i, value in enumerate(result[output_name]) | for i, value in enumerate(result[output_name]) | ||||
| ] | ] | ||||
| elif output_config.type == "array[object]": | |||||
| elif output_config.type == SegmentType.ARRAY_OBJECT: | |||||
| # check if array of object available | # check if array of object available | ||||
| if not isinstance(result[output_name], list): | if not isinstance(result[output_name], list): | ||||
| if result[output_name] is None: | if result[output_name] is None: | ||||
| ) | ) | ||||
| for i, value in enumerate(result[output_name]) | for i, value in enumerate(result[output_name]) | ||||
| ] | ] | ||||
| elif output_config.type == SegmentType.ARRAY_BOOLEAN: | |||||
| # check if array of object available | |||||
| if not isinstance(result[output_name], list): | |||||
| if result[output_name] is None: | |||||
| transformed_result[output_name] = None | |||||
| else: | |||||
| raise OutputValidationError( | |||||
| f"Output {prefix}{dot}{output_name} is not an array," | |||||
| f" got {type(result.get(output_name))} instead." | |||||
| ) | |||||
| else: | |||||
| transformed_result[output_name] = [ | |||||
| self._check_boolean(value=value, variable=f"{prefix}{dot}{output_name}[{i}]") | |||||
| for i, value in enumerate(result[output_name]) | |||||
| ] | |||||
| else: | else: | ||||
| raise OutputValidationError(f"Output type {output_config.type} is not supported.") | raise OutputValidationError(f"Output type {output_config.type} is not supported.") | ||||
| @property | @property | ||||
| def retry(self) -> bool: | def retry(self) -> bool: | ||||
| return self._node_data.retry_config.retry_enabled | return self._node_data.retry_config.retry_enabled | ||||
| @staticmethod | |||||
| def _convert_boolean_to_int(value: bool | int | float | None) -> int | float | None: | |||||
| """This function convert boolean to integers when the output schema specifies a NUMBER type. | |||||
| This ensures compatibility with existing workflows that may use | |||||
| `True` and `False` as values for NUMBER type outputs. | |||||
| """ | |||||
| if value is None: | |||||
| return None | |||||
| if isinstance(value, bool): | |||||
| return int(value) | |||||
| return value |
| from typing import Literal, Optional | |||||
| from typing import Annotated, Literal, Optional | |||||
| from pydantic import BaseModel | |||||
| from pydantic import AfterValidator, BaseModel | |||||
| from core.helper.code_executor.code_executor import CodeLanguage | from core.helper.code_executor.code_executor import CodeLanguage | ||||
| from core.variables.types import SegmentType | |||||
| from core.workflow.entities.variable_entities import VariableSelector | from core.workflow.entities.variable_entities import VariableSelector | ||||
| from core.workflow.nodes.base import BaseNodeData | from core.workflow.nodes.base import BaseNodeData | ||||
| _ALLOWED_OUTPUT_FROM_CODE = frozenset( | |||||
| [ | |||||
| SegmentType.STRING, | |||||
| SegmentType.NUMBER, | |||||
| SegmentType.OBJECT, | |||||
| SegmentType.BOOLEAN, | |||||
| SegmentType.ARRAY_STRING, | |||||
| SegmentType.ARRAY_NUMBER, | |||||
| SegmentType.ARRAY_OBJECT, | |||||
| SegmentType.ARRAY_BOOLEAN, | |||||
| ] | |||||
| ) | |||||
| def _validate_type(segment_type: SegmentType) -> SegmentType: | |||||
| if segment_type not in _ALLOWED_OUTPUT_FROM_CODE: | |||||
| raise ValueError(f"invalid type for code output, expected {_ALLOWED_OUTPUT_FROM_CODE}, actual {segment_type}") | |||||
| return segment_type | |||||
| class CodeNodeData(BaseNodeData): | class CodeNodeData(BaseNodeData): | ||||
| """ | """ | ||||
| """ | """ | ||||
| class Output(BaseModel): | class Output(BaseModel): | ||||
| type: Literal["string", "number", "object", "array[string]", "array[number]", "array[object]"] | |||||
| type: Annotated[SegmentType, AfterValidator(_validate_type)] | |||||
| children: Optional[dict[str, "CodeNodeData.Output"]] = None | children: Optional[dict[str, "CodeNodeData.Output"]] = None | ||||
| class Dependency(BaseModel): | class Dependency(BaseModel): |
| from collections.abc import Sequence | from collections.abc import Sequence | ||||
| from typing import Literal | |||||
| from enum import StrEnum | |||||
| from pydantic import BaseModel, Field | from pydantic import BaseModel, Field | ||||
| from core.workflow.nodes.base import BaseNodeData | from core.workflow.nodes.base import BaseNodeData | ||||
| _Condition = Literal[ | |||||
| class FilterOperator(StrEnum): | |||||
| # string conditions | # string conditions | ||||
| "contains", | |||||
| "start with", | |||||
| "end with", | |||||
| "is", | |||||
| "in", | |||||
| "empty", | |||||
| "not contains", | |||||
| "is not", | |||||
| "not in", | |||||
| "not empty", | |||||
| CONTAINS = "contains" | |||||
| START_WITH = "start with" | |||||
| END_WITH = "end with" | |||||
| IS = "is" | |||||
| IN = "in" | |||||
| EMPTY = "empty" | |||||
| NOT_CONTAINS = "not contains" | |||||
| IS_NOT = "is not" | |||||
| NOT_IN = "not in" | |||||
| NOT_EMPTY = "not empty" | |||||
| # number conditions | # number conditions | ||||
| "=", | |||||
| "≠", | |||||
| "<", | |||||
| ">", | |||||
| "≥", | |||||
| "≤", | |||||
| ] | |||||
| EQUAL = "=" | |||||
| NOT_EQUAL = "≠" | |||||
| LESS_THAN = "<" | |||||
| GREATER_THAN = ">" | |||||
| GREATER_THAN_OR_EQUAL = "≥" | |||||
| LESS_THAN_OR_EQUAL = "≤" | |||||
| class Order(StrEnum): | |||||
| ASC = "asc" | |||||
| DESC = "desc" | |||||
| class FilterCondition(BaseModel): | class FilterCondition(BaseModel): | ||||
| key: str = "" | key: str = "" | ||||
| comparison_operator: _Condition = "contains" | |||||
| value: str | Sequence[str] = "" | |||||
| comparison_operator: FilterOperator = FilterOperator.CONTAINS | |||||
| # the value is bool if the filter operator is comparing with | |||||
| # a boolean constant. | |||||
| value: str | Sequence[str] | bool = "" | |||||
| class FilterBy(BaseModel): | class FilterBy(BaseModel): | ||||
| conditions: Sequence[FilterCondition] = Field(default_factory=list) | conditions: Sequence[FilterCondition] = Field(default_factory=list) | ||||
| class OrderBy(BaseModel): | |||||
| class OrderByConfig(BaseModel): | |||||
| enabled: bool = False | enabled: bool = False | ||||
| key: str = "" | key: str = "" | ||||
| value: Literal["asc", "desc"] = "asc" | |||||
| value: Order = Order.ASC | |||||
| class Limit(BaseModel): | class Limit(BaseModel): | ||||
| class ListOperatorNodeData(BaseNodeData): | class ListOperatorNodeData(BaseNodeData): | ||||
| variable: Sequence[str] = Field(default_factory=list) | variable: Sequence[str] = Field(default_factory=list) | ||||
| filter_by: FilterBy | filter_by: FilterBy | ||||
| order_by: OrderBy | |||||
| order_by: OrderByConfig | |||||
| limit: Limit | limit: Limit | ||||
| extract_by: ExtractConfig = Field(default_factory=ExtractConfig) | extract_by: ExtractConfig = Field(default_factory=ExtractConfig) |
| from collections.abc import Callable, Mapping, Sequence | from collections.abc import Callable, Mapping, Sequence | ||||
| from typing import Any, Literal, Optional, Union | |||||
| from typing import Any, Optional, TypeAlias, TypeVar | |||||
| from core.file import File | from core.file import File | ||||
| from core.variables import ArrayFileSegment, ArrayNumberSegment, ArrayStringSegment | from core.variables import ArrayFileSegment, ArrayNumberSegment, ArrayStringSegment | ||||
| from core.variables.segments import ArrayAnySegment, ArraySegment | |||||
| from core.variables.segments import ArrayAnySegment, ArrayBooleanSegment, ArraySegment | |||||
| from core.workflow.entities.node_entities import NodeRunResult | from core.workflow.entities.node_entities import NodeRunResult | ||||
| from core.workflow.entities.workflow_node_execution import WorkflowNodeExecutionStatus | from core.workflow.entities.workflow_node_execution import WorkflowNodeExecutionStatus | ||||
| from core.workflow.nodes.base import BaseNode | from core.workflow.nodes.base import BaseNode | ||||
| from core.workflow.nodes.base.entities import BaseNodeData, RetryConfig | from core.workflow.nodes.base.entities import BaseNodeData, RetryConfig | ||||
| from core.workflow.nodes.enums import ErrorStrategy, NodeType | from core.workflow.nodes.enums import ErrorStrategy, NodeType | ||||
| from .entities import ListOperatorNodeData | |||||
| from .entities import FilterOperator, ListOperatorNodeData, Order | |||||
| from .exc import InvalidConditionError, InvalidFilterValueError, InvalidKeyError, ListOperatorError | from .exc import InvalidConditionError, InvalidFilterValueError, InvalidKeyError, ListOperatorError | ||||
| _SUPPORTED_TYPES_TUPLE = ( | |||||
| ArrayFileSegment, | |||||
| ArrayNumberSegment, | |||||
| ArrayStringSegment, | |||||
| ArrayBooleanSegment, | |||||
| ) | |||||
| _SUPPORTED_TYPES_ALIAS: TypeAlias = ArrayFileSegment | ArrayNumberSegment | ArrayStringSegment | ArrayBooleanSegment | |||||
| _T = TypeVar("_T") | |||||
| def _negation(filter_: Callable[[_T], bool]) -> Callable[[_T], bool]: | |||||
| """Returns the negation of a given filter function. If the original filter | |||||
| returns `True` for a value, the negated filter will return `False`, and vice versa. | |||||
| """ | |||||
| def wrapper(value: _T) -> bool: | |||||
| return not filter_(value) | |||||
| return wrapper | |||||
| class ListOperatorNode(BaseNode): | class ListOperatorNode(BaseNode): | ||||
| _node_type = NodeType.LIST_OPERATOR | _node_type = NodeType.LIST_OPERATOR | ||||
| process_data=process_data, | process_data=process_data, | ||||
| outputs=outputs, | outputs=outputs, | ||||
| ) | ) | ||||
| if not isinstance(variable, ArrayFileSegment | ArrayNumberSegment | ArrayStringSegment): | |||||
| error_message = ( | |||||
| f"Variable {self._node_data.variable} is not an ArrayFileSegment, ArrayNumberSegment " | |||||
| "or ArrayStringSegment" | |||||
| ) | |||||
| if not isinstance(variable, _SUPPORTED_TYPES_TUPLE): | |||||
| error_message = f"Variable {self._node_data.variable} is not an array type, actual type: {type(variable)}" | |||||
| return NodeRunResult( | return NodeRunResult( | ||||
| status=WorkflowNodeExecutionStatus.FAILED, error=error_message, inputs=inputs, outputs=outputs | status=WorkflowNodeExecutionStatus.FAILED, error=error_message, inputs=inputs, outputs=outputs | ||||
| ) | ) | ||||
| outputs=outputs, | outputs=outputs, | ||||
| ) | ) | ||||
| def _apply_filter( | |||||
| self, variable: Union[ArrayFileSegment, ArrayNumberSegment, ArrayStringSegment] | |||||
| ) -> Union[ArrayFileSegment, ArrayNumberSegment, ArrayStringSegment]: | |||||
| def _apply_filter(self, variable: _SUPPORTED_TYPES_ALIAS) -> _SUPPORTED_TYPES_ALIAS: | |||||
| filter_func: Callable[[Any], bool] | filter_func: Callable[[Any], bool] | ||||
| result: list[Any] = [] | result: list[Any] = [] | ||||
| for condition in self._node_data.filter_by.conditions: | for condition in self._node_data.filter_by.conditions: | ||||
| ) | ) | ||||
| result = list(filter(filter_func, variable.value)) | result = list(filter(filter_func, variable.value)) | ||||
| variable = variable.model_copy(update={"value": result}) | variable = variable.model_copy(update={"value": result}) | ||||
| elif isinstance(variable, ArrayBooleanSegment): | |||||
| if not isinstance(condition.value, bool): | |||||
| raise InvalidFilterValueError(f"Invalid filter value: {condition.value}") | |||||
| filter_func = _get_boolean_filter_func(condition=condition.comparison_operator, value=condition.value) | |||||
| result = list(filter(filter_func, variable.value)) | |||||
| variable = variable.model_copy(update={"value": result}) | |||||
| else: | |||||
| raise AssertionError("this statment should be unreachable.") | |||||
| return variable | return variable | ||||
| def _apply_order( | |||||
| self, variable: Union[ArrayFileSegment, ArrayNumberSegment, ArrayStringSegment] | |||||
| ) -> Union[ArrayFileSegment, ArrayNumberSegment, ArrayStringSegment]: | |||||
| if isinstance(variable, ArrayStringSegment): | |||||
| result = _order_string(order=self._node_data.order_by.value, array=variable.value) | |||||
| variable = variable.model_copy(update={"value": result}) | |||||
| elif isinstance(variable, ArrayNumberSegment): | |||||
| result = _order_number(order=self._node_data.order_by.value, array=variable.value) | |||||
| def _apply_order(self, variable: _SUPPORTED_TYPES_ALIAS) -> _SUPPORTED_TYPES_ALIAS: | |||||
| if isinstance(variable, (ArrayStringSegment, ArrayNumberSegment, ArrayBooleanSegment)): | |||||
| result = sorted(variable.value, reverse=self._node_data.order_by == Order.DESC) | |||||
| variable = variable.model_copy(update={"value": result}) | variable = variable.model_copy(update={"value": result}) | ||||
| elif isinstance(variable, ArrayFileSegment): | elif isinstance(variable, ArrayFileSegment): | ||||
| result = _order_file( | result = _order_file( | ||||
| order=self._node_data.order_by.value, order_by=self._node_data.order_by.key, array=variable.value | order=self._node_data.order_by.value, order_by=self._node_data.order_by.key, array=variable.value | ||||
| ) | ) | ||||
| variable = variable.model_copy(update={"value": result}) | variable = variable.model_copy(update={"value": result}) | ||||
| else: | |||||
| raise AssertionError("this statement should be unreachable") | |||||
| return variable | return variable | ||||
| def _apply_slice( | |||||
| self, variable: Union[ArrayFileSegment, ArrayNumberSegment, ArrayStringSegment] | |||||
| ) -> Union[ArrayFileSegment, ArrayNumberSegment, ArrayStringSegment]: | |||||
| def _apply_slice(self, variable: _SUPPORTED_TYPES_ALIAS) -> _SUPPORTED_TYPES_ALIAS: | |||||
| result = variable.value[: self._node_data.limit.size] | result = variable.value[: self._node_data.limit.size] | ||||
| return variable.model_copy(update={"value": result}) | return variable.model_copy(update={"value": result}) | ||||
| def _extract_slice( | |||||
| self, variable: Union[ArrayFileSegment, ArrayNumberSegment, ArrayStringSegment] | |||||
| ) -> Union[ArrayFileSegment, ArrayNumberSegment, ArrayStringSegment]: | |||||
| def _extract_slice(self, variable: _SUPPORTED_TYPES_ALIAS) -> _SUPPORTED_TYPES_ALIAS: | |||||
| value = int(self.graph_runtime_state.variable_pool.convert_template(self._node_data.extract_by.serial).text) | value = int(self.graph_runtime_state.variable_pool.convert_template(self._node_data.extract_by.serial).text) | ||||
| if value < 1: | if value < 1: | ||||
| raise ValueError(f"Invalid serial index: must be >= 1, got {value}") | raise ValueError(f"Invalid serial index: must be >= 1, got {value}") | ||||
| case "empty": | case "empty": | ||||
| return lambda x: x == "" | return lambda x: x == "" | ||||
| case "not contains": | case "not contains": | ||||
| return lambda x: not _contains(value)(x) | |||||
| return _negation(_contains(value)) | |||||
| case "is not": | case "is not": | ||||
| return lambda x: not _is(value)(x) | |||||
| return _negation(_is(value)) | |||||
| case "not in": | case "not in": | ||||
| return lambda x: not _in(value)(x) | |||||
| return _negation(_in(value)) | |||||
| case "not empty": | case "not empty": | ||||
| return lambda x: x != "" | return lambda x: x != "" | ||||
| case _: | case _: | ||||
| case "in": | case "in": | ||||
| return _in(value) | return _in(value) | ||||
| case "not in": | case "not in": | ||||
| return lambda x: not _in(value)(x) | |||||
| return _negation(_in(value)) | |||||
| case _: | case _: | ||||
| raise InvalidConditionError(f"Invalid condition: {condition}") | raise InvalidConditionError(f"Invalid condition: {condition}") | ||||
| raise InvalidConditionError(f"Invalid condition: {condition}") | raise InvalidConditionError(f"Invalid condition: {condition}") | ||||
| def _get_boolean_filter_func(*, condition: FilterOperator, value: bool) -> Callable[[bool], bool]: | |||||
| match condition: | |||||
| case FilterOperator.IS: | |||||
| return _is(value) | |||||
| case FilterOperator.IS_NOT: | |||||
| return _negation(_is(value)) | |||||
| case _: | |||||
| raise InvalidConditionError(f"Invalid condition: {condition}") | |||||
| def _get_file_filter_func(*, key: str, condition: str, value: str | Sequence[str]) -> Callable[[File], bool]: | def _get_file_filter_func(*, key: str, condition: str, value: str | Sequence[str]) -> Callable[[File], bool]: | ||||
| extract_func: Callable[[File], Any] | extract_func: Callable[[File], Any] | ||||
| if key in {"name", "extension", "mime_type", "url"} and isinstance(value, str): | if key in {"name", "extension", "mime_type", "url"} and isinstance(value, str): | ||||
| return lambda x: x.endswith(value) | return lambda x: x.endswith(value) | ||||
| def _is(value: str) -> Callable[[str], bool]: | |||||
| def _is(value: _T) -> Callable[[_T], bool]: | |||||
| return lambda x: x == value | return lambda x: x == value | ||||
| return lambda x: x >= value | return lambda x: x >= value | ||||
| def _order_number(*, order: Literal["asc", "desc"], array: Sequence[int | float]): | |||||
| return sorted(array, key=lambda x: x, reverse=order == "desc") | |||||
| def _order_string(*, order: Literal["asc", "desc"], array: Sequence[str]): | |||||
| return sorted(array, key=lambda x: x, reverse=order == "desc") | |||||
| def _order_file(*, order: Literal["asc", "desc"], order_by: str = "", array: Sequence[File]): | |||||
| def _order_file(*, order: Order, order_by: str = "", array: Sequence[File]): | |||||
| extract_func: Callable[[File], Any] | extract_func: Callable[[File], Any] | ||||
| if order_by in {"name", "type", "extension", "mime_type", "transfer_method", "url"}: | if order_by in {"name", "type", "extension", "mime_type", "transfer_method", "url"}: | ||||
| extract_func = _get_file_extract_string_func(key=order_by) | extract_func = _get_file_extract_string_func(key=order_by) | ||||
| return sorted(array, key=lambda x: extract_func(x), reverse=order == "desc") | |||||
| return sorted(array, key=lambda x: extract_func(x), reverse=order == Order.DESC) | |||||
| elif order_by == "size": | elif order_by == "size": | ||||
| extract_func = _get_file_extract_number_func(key=order_by) | extract_func = _get_file_extract_number_func(key=order_by) | ||||
| return sorted(array, key=lambda x: extract_func(x), reverse=order == "desc") | |||||
| return sorted(array, key=lambda x: extract_func(x), reverse=order == Order.DESC) | |||||
| else: | else: | ||||
| raise InvalidKeyError(f"Invalid order key: {order_by}") | raise InvalidKeyError(f"Invalid order key: {order_by}") |
| import json | import json | ||||
| import logging | import logging | ||||
| from collections.abc import Generator, Mapping, Sequence | from collections.abc import Generator, Mapping, Sequence | ||||
| from typing import TYPE_CHECKING, Any, Optional | |||||
| from typing import TYPE_CHECKING, Any, Optional, Union | |||||
| from core.app.entities.app_invoke_entities import ModelConfigWithCredentialsEntity | from core.app.entities.app_invoke_entities import ModelConfigWithCredentialsEntity | ||||
| from core.file import FileType, file_manager | from core.file import FileType, file_manager | ||||
| from core.workflow.entities.variable_pool import VariablePool | from core.workflow.entities.variable_pool import VariablePool | ||||
| from core.workflow.entities.workflow_node_execution import WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus | from core.workflow.entities.workflow_node_execution import WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus | ||||
| from core.workflow.enums import SystemVariableKey | from core.workflow.enums import SystemVariableKey | ||||
| from core.workflow.graph_engine.entities.event import InNodeEvent | |||||
| from core.workflow.nodes.base import BaseNode | from core.workflow.nodes.base import BaseNode | ||||
| from core.workflow.nodes.base.entities import BaseNodeData, RetryConfig | from core.workflow.nodes.base.entities import BaseNodeData, RetryConfig | ||||
| from core.workflow.nodes.enums import ErrorStrategy, NodeType | from core.workflow.nodes.enums import ErrorStrategy, NodeType | ||||
| if TYPE_CHECKING: | if TYPE_CHECKING: | ||||
| from core.file.models import File | from core.file.models import File | ||||
| from core.workflow.graph_engine import Graph, GraphInitParams, GraphRuntimeState | from core.workflow.graph_engine import Graph, GraphInitParams, GraphRuntimeState | ||||
| from core.workflow.graph_engine.entities.event import InNodeEvent | |||||
| logger = logging.getLogger(__name__) | logger = logging.getLogger(__name__) | ||||
| def version(cls) -> str: | def version(cls) -> str: | ||||
| return "1" | return "1" | ||||
| def _run(self) -> Generator[NodeEvent | InNodeEvent, None, None]: | |||||
| def _run(self) -> Generator[Union[NodeEvent, "InNodeEvent"], None, None]: | |||||
| node_inputs: Optional[dict[str, Any]] = None | node_inputs: Optional[dict[str, Any]] = None | ||||
| process_data = None | process_data = None | ||||
| result_text = "" | result_text = "" |
| SegmentType.STRING, | SegmentType.STRING, | ||||
| SegmentType.NUMBER, | SegmentType.NUMBER, | ||||
| SegmentType.OBJECT, | SegmentType.OBJECT, | ||||
| SegmentType.BOOLEAN, | |||||
| SegmentType.ARRAY_STRING, | SegmentType.ARRAY_STRING, | ||||
| SegmentType.ARRAY_NUMBER, | SegmentType.ARRAY_NUMBER, | ||||
| SegmentType.ARRAY_OBJECT, | SegmentType.ARRAY_OBJECT, | ||||
| SegmentType.ARRAY_BOOLEAN, | |||||
| ] | ] | ||||
| ) | ) | ||||
| for node_id in loop_graph.node_ids: | for node_id in loop_graph.node_ids: | ||||
| variable_pool.remove([node_id]) | variable_pool.remove([node_id]) | ||||
| _outputs = {} | |||||
| _outputs: dict[str, Segment | int | None] = {} | |||||
| for loop_variable_key, loop_variable_selector in loop_variable_selectors.items(): | for loop_variable_key, loop_variable_selector in loop_variable_selectors.items(): | ||||
| _loop_variable_segment = variable_pool.get(loop_variable_selector) | _loop_variable_segment = variable_pool.get(loop_variable_selector) | ||||
| if _loop_variable_segment: | if _loop_variable_segment: | ||||
| _outputs[loop_variable_key] = _loop_variable_segment.value | |||||
| _outputs[loop_variable_key] = _loop_variable_segment | |||||
| else: | else: | ||||
| _outputs[loop_variable_key] = None | _outputs[loop_variable_key] = None | ||||
| return variable_mapping | return variable_mapping | ||||
| @staticmethod | @staticmethod | ||||
| def _get_segment_for_constant(var_type: SegmentType, value: Any) -> Segment: | |||||
| def _get_segment_for_constant(var_type: SegmentType, original_value: Any) -> Segment: | |||||
| """Get the appropriate segment type for a constant value.""" | """Get the appropriate segment type for a constant value.""" | ||||
| if var_type in ["array[string]", "array[number]", "array[object]"]: | |||||
| if value and isinstance(value, str): | |||||
| value = json.loads(value) | |||||
| if var_type in [ | |||||
| SegmentType.ARRAY_NUMBER, | |||||
| SegmentType.ARRAY_OBJECT, | |||||
| SegmentType.ARRAY_STRING, | |||||
| ]: | |||||
| if original_value and isinstance(original_value, str): | |||||
| value = json.loads(original_value) | |||||
| else: | else: | ||||
| logger.warning("unexpected value for LoopNode, value_type=%s, value=%s", original_value, var_type) | |||||
| value = [] | value = [] | ||||
| elif var_type == SegmentType.ARRAY_BOOLEAN: | |||||
| value = original_value | |||||
| else: | |||||
| raise AssertionError("this statement should be unreachable.") | |||||
| try: | try: | ||||
| return build_segment_with_type(var_type, value) | |||||
| return build_segment_with_type(var_type, value=value) | |||||
| except TypeMismatchError as type_exc: | except TypeMismatchError as type_exc: | ||||
| # Attempt to parse the value as a JSON-encoded string, if applicable. | # Attempt to parse the value as a JSON-encoded string, if applicable. | ||||
| if not isinstance(value, str): | |||||
| if not isinstance(original_value, str): | |||||
| raise | raise | ||||
| try: | try: | ||||
| value = json.loads(value) | |||||
| value = json.loads(original_value) | |||||
| except ValueError: | except ValueError: | ||||
| raise type_exc | raise type_exc | ||||
| return build_segment_with_type(var_type, value) | return build_segment_with_type(var_type, value) |
| from typing import Any, Literal, Optional | |||||
| from typing import Annotated, Any, Literal, Optional | |||||
| from pydantic import BaseModel, Field, field_validator | |||||
| from pydantic import ( | |||||
| BaseModel, | |||||
| BeforeValidator, | |||||
| Field, | |||||
| field_validator, | |||||
| ) | |||||
| from core.prompt.entities.advanced_prompt_entities import MemoryConfig | from core.prompt.entities.advanced_prompt_entities import MemoryConfig | ||||
| from core.variables.types import SegmentType | |||||
| from core.workflow.nodes.base import BaseNodeData | from core.workflow.nodes.base import BaseNodeData | ||||
| from core.workflow.nodes.llm import ModelConfig, VisionConfig | |||||
| from core.workflow.nodes.llm.entities import ModelConfig, VisionConfig | |||||
| _OLD_BOOL_TYPE_NAME = "bool" | |||||
| _OLD_SELECT_TYPE_NAME = "select" | |||||
| _VALID_PARAMETER_TYPES = frozenset( | |||||
| [ | |||||
| SegmentType.STRING, # "string", | |||||
| SegmentType.NUMBER, # "number", | |||||
| SegmentType.BOOLEAN, | |||||
| SegmentType.ARRAY_STRING, | |||||
| SegmentType.ARRAY_NUMBER, | |||||
| SegmentType.ARRAY_OBJECT, | |||||
| SegmentType.ARRAY_BOOLEAN, | |||||
| _OLD_BOOL_TYPE_NAME, # old boolean type used by Parameter Extractor node | |||||
| _OLD_SELECT_TYPE_NAME, # string type with enumeration choices. | |||||
| ] | |||||
| ) | |||||
| def _validate_type(parameter_type: str) -> SegmentType: | |||||
| if not isinstance(parameter_type, str): | |||||
| raise TypeError(f"type should be str, got {type(parameter_type)}, value={parameter_type}") | |||||
| if parameter_type not in _VALID_PARAMETER_TYPES: | |||||
| raise ValueError(f"type {parameter_type} is not allowd to use in Parameter Extractor node.") | |||||
| if parameter_type == _OLD_BOOL_TYPE_NAME: | |||||
| return SegmentType.BOOLEAN | |||||
| elif parameter_type == _OLD_SELECT_TYPE_NAME: | |||||
| return SegmentType.STRING | |||||
| return SegmentType(parameter_type) | |||||
| class _ParameterConfigError(Exception): | class _ParameterConfigError(Exception): | ||||
| """ | """ | ||||
| name: str | name: str | ||||
| type: Literal["string", "number", "bool", "select", "array[string]", "array[number]", "array[object]"] | |||||
| type: Annotated[SegmentType, BeforeValidator(_validate_type)] | |||||
| options: Optional[list[str]] = None | options: Optional[list[str]] = None | ||||
| description: str | description: str | ||||
| required: bool | required: bool | ||||
| return str(value) | return str(value) | ||||
| def is_array_type(self) -> bool: | def is_array_type(self) -> bool: | ||||
| return self.type in ("array[string]", "array[number]", "array[object]") | |||||
| return self.type.is_array_type() | |||||
| def element_type(self) -> Literal["string", "number", "object"]: | |||||
| if self.type == "array[number]": | |||||
| return "number" | |||||
| elif self.type == "array[string]": | |||||
| return "string" | |||||
| elif self.type == "array[object]": | |||||
| return "object" | |||||
| else: | |||||
| raise _ParameterConfigError(f"{self.type} is not array type.") | |||||
| def element_type(self) -> SegmentType: | |||||
| """Return the element type of the parameter. | |||||
| Raises a ValueError if the parameter's type is not an array type. | |||||
| """ | |||||
| element_type = self.type.element_type() | |||||
| # At this point, self.type is guaranteed to be one of `ARRAY_STRING`, | |||||
| # `ARRAY_NUMBER`, `ARRAY_OBJECT`, or `ARRAY_BOOLEAN`. | |||||
| # | |||||
| # See: _VALID_PARAMETER_TYPES for reference. | |||||
| assert element_type is not None, f"the element type should not be None, {self.type=}" | |||||
| return element_type | |||||
| class ParameterExtractorNodeData(BaseNodeData): | class ParameterExtractorNodeData(BaseNodeData): | ||||
| for parameter in self.parameters: | for parameter in self.parameters: | ||||
| parameter_schema: dict[str, Any] = {"description": parameter.description} | parameter_schema: dict[str, Any] = {"description": parameter.description} | ||||
| if parameter.type in {"string", "select"}: | |||||
| if parameter.type == SegmentType.STRING: | |||||
| parameter_schema["type"] = "string" | parameter_schema["type"] = "string" | ||||
| elif parameter.type.startswith("array"): | |||||
| elif parameter.type.is_array_type(): | |||||
| parameter_schema["type"] = "array" | parameter_schema["type"] = "array" | ||||
| nested_type = parameter.type[6:-1] | |||||
| parameter_schema["items"] = {"type": nested_type} | |||||
| element_type = parameter.type.element_type() | |||||
| if element_type is None: | |||||
| raise AssertionError("element type should not be None.") | |||||
| parameter_schema["items"] = {"type": element_type.value} | |||||
| else: | else: | ||||
| parameter_schema["type"] = parameter.type | parameter_schema["type"] = parameter.type | ||||
| if parameter.type == "select": | |||||
| if parameter.options: | |||||
| parameter_schema["enum"] = parameter.options | parameter_schema["enum"] = parameter.options | ||||
| parameters["properties"][parameter.name] = parameter_schema | parameters["properties"][parameter.name] = parameter_schema |
| from typing import Any | |||||
| from core.variables.types import SegmentType | |||||
| class ParameterExtractorNodeError(ValueError): | class ParameterExtractorNodeError(ValueError): | ||||
| """Base error for ParameterExtractorNode.""" | """Base error for ParameterExtractorNode.""" | ||||
| class InvalidModelModeError(ParameterExtractorNodeError): | class InvalidModelModeError(ParameterExtractorNodeError): | ||||
| """Raised when the model mode is invalid.""" | """Raised when the model mode is invalid.""" | ||||
| class InvalidValueTypeError(ParameterExtractorNodeError): | |||||
| def __init__( | |||||
| self, | |||||
| /, | |||||
| parameter_name: str, | |||||
| expected_type: SegmentType, | |||||
| actual_type: SegmentType | None, | |||||
| value: Any, | |||||
| ) -> None: | |||||
| message = ( | |||||
| f"Invalid value for parameter {parameter_name}, expected segment type: {expected_type}, " | |||||
| f"actual_type: {actual_type}, python_type: {type(value)}, value: {value}" | |||||
| ) | |||||
| super().__init__(message) | |||||
| self.parameter_name = parameter_name | |||||
| self.expected_type = expected_type | |||||
| self.actual_type = actual_type | |||||
| self.value = value |
| from core.prompt.entities.advanced_prompt_entities import ChatModelMessage, CompletionModelPromptTemplate | from core.prompt.entities.advanced_prompt_entities import ChatModelMessage, CompletionModelPromptTemplate | ||||
| from core.prompt.simple_prompt_transform import ModelMode | from core.prompt.simple_prompt_transform import ModelMode | ||||
| from core.prompt.utils.prompt_message_util import PromptMessageUtil | from core.prompt.utils.prompt_message_util import PromptMessageUtil | ||||
| from core.variables.types import SegmentType | |||||
| from core.variables.types import ArrayValidation, SegmentType | |||||
| from core.workflow.entities.node_entities import NodeRunResult | from core.workflow.entities.node_entities import NodeRunResult | ||||
| from core.workflow.entities.variable_pool import VariablePool | from core.workflow.entities.variable_pool import VariablePool | ||||
| from core.workflow.entities.workflow_node_execution import WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus | from core.workflow.entities.workflow_node_execution import WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus | ||||
| from .entities import ParameterExtractorNodeData | from .entities import ParameterExtractorNodeData | ||||
| from .exc import ( | from .exc import ( | ||||
| InvalidArrayValueError, | |||||
| InvalidBoolValueError, | |||||
| InvalidInvokeResultError, | InvalidInvokeResultError, | ||||
| InvalidModelModeError, | InvalidModelModeError, | ||||
| InvalidModelTypeError, | InvalidModelTypeError, | ||||
| InvalidNumberOfParametersError, | InvalidNumberOfParametersError, | ||||
| InvalidNumberValueError, | |||||
| InvalidSelectValueError, | InvalidSelectValueError, | ||||
| InvalidStringValueError, | |||||
| InvalidTextContentTypeError, | InvalidTextContentTypeError, | ||||
| InvalidValueTypeError, | |||||
| ModelSchemaNotFoundError, | ModelSchemaNotFoundError, | ||||
| ParameterExtractorNodeError, | ParameterExtractorNodeError, | ||||
| RequiredParameterMissingError, | RequiredParameterMissingError, | ||||
| return prompt_messages | return prompt_messages | ||||
| def _validate_result(self, data: ParameterExtractorNodeData, result: dict) -> dict: | def _validate_result(self, data: ParameterExtractorNodeData, result: dict) -> dict: | ||||
| """ | |||||
| Validate result. | |||||
| """ | |||||
| if len(data.parameters) != len(result): | if len(data.parameters) != len(result): | ||||
| raise InvalidNumberOfParametersError("Invalid number of parameters") | raise InvalidNumberOfParametersError("Invalid number of parameters") | ||||
| if parameter.required and parameter.name not in result: | if parameter.required and parameter.name not in result: | ||||
| raise RequiredParameterMissingError(f"Parameter {parameter.name} is required") | raise RequiredParameterMissingError(f"Parameter {parameter.name} is required") | ||||
| if parameter.type == "select" and parameter.options and result.get(parameter.name) not in parameter.options: | |||||
| raise InvalidSelectValueError(f"Invalid `select` value for parameter {parameter.name}") | |||||
| if parameter.type == "number" and not isinstance(result.get(parameter.name), int | float): | |||||
| raise InvalidNumberValueError(f"Invalid `number` value for parameter {parameter.name}") | |||||
| if parameter.type == "bool" and not isinstance(result.get(parameter.name), bool): | |||||
| raise InvalidBoolValueError(f"Invalid `bool` value for parameter {parameter.name}") | |||||
| if parameter.type == "string" and not isinstance(result.get(parameter.name), str): | |||||
| raise InvalidStringValueError(f"Invalid `string` value for parameter {parameter.name}") | |||||
| if parameter.type.startswith("array"): | |||||
| parameters = result.get(parameter.name) | |||||
| if not isinstance(parameters, list): | |||||
| raise InvalidArrayValueError(f"Invalid `array` value for parameter {parameter.name}") | |||||
| nested_type = parameter.type[6:-1] | |||||
| for item in parameters: | |||||
| if nested_type == "number" and not isinstance(item, int | float): | |||||
| raise InvalidArrayValueError(f"Invalid `array[number]` value for parameter {parameter.name}") | |||||
| if nested_type == "string" and not isinstance(item, str): | |||||
| raise InvalidArrayValueError(f"Invalid `array[string]` value for parameter {parameter.name}") | |||||
| if nested_type == "object" and not isinstance(item, dict): | |||||
| raise InvalidArrayValueError(f"Invalid `array[object]` value for parameter {parameter.name}") | |||||
| param_value = result.get(parameter.name) | |||||
| if not parameter.type.is_valid(param_value, array_validation=ArrayValidation.ALL): | |||||
| inferred_type = SegmentType.infer_segment_type(param_value) | |||||
| raise InvalidValueTypeError( | |||||
| parameter_name=parameter.name, | |||||
| expected_type=parameter.type, | |||||
| actual_type=inferred_type, | |||||
| value=param_value, | |||||
| ) | |||||
| if parameter.type == SegmentType.STRING and parameter.options: | |||||
| if param_value not in parameter.options: | |||||
| raise InvalidSelectValueError(f"Invalid `select` value for parameter {parameter.name}") | |||||
| return result | return result | ||||
| @staticmethod | |||||
| def _transform_number(value: int | float | str | bool) -> int | float | None: | |||||
| """ | |||||
| Attempts to transform the input into an integer or float. | |||||
| Returns: | |||||
| int or float: The transformed number if the conversion is successful. | |||||
| None: If the transformation fails. | |||||
| Note: | |||||
| Boolean values `True` and `False` are converted to integers `1` and `0`, respectively. | |||||
| This behavior ensures compatibility with existing workflows that may use boolean types as integers. | |||||
| """ | |||||
| if isinstance(value, bool): | |||||
| return int(value) | |||||
| elif isinstance(value, (int, float)): | |||||
| return value | |||||
| elif not isinstance(value, str): | |||||
| return None | |||||
| if "." in value: | |||||
| try: | |||||
| return float(value) | |||||
| except ValueError: | |||||
| return None | |||||
| else: | |||||
| try: | |||||
| return int(value) | |||||
| except ValueError: | |||||
| return None | |||||
| def _transform_result(self, data: ParameterExtractorNodeData, result: dict) -> dict: | def _transform_result(self, data: ParameterExtractorNodeData, result: dict) -> dict: | ||||
| """ | """ | ||||
| Transform result into standard format. | Transform result into standard format. | ||||
| """ | """ | ||||
| transformed_result = {} | |||||
| transformed_result: dict[str, Any] = {} | |||||
| for parameter in data.parameters: | for parameter in data.parameters: | ||||
| if parameter.name in result: | if parameter.name in result: | ||||
| param_value = result[parameter.name] | |||||
| # transform value | # transform value | ||||
| if parameter.type == "number": | |||||
| if isinstance(result[parameter.name], int | float): | |||||
| transformed_result[parameter.name] = result[parameter.name] | |||||
| elif isinstance(result[parameter.name], str): | |||||
| try: | |||||
| if "." in result[parameter.name]: | |||||
| result[parameter.name] = float(result[parameter.name]) | |||||
| else: | |||||
| result[parameter.name] = int(result[parameter.name]) | |||||
| except ValueError: | |||||
| pass | |||||
| else: | |||||
| pass | |||||
| # TODO: bool is not supported in the current version | |||||
| # elif parameter.type == 'bool': | |||||
| # if isinstance(result[parameter.name], bool): | |||||
| # transformed_result[parameter.name] = bool(result[parameter.name]) | |||||
| # elif isinstance(result[parameter.name], str): | |||||
| # if result[parameter.name].lower() in ['true', 'false']: | |||||
| # transformed_result[parameter.name] = bool(result[parameter.name].lower() == 'true') | |||||
| # elif isinstance(result[parameter.name], int): | |||||
| # transformed_result[parameter.name] = bool(result[parameter.name]) | |||||
| elif parameter.type in {"string", "select"}: | |||||
| if isinstance(result[parameter.name], str): | |||||
| transformed_result[parameter.name] = result[parameter.name] | |||||
| if parameter.type == SegmentType.NUMBER: | |||||
| transformed = self._transform_number(param_value) | |||||
| if transformed is not None: | |||||
| transformed_result[parameter.name] = transformed | |||||
| elif parameter.type == SegmentType.BOOLEAN: | |||||
| if isinstance(result[parameter.name], (bool, int)): | |||||
| transformed_result[parameter.name] = bool(result[parameter.name]) | |||||
| # elif isinstance(result[parameter.name], str): | |||||
| # if result[parameter.name].lower() in ["true", "false"]: | |||||
| # transformed_result[parameter.name] = bool(result[parameter.name].lower() == "true") | |||||
| elif parameter.type == SegmentType.STRING: | |||||
| if isinstance(param_value, str): | |||||
| transformed_result[parameter.name] = param_value | |||||
| elif parameter.is_array_type(): | elif parameter.is_array_type(): | ||||
| if isinstance(result[parameter.name], list): | |||||
| if isinstance(param_value, list): | |||||
| nested_type = parameter.element_type() | nested_type = parameter.element_type() | ||||
| assert nested_type is not None | assert nested_type is not None | ||||
| segment_value = build_segment_with_type(segment_type=SegmentType(parameter.type), value=[]) | segment_value = build_segment_with_type(segment_type=SegmentType(parameter.type), value=[]) | ||||
| transformed_result[parameter.name] = segment_value | transformed_result[parameter.name] = segment_value | ||||
| for item in result[parameter.name]: | |||||
| if nested_type == "number": | |||||
| if isinstance(item, int | float): | |||||
| segment_value.value.append(item) | |||||
| elif isinstance(item, str): | |||||
| try: | |||||
| if "." in item: | |||||
| segment_value.value.append(float(item)) | |||||
| else: | |||||
| segment_value.value.append(int(item)) | |||||
| except ValueError: | |||||
| pass | |||||
| elif nested_type == "string": | |||||
| for item in param_value: | |||||
| if nested_type == SegmentType.NUMBER: | |||||
| transformed = self._transform_number(item) | |||||
| if transformed is not None: | |||||
| segment_value.value.append(transformed) | |||||
| elif nested_type == SegmentType.STRING: | |||||
| if isinstance(item, str): | if isinstance(item, str): | ||||
| segment_value.value.append(item) | segment_value.value.append(item) | ||||
| elif nested_type == "object": | |||||
| elif nested_type == SegmentType.OBJECT: | |||||
| if isinstance(item, dict): | if isinstance(item, dict): | ||||
| segment_value.value.append(item) | segment_value.value.append(item) | ||||
| elif nested_type == SegmentType.BOOLEAN: | |||||
| if isinstance(item, bool): | |||||
| segment_value.value.append(item) | |||||
| if parameter.name not in transformed_result: | if parameter.name not in transformed_result: | ||||
| if parameter.type == "number": | |||||
| transformed_result[parameter.name] = 0 | |||||
| elif parameter.type == "bool": | |||||
| transformed_result[parameter.name] = False | |||||
| elif parameter.type in {"string", "select"}: | |||||
| transformed_result[parameter.name] = "" | |||||
| elif parameter.type.startswith("array"): | |||||
| if parameter.type.is_array_type(): | |||||
| transformed_result[parameter.name] = build_segment_with_type( | transformed_result[parameter.name] = build_segment_with_type( | ||||
| segment_type=SegmentType(parameter.type), value=[] | segment_type=SegmentType(parameter.type), value=[] | ||||
| ) | ) | ||||
| elif parameter.type in (SegmentType.STRING, SegmentType.SECRET): | |||||
| transformed_result[parameter.name] = "" | |||||
| elif parameter.type == SegmentType.NUMBER: | |||||
| transformed_result[parameter.name] = 0 | |||||
| elif parameter.type == SegmentType.BOOLEAN: | |||||
| transformed_result[parameter.name] = False | |||||
| else: | |||||
| raise AssertionError("this statement should be unreachable.") | |||||
| return transformed_result | return transformed_result | ||||
| from typing import TYPE_CHECKING, Any, Optional, TypeAlias | from typing import TYPE_CHECKING, Any, Optional, TypeAlias | ||||
| from core.variables import SegmentType, Variable | from core.variables import SegmentType, Variable | ||||
| from core.variables.segments import BooleanSegment | |||||
| from core.workflow.constants import CONVERSATION_VARIABLE_NODE_ID | from core.workflow.constants import CONVERSATION_VARIABLE_NODE_ID | ||||
| from core.workflow.conversation_variable_updater import ConversationVariableUpdater | from core.workflow.conversation_variable_updater import ConversationVariableUpdater | ||||
| from core.workflow.entities.node_entities import NodeRunResult | from core.workflow.entities.node_entities import NodeRunResult | ||||
| def get_zero_value(t: SegmentType): | def get_zero_value(t: SegmentType): | ||||
| # TODO(QuantumGhost): this should be a method of `SegmentType`. | # TODO(QuantumGhost): this should be a method of `SegmentType`. | ||||
| match t: | match t: | ||||
| case SegmentType.ARRAY_OBJECT | SegmentType.ARRAY_STRING | SegmentType.ARRAY_NUMBER: | |||||
| return variable_factory.build_segment([]) | |||||
| case SegmentType.ARRAY_OBJECT | SegmentType.ARRAY_STRING | SegmentType.ARRAY_NUMBER | SegmentType.ARRAY_BOOLEAN: | |||||
| return variable_factory.build_segment_with_type(t, []) | |||||
| case SegmentType.OBJECT: | case SegmentType.OBJECT: | ||||
| return variable_factory.build_segment({}) | return variable_factory.build_segment({}) | ||||
| case SegmentType.STRING: | case SegmentType.STRING: | ||||
| return variable_factory.build_segment(0.0) | return variable_factory.build_segment(0.0) | ||||
| case SegmentType.NUMBER: | case SegmentType.NUMBER: | ||||
| return variable_factory.build_segment(0) | return variable_factory.build_segment(0) | ||||
| case SegmentType.BOOLEAN: | |||||
| return BooleanSegment(value=False) | |||||
| case _: | case _: | ||||
| raise VariableOperatorNodeError(f"unsupported variable type: {t}") | raise VariableOperatorNodeError(f"unsupported variable type: {t}") |
| EMPTY_VALUE_MAPPING = { | EMPTY_VALUE_MAPPING = { | ||||
| SegmentType.STRING: "", | SegmentType.STRING: "", | ||||
| SegmentType.NUMBER: 0, | SegmentType.NUMBER: 0, | ||||
| SegmentType.BOOLEAN: False, | |||||
| SegmentType.OBJECT: {}, | SegmentType.OBJECT: {}, | ||||
| SegmentType.ARRAY_ANY: [], | SegmentType.ARRAY_ANY: [], | ||||
| SegmentType.ARRAY_STRING: [], | SegmentType.ARRAY_STRING: [], | ||||
| SegmentType.ARRAY_NUMBER: [], | SegmentType.ARRAY_NUMBER: [], | ||||
| SegmentType.ARRAY_OBJECT: [], | SegmentType.ARRAY_OBJECT: [], | ||||
| SegmentType.ARRAY_BOOLEAN: [], | |||||
| } | } |
| SegmentType.NUMBER, | SegmentType.NUMBER, | ||||
| SegmentType.INTEGER, | SegmentType.INTEGER, | ||||
| SegmentType.FLOAT, | SegmentType.FLOAT, | ||||
| SegmentType.BOOLEAN, | |||||
| } | } | ||||
| case Operation.ADD | Operation.SUBTRACT | Operation.MULTIPLY | Operation.DIVIDE: | case Operation.ADD | Operation.SUBTRACT | Operation.MULTIPLY | Operation.DIVIDE: | ||||
| # Only number variable can be added, subtracted, multiplied or divided | # Only number variable can be added, subtracted, multiplied or divided | ||||
| return variable_type in {SegmentType.NUMBER, SegmentType.INTEGER, SegmentType.FLOAT} | return variable_type in {SegmentType.NUMBER, SegmentType.INTEGER, SegmentType.FLOAT} | ||||
| case Operation.APPEND | Operation.EXTEND: | |||||
| case Operation.APPEND | Operation.EXTEND | Operation.REMOVE_FIRST | Operation.REMOVE_LAST: | |||||
| # Only array variable can be appended or extended | # 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 Operation.REMOVE_FIRST | Operation.REMOVE_LAST: | |||||
| # Only array variable can have elements removed | # Only array variable can have elements removed | ||||
| return variable_type in { | |||||
| SegmentType.ARRAY_ANY, | |||||
| SegmentType.ARRAY_OBJECT, | |||||
| SegmentType.ARRAY_STRING, | |||||
| SegmentType.ARRAY_NUMBER, | |||||
| SegmentType.ARRAY_FILE, | |||||
| } | |||||
| return variable_type.is_array_type() | |||||
| case _: | case _: | ||||
| return False | return False | ||||
| def is_constant_input_supported(*, variable_type: SegmentType, operation: Operation): | def is_constant_input_supported(*, variable_type: SegmentType, operation: Operation): | ||||
| match variable_type: | match variable_type: | ||||
| case SegmentType.STRING | SegmentType.OBJECT: | |||||
| case SegmentType.STRING | SegmentType.OBJECT | SegmentType.BOOLEAN: | |||||
| return operation in {Operation.OVER_WRITE, Operation.SET} | return operation in {Operation.OVER_WRITE, Operation.SET} | ||||
| case SegmentType.NUMBER | SegmentType.INTEGER | SegmentType.FLOAT: | case SegmentType.NUMBER | SegmentType.INTEGER | SegmentType.FLOAT: | ||||
| return operation in { | return operation in { | ||||
| case SegmentType.STRING: | case SegmentType.STRING: | ||||
| return isinstance(value, str) | return isinstance(value, str) | ||||
| case SegmentType.BOOLEAN: | |||||
| return isinstance(value, bool) | |||||
| case SegmentType.NUMBER | SegmentType.INTEGER | SegmentType.FLOAT: | case SegmentType.NUMBER | SegmentType.INTEGER | SegmentType.FLOAT: | ||||
| if not isinstance(value, int | float): | if not isinstance(value, int | float): | ||||
| return False | return False | ||||
| return isinstance(value, int | float) | return isinstance(value, int | float) | ||||
| case SegmentType.ARRAY_OBJECT if operation == Operation.APPEND: | case SegmentType.ARRAY_OBJECT if operation == Operation.APPEND: | ||||
| return isinstance(value, dict) | return isinstance(value, dict) | ||||
| case SegmentType.ARRAY_BOOLEAN if operation == Operation.APPEND: | |||||
| return isinstance(value, bool) | |||||
| # Array & Extend / Overwrite | # Array & Extend / Overwrite | ||||
| case SegmentType.ARRAY_ANY if operation in {Operation.EXTEND, Operation.OVER_WRITE}: | case SegmentType.ARRAY_ANY if operation in {Operation.EXTEND, Operation.OVER_WRITE}: | ||||
| return isinstance(value, list) and all(isinstance(item, int | float) for item in value) | 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}: | 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) | return isinstance(value, list) and all(isinstance(item, dict) for item in value) | ||||
| case SegmentType.ARRAY_BOOLEAN if operation in {Operation.EXTEND, Operation.OVER_WRITE}: | |||||
| return isinstance(value, list) and all(isinstance(item, bool) for item in value) | |||||
| case _: | case _: | ||||
| return False | return False |
| class Condition(BaseModel): | class Condition(BaseModel): | ||||
| variable_selector: list[str] | variable_selector: list[str] | ||||
| comparison_operator: SupportedComparisonOperator | comparison_operator: SupportedComparisonOperator | ||||
| value: str | Sequence[str] | None = None | |||||
| value: str | Sequence[str] | bool | None = None | |||||
| sub_variable_condition: SubVariableCondition | None = None | sub_variable_condition: SubVariableCondition | None = None |
| import json | |||||
| from collections.abc import Sequence | from collections.abc import Sequence | ||||
| from typing import Any, Literal | |||||
| from typing import Any, Literal, Union | |||||
| from core.file import FileAttribute, file_manager | from core.file import FileAttribute, file_manager | ||||
| from core.variables import ArrayFileSegment | from core.variables import ArrayFileSegment | ||||
| from core.variables.segments import ArrayBooleanSegment, BooleanSegment | |||||
| from core.workflow.entities.variable_pool import VariablePool | from core.workflow.entities.variable_pool import VariablePool | ||||
| from .entities import Condition, SubCondition, SupportedComparisonOperator | from .entities import Condition, SubCondition, SupportedComparisonOperator | ||||
| def _convert_to_bool(value: Any) -> bool: | |||||
| if isinstance(value, int): | |||||
| return bool(value) | |||||
| if isinstance(value, str): | |||||
| loaded = json.loads(value) | |||||
| if isinstance(loaded, (int, bool)): | |||||
| return bool(loaded) | |||||
| raise TypeError(f"unexpected value: type={type(value)}, value={value}") | |||||
| class ConditionProcessor: | class ConditionProcessor: | ||||
| def process_conditions( | def process_conditions( | ||||
| self, | self, | ||||
| ) | ) | ||||
| else: | else: | ||||
| actual_value = variable.value if variable else None | actual_value = variable.value if variable else None | ||||
| expected_value = condition.value | |||||
| expected_value: str | Sequence[str] | bool | list[bool] | None = condition.value | |||||
| if isinstance(expected_value, str): | if isinstance(expected_value, str): | ||||
| expected_value = variable_pool.convert_template(expected_value).text | expected_value = variable_pool.convert_template(expected_value).text | ||||
| # Here we need to explicit convet the input string to boolean. | |||||
| if isinstance(variable, (BooleanSegment, ArrayBooleanSegment)) and expected_value is not None: | |||||
| # The following two lines is for compatibility with existing workflows. | |||||
| if isinstance(expected_value, list): | |||||
| expected_value = [_convert_to_bool(i) for i in expected_value] | |||||
| else: | |||||
| expected_value = _convert_to_bool(expected_value) | |||||
| input_conditions.append( | input_conditions.append( | ||||
| { | { | ||||
| "actual_value": actual_value, | "actual_value": actual_value, | ||||
| *, | *, | ||||
| operator: SupportedComparisonOperator, | operator: SupportedComparisonOperator, | ||||
| value: Any, | value: Any, | ||||
| expected: str | Sequence[str] | None, | |||||
| expected: Union[str, Sequence[str], bool | Sequence[bool], None], | |||||
| ) -> bool: | ) -> bool: | ||||
| match operator: | match operator: | ||||
| case "contains": | case "contains": | ||||
| if not value: | if not value: | ||||
| return False | return False | ||||
| if not isinstance(value, str | list): | |||||
| if not isinstance(value, (str, list)): | |||||
| raise ValueError("Invalid actual value type: string or array") | raise ValueError("Invalid actual value type: string or array") | ||||
| if expected not in value: | if expected not in value: | ||||
| if not value: | if not value: | ||||
| return True | return True | ||||
| if not isinstance(value, str | list): | |||||
| if not isinstance(value, (str, list)): | |||||
| raise ValueError("Invalid actual value type: string or array") | raise ValueError("Invalid actual value type: string or array") | ||||
| if expected in value: | if expected in value: | ||||
| if value is None: | if value is None: | ||||
| return False | return False | ||||
| if not isinstance(value, str): | |||||
| raise ValueError("Invalid actual value type: string") | |||||
| if not isinstance(value, (str, bool)): | |||||
| raise ValueError("Invalid actual value type: string or boolean") | |||||
| if value != expected: | if value != expected: | ||||
| return False | return False | ||||
| if value is None: | if value is None: | ||||
| return False | return False | ||||
| if not isinstance(value, str): | |||||
| raise ValueError("Invalid actual value type: string") | |||||
| if not isinstance(value, (str, bool)): | |||||
| raise ValueError("Invalid actual value type: string or boolean") | |||||
| if value == expected: | if value == expected: | ||||
| return False | return False | ||||
| if value is None: | if value is None: | ||||
| return False | return False | ||||
| if not isinstance(value, int | float): | |||||
| raise ValueError("Invalid actual value type: number") | |||||
| if not isinstance(value, (int, float, bool)): | |||||
| raise ValueError("Invalid actual value type: number or boolean") | |||||
| if isinstance(value, int): | |||||
| # Handle boolean comparison | |||||
| if isinstance(value, bool): | |||||
| expected = bool(expected) | |||||
| elif isinstance(value, int): | |||||
| expected = int(expected) | expected = int(expected) | ||||
| else: | else: | ||||
| expected = float(expected) | expected = float(expected) | ||||
| if value is None: | if value is None: | ||||
| return False | return False | ||||
| if not isinstance(value, int | float): | |||||
| raise ValueError("Invalid actual value type: number") | |||||
| if not isinstance(value, (int, float, bool)): | |||||
| raise ValueError("Invalid actual value type: number or boolean") | |||||
| if isinstance(value, int): | |||||
| # Handle boolean comparison | |||||
| if isinstance(value, bool): | |||||
| expected = bool(expected) | |||||
| elif isinstance(value, int): | |||||
| expected = int(expected) | expected = int(expected) | ||||
| else: | else: | ||||
| expected = float(expected) | expected = float(expected) | ||||
| if value is None: | if value is None: | ||||
| return False | return False | ||||
| if not isinstance(value, int | float): | |||||
| if not isinstance(value, (int, float)): | |||||
| raise ValueError("Invalid actual value type: number") | raise ValueError("Invalid actual value type: number") | ||||
| if isinstance(value, int): | if isinstance(value, int): | ||||
| if value is None: | if value is None: | ||||
| return False | return False | ||||
| if not isinstance(value, int | float): | |||||
| if not isinstance(value, (int, float)): | |||||
| raise ValueError("Invalid actual value type: number") | raise ValueError("Invalid actual value type: number") | ||||
| if isinstance(value, int): | if isinstance(value, int): | ||||
| if value is None: | if value is None: | ||||
| return False | return False | ||||
| if not isinstance(value, int | float): | |||||
| if not isinstance(value, (int, float)): | |||||
| raise ValueError("Invalid actual value type: number") | raise ValueError("Invalid actual value type: number") | ||||
| if isinstance(value, int): | if isinstance(value, int): | ||||
| if value is None: | if value is None: | ||||
| return False | return False | ||||
| if not isinstance(value, int | float): | |||||
| if not isinstance(value, (int, float)): | |||||
| raise ValueError("Invalid actual value type: number") | raise ValueError("Invalid actual value type: number") | ||||
| if isinstance(value, int): | if isinstance(value, int): |
| from core.variables.exc import VariableError | from core.variables.exc import VariableError | ||||
| from core.variables.segments import ( | from core.variables.segments import ( | ||||
| ArrayAnySegment, | ArrayAnySegment, | ||||
| ArrayBooleanSegment, | |||||
| ArrayFileSegment, | ArrayFileSegment, | ||||
| ArrayNumberSegment, | ArrayNumberSegment, | ||||
| ArrayObjectSegment, | ArrayObjectSegment, | ||||
| ArraySegment, | ArraySegment, | ||||
| ArrayStringSegment, | ArrayStringSegment, | ||||
| BooleanSegment, | |||||
| FileSegment, | FileSegment, | ||||
| FloatSegment, | FloatSegment, | ||||
| IntegerSegment, | IntegerSegment, | ||||
| from core.variables.types import SegmentType | from core.variables.types import SegmentType | ||||
| from core.variables.variables import ( | from core.variables.variables import ( | ||||
| ArrayAnyVariable, | ArrayAnyVariable, | ||||
| ArrayBooleanVariable, | |||||
| ArrayFileVariable, | ArrayFileVariable, | ||||
| ArrayNumberVariable, | ArrayNumberVariable, | ||||
| ArrayObjectVariable, | ArrayObjectVariable, | ||||
| ArrayStringVariable, | ArrayStringVariable, | ||||
| BooleanVariable, | |||||
| FileVariable, | FileVariable, | ||||
| FloatVariable, | FloatVariable, | ||||
| IntegerVariable, | IntegerVariable, | ||||
| # Define the constant | # Define the constant | ||||
| SEGMENT_TO_VARIABLE_MAP = { | SEGMENT_TO_VARIABLE_MAP = { | ||||
| StringSegment: StringVariable, | |||||
| IntegerSegment: IntegerVariable, | |||||
| FloatSegment: FloatVariable, | |||||
| ObjectSegment: ObjectVariable, | |||||
| FileSegment: FileVariable, | |||||
| ArrayStringSegment: ArrayStringVariable, | |||||
| ArrayAnySegment: ArrayAnyVariable, | |||||
| ArrayBooleanSegment: ArrayBooleanVariable, | |||||
| ArrayFileSegment: ArrayFileVariable, | |||||
| ArrayNumberSegment: ArrayNumberVariable, | ArrayNumberSegment: ArrayNumberVariable, | ||||
| ArrayObjectSegment: ArrayObjectVariable, | ArrayObjectSegment: ArrayObjectVariable, | ||||
| ArrayFileSegment: ArrayFileVariable, | |||||
| ArrayAnySegment: ArrayAnyVariable, | |||||
| ArrayStringSegment: ArrayStringVariable, | |||||
| BooleanSegment: BooleanVariable, | |||||
| FileSegment: FileVariable, | |||||
| FloatSegment: FloatVariable, | |||||
| IntegerSegment: IntegerVariable, | |||||
| NoneSegment: NoneVariable, | NoneSegment: NoneVariable, | ||||
| ObjectSegment: ObjectVariable, | |||||
| StringSegment: StringVariable, | |||||
| } | } | ||||
| mapping = dict(mapping) | mapping = dict(mapping) | ||||
| mapping["value_type"] = SegmentType.FLOAT | mapping["value_type"] = SegmentType.FLOAT | ||||
| result = FloatVariable.model_validate(mapping) | result = FloatVariable.model_validate(mapping) | ||||
| case SegmentType.BOOLEAN: | |||||
| result = BooleanVariable.model_validate(mapping) | |||||
| case SegmentType.NUMBER if not isinstance(value, float | int): | case SegmentType.NUMBER if not isinstance(value, float | int): | ||||
| raise VariableError(f"invalid number value {value}") | raise VariableError(f"invalid number value {value}") | ||||
| case SegmentType.OBJECT if isinstance(value, dict): | case SegmentType.OBJECT if isinstance(value, dict): | ||||
| result = ArrayNumberVariable.model_validate(mapping) | result = ArrayNumberVariable.model_validate(mapping) | ||||
| case SegmentType.ARRAY_OBJECT if isinstance(value, list): | case SegmentType.ARRAY_OBJECT if isinstance(value, list): | ||||
| result = ArrayObjectVariable.model_validate(mapping) | result = ArrayObjectVariable.model_validate(mapping) | ||||
| case SegmentType.ARRAY_BOOLEAN if isinstance(value, list): | |||||
| result = ArrayBooleanVariable.model_validate(mapping) | |||||
| case _: | case _: | ||||
| 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: | ||||
| return NoneSegment() | return NoneSegment() | ||||
| if isinstance(value, str): | if isinstance(value, str): | ||||
| return StringSegment(value=value) | return StringSegment(value=value) | ||||
| if isinstance(value, bool): | |||||
| return BooleanSegment(value=value) | |||||
| if isinstance(value, int): | if isinstance(value, int): | ||||
| return IntegerSegment(value=value) | return IntegerSegment(value=value) | ||||
| if isinstance(value, float): | if isinstance(value, float): | ||||
| return ArrayStringSegment(value=value) | return ArrayStringSegment(value=value) | ||||
| case SegmentType.NUMBER | SegmentType.INTEGER | SegmentType.FLOAT: | case SegmentType.NUMBER | SegmentType.INTEGER | SegmentType.FLOAT: | ||||
| return ArrayNumberSegment(value=value) | return ArrayNumberSegment(value=value) | ||||
| case SegmentType.BOOLEAN: | |||||
| return ArrayBooleanSegment(value=value) | |||||
| case SegmentType.OBJECT: | case SegmentType.OBJECT: | ||||
| return ArrayObjectSegment(value=value) | return ArrayObjectSegment(value=value) | ||||
| case SegmentType.FILE: | case SegmentType.FILE: | ||||
| SegmentType.INTEGER: IntegerSegment, | SegmentType.INTEGER: IntegerSegment, | ||||
| SegmentType.FLOAT: FloatSegment, | SegmentType.FLOAT: FloatSegment, | ||||
| SegmentType.FILE: FileSegment, | SegmentType.FILE: FileSegment, | ||||
| SegmentType.BOOLEAN: BooleanSegment, | |||||
| SegmentType.OBJECT: ObjectSegment, | SegmentType.OBJECT: ObjectSegment, | ||||
| # Array types | # Array types | ||||
| SegmentType.ARRAY_ANY: ArrayAnySegment, | SegmentType.ARRAY_ANY: ArrayAnySegment, | ||||
| SegmentType.ARRAY_NUMBER: ArrayNumberSegment, | SegmentType.ARRAY_NUMBER: ArrayNumberSegment, | ||||
| SegmentType.ARRAY_OBJECT: ArrayObjectSegment, | SegmentType.ARRAY_OBJECT: ArrayObjectSegment, | ||||
| SegmentType.ARRAY_FILE: ArrayFileSegment, | SegmentType.ARRAY_FILE: ArrayFileSegment, | ||||
| SegmentType.ARRAY_BOOLEAN: ArrayBooleanSegment, | |||||
| } | } | ||||
| return ArrayAnySegment(value=value) | return ArrayAnySegment(value=value) | ||||
| elif segment_type == SegmentType.ARRAY_STRING: | elif segment_type == SegmentType.ARRAY_STRING: | ||||
| return ArrayStringSegment(value=value) | return ArrayStringSegment(value=value) | ||||
| elif segment_type == SegmentType.ARRAY_BOOLEAN: | |||||
| return ArrayBooleanSegment(value=value) | |||||
| elif segment_type == SegmentType.ARRAY_NUMBER: | elif segment_type == SegmentType.ARRAY_NUMBER: | ||||
| return ArrayNumberSegment(value=value) | return ArrayNumberSegment(value=value) | ||||
| elif segment_type == SegmentType.ARRAY_OBJECT: | elif segment_type == SegmentType.ARRAY_OBJECT: |
| from tests.integration_tests.utils.parent_class import ParentClass | |||||
| class LazyLoadChildClass(ParentClass): | |||||
| """Test lazy load child class for module import helper tests""" | |||||
| def __init__(self, name): | |||||
| super().__init__(name) | |||||
| def get_name(self): | |||||
| return self.name |
| [mypy-flask_restx.inputs] | [mypy-flask_restx.inputs] | ||||
| ignore_missing_imports=True | ignore_missing_imports=True | ||||
| [mypy-google.cloud.storage] | |||||
| ignore_missing_imports=True |
| SegmentType.ARRAY_NUMBER, | SegmentType.ARRAY_NUMBER, | ||||
| SegmentType.ARRAY_OBJECT, | SegmentType.ARRAY_OBJECT, | ||||
| SegmentType.ARRAY_FILE, | SegmentType.ARRAY_FILE, | ||||
| SegmentType.ARRAY_BOOLEAN, | |||||
| ] | ] | ||||
| expected_non_array_types = [ | expected_non_array_types = [ | ||||
| SegmentType.INTEGER, | SegmentType.INTEGER, | ||||
| SegmentType.FILE, | SegmentType.FILE, | ||||
| SegmentType.NONE, | SegmentType.NONE, | ||||
| SegmentType.GROUP, | SegmentType.GROUP, | ||||
| SegmentType.BOOLEAN, | |||||
| ] | ] | ||||
| for seg_type in expected_array_types: | for seg_type in expected_array_types: |
| """ | |||||
| Comprehensive unit tests for SegmentType.is_valid and SegmentType._validate_array methods. | |||||
| This module provides thorough testing of the validation logic for all SegmentType values, | |||||
| including edge cases, error conditions, and different ArrayValidation strategies. | |||||
| """ | |||||
| from dataclasses import dataclass | |||||
| from typing import Any | |||||
| import pytest | |||||
| from core.file.enums import FileTransferMethod, FileType | |||||
| from core.file.models import File | |||||
| from core.variables.types import ArrayValidation, SegmentType | |||||
| def create_test_file( | |||||
| file_type: FileType = FileType.DOCUMENT, | |||||
| transfer_method: FileTransferMethod = FileTransferMethod.LOCAL_FILE, | |||||
| filename: str = "test.txt", | |||||
| extension: str = ".txt", | |||||
| mime_type: str = "text/plain", | |||||
| size: int = 1024, | |||||
| ) -> File: | |||||
| """Factory function to create File objects for testing.""" | |||||
| return File( | |||||
| tenant_id="test-tenant", | |||||
| type=file_type, | |||||
| transfer_method=transfer_method, | |||||
| filename=filename, | |||||
| extension=extension, | |||||
| mime_type=mime_type, | |||||
| size=size, | |||||
| related_id="test-file-id" if transfer_method != FileTransferMethod.REMOTE_URL else None, | |||||
| remote_url="https://example.com/file.txt" if transfer_method == FileTransferMethod.REMOTE_URL else None, | |||||
| storage_key="test-storage-key", | |||||
| ) | |||||
| @dataclass | |||||
| class ValidationTestCase: | |||||
| """Test case data structure for validation tests.""" | |||||
| segment_type: SegmentType | |||||
| value: Any | |||||
| expected: bool | |||||
| description: str | |||||
| def get_id(self): | |||||
| return self.description | |||||
| @dataclass | |||||
| class ArrayValidationTestCase: | |||||
| """Test case data structure for array validation tests.""" | |||||
| segment_type: SegmentType | |||||
| value: Any | |||||
| array_validation: ArrayValidation | |||||
| expected: bool | |||||
| description: str | |||||
| def get_id(self): | |||||
| return self.description | |||||
| # Test data construction functions | |||||
| def get_boolean_cases() -> list[ValidationTestCase]: | |||||
| return [ | |||||
| # valid values | |||||
| ValidationTestCase(SegmentType.BOOLEAN, True, True, "True boolean"), | |||||
| ValidationTestCase(SegmentType.BOOLEAN, False, True, "False boolean"), | |||||
| # Invalid values | |||||
| ValidationTestCase(SegmentType.BOOLEAN, 1, False, "Integer 1 (not boolean)"), | |||||
| ValidationTestCase(SegmentType.BOOLEAN, 0, False, "Integer 0 (not boolean)"), | |||||
| ValidationTestCase(SegmentType.BOOLEAN, "true", False, "String 'true'"), | |||||
| ValidationTestCase(SegmentType.BOOLEAN, "false", False, "String 'false'"), | |||||
| ValidationTestCase(SegmentType.BOOLEAN, None, False, "None value"), | |||||
| ValidationTestCase(SegmentType.BOOLEAN, [], False, "Empty list"), | |||||
| ValidationTestCase(SegmentType.BOOLEAN, {}, False, "Empty dict"), | |||||
| ] | |||||
| def get_number_cases() -> list[ValidationTestCase]: | |||||
| """Get test cases for valid number values.""" | |||||
| return [ | |||||
| # valid values | |||||
| ValidationTestCase(SegmentType.NUMBER, 42, True, "Positive integer"), | |||||
| ValidationTestCase(SegmentType.NUMBER, -42, True, "Negative integer"), | |||||
| ValidationTestCase(SegmentType.NUMBER, 0, True, "Zero integer"), | |||||
| ValidationTestCase(SegmentType.NUMBER, 3.14, True, "Positive float"), | |||||
| ValidationTestCase(SegmentType.NUMBER, -3.14, True, "Negative float"), | |||||
| ValidationTestCase(SegmentType.NUMBER, 0.0, True, "Zero float"), | |||||
| ValidationTestCase(SegmentType.NUMBER, float("inf"), True, "Positive infinity"), | |||||
| ValidationTestCase(SegmentType.NUMBER, float("-inf"), True, "Negative infinity"), | |||||
| ValidationTestCase(SegmentType.NUMBER, float("nan"), True, "float(NaN)"), | |||||
| # invalid number values | |||||
| ValidationTestCase(SegmentType.NUMBER, "42", False, "String number"), | |||||
| ValidationTestCase(SegmentType.NUMBER, None, False, "None value"), | |||||
| ValidationTestCase(SegmentType.NUMBER, [], False, "Empty list"), | |||||
| ValidationTestCase(SegmentType.NUMBER, {}, False, "Empty dict"), | |||||
| ValidationTestCase(SegmentType.NUMBER, "3.14", False, "String float"), | |||||
| ] | |||||
| def get_string_cases() -> list[ValidationTestCase]: | |||||
| """Get test cases for valid string values.""" | |||||
| return [ | |||||
| # valid values | |||||
| ValidationTestCase(SegmentType.STRING, "", True, "Empty string"), | |||||
| ValidationTestCase(SegmentType.STRING, "hello", True, "Simple string"), | |||||
| ValidationTestCase(SegmentType.STRING, "🚀", True, "Unicode emoji"), | |||||
| ValidationTestCase(SegmentType.STRING, "line1\nline2", True, "Multiline string"), | |||||
| # invalid values | |||||
| ValidationTestCase(SegmentType.STRING, 123, False, "Integer"), | |||||
| ValidationTestCase(SegmentType.STRING, 3.14, False, "Float"), | |||||
| ValidationTestCase(SegmentType.STRING, True, False, "Boolean"), | |||||
| ValidationTestCase(SegmentType.STRING, None, False, "None value"), | |||||
| ValidationTestCase(SegmentType.STRING, [], False, "Empty list"), | |||||
| ValidationTestCase(SegmentType.STRING, {}, False, "Empty dict"), | |||||
| ] | |||||
| def get_object_cases() -> list[ValidationTestCase]: | |||||
| """Get test cases for valid object values.""" | |||||
| return [ | |||||
| # valid cases | |||||
| ValidationTestCase(SegmentType.OBJECT, {}, True, "Empty dict"), | |||||
| ValidationTestCase(SegmentType.OBJECT, {"key": "value"}, True, "Simple dict"), | |||||
| ValidationTestCase(SegmentType.OBJECT, {"a": 1, "b": 2}, True, "Dict with numbers"), | |||||
| ValidationTestCase(SegmentType.OBJECT, {"nested": {"key": "value"}}, True, "Nested dict"), | |||||
| ValidationTestCase(SegmentType.OBJECT, {"list": [1, 2, 3]}, True, "Dict with list"), | |||||
| ValidationTestCase(SegmentType.OBJECT, {"mixed": [1, "two", {"three": 3}]}, True, "Complex dict"), | |||||
| # invalid cases | |||||
| ValidationTestCase(SegmentType.OBJECT, "not a dict", False, "String"), | |||||
| ValidationTestCase(SegmentType.OBJECT, 123, False, "Integer"), | |||||
| ValidationTestCase(SegmentType.OBJECT, 3.14, False, "Float"), | |||||
| ValidationTestCase(SegmentType.OBJECT, True, False, "Boolean"), | |||||
| ValidationTestCase(SegmentType.OBJECT, None, False, "None value"), | |||||
| ValidationTestCase(SegmentType.OBJECT, [], False, "Empty list"), | |||||
| ValidationTestCase(SegmentType.OBJECT, [1, 2, 3], False, "List with values"), | |||||
| ] | |||||
| def get_secret_cases() -> list[ValidationTestCase]: | |||||
| """Get test cases for valid secret values.""" | |||||
| return [ | |||||
| # valid cases | |||||
| ValidationTestCase(SegmentType.SECRET, "", True, "Empty secret"), | |||||
| ValidationTestCase(SegmentType.SECRET, "secret", True, "Simple secret"), | |||||
| ValidationTestCase(SegmentType.SECRET, "api_key_123", True, "API key format"), | |||||
| ValidationTestCase(SegmentType.SECRET, "very_long_secret_key_with_special_chars!@#", True, "Complex secret"), | |||||
| # invalid cases | |||||
| ValidationTestCase(SegmentType.SECRET, 123, False, "Integer"), | |||||
| ValidationTestCase(SegmentType.SECRET, 3.14, False, "Float"), | |||||
| ValidationTestCase(SegmentType.SECRET, True, False, "Boolean"), | |||||
| ValidationTestCase(SegmentType.SECRET, None, False, "None value"), | |||||
| ValidationTestCase(SegmentType.SECRET, [], False, "Empty list"), | |||||
| ValidationTestCase(SegmentType.SECRET, {}, False, "Empty dict"), | |||||
| ] | |||||
| def get_file_cases() -> list[ValidationTestCase]: | |||||
| """Get test cases for valid file values.""" | |||||
| test_file = create_test_file() | |||||
| image_file = create_test_file( | |||||
| file_type=FileType.IMAGE, filename="image.jpg", extension=".jpg", mime_type="image/jpeg" | |||||
| ) | |||||
| remote_file = create_test_file( | |||||
| transfer_method=FileTransferMethod.REMOTE_URL, filename="remote.pdf", extension=".pdf" | |||||
| ) | |||||
| return [ | |||||
| # valid cases | |||||
| ValidationTestCase(SegmentType.FILE, test_file, True, "Document file"), | |||||
| ValidationTestCase(SegmentType.FILE, image_file, True, "Image file"), | |||||
| ValidationTestCase(SegmentType.FILE, remote_file, True, "Remote file"), | |||||
| # invalid cases | |||||
| ValidationTestCase(SegmentType.FILE, "not a file", False, "String"), | |||||
| ValidationTestCase(SegmentType.FILE, 123, False, "Integer"), | |||||
| ValidationTestCase(SegmentType.FILE, {"filename": "test.txt"}, False, "Dict resembling file"), | |||||
| ValidationTestCase(SegmentType.FILE, None, False, "None value"), | |||||
| ValidationTestCase(SegmentType.FILE, [], False, "Empty list"), | |||||
| ValidationTestCase(SegmentType.FILE, True, False, "Boolean"), | |||||
| ] | |||||
| def get_none_cases() -> list[ValidationTestCase]: | |||||
| """Get test cases for valid none values.""" | |||||
| return [ | |||||
| # valid cases | |||||
| ValidationTestCase(SegmentType.NONE, None, True, "None value"), | |||||
| # invalid cases | |||||
| ValidationTestCase(SegmentType.NONE, "", False, "Empty string"), | |||||
| ValidationTestCase(SegmentType.NONE, 0, False, "Zero integer"), | |||||
| ValidationTestCase(SegmentType.NONE, 0.0, False, "Zero float"), | |||||
| ValidationTestCase(SegmentType.NONE, False, False, "False boolean"), | |||||
| ValidationTestCase(SegmentType.NONE, [], False, "Empty list"), | |||||
| ValidationTestCase(SegmentType.NONE, {}, False, "Empty dict"), | |||||
| ValidationTestCase(SegmentType.NONE, "null", False, "String 'null'"), | |||||
| ] | |||||
| def get_array_any_validation_cases() -> list[ArrayValidationTestCase]: | |||||
| """Get test cases for ARRAY_ANY validation.""" | |||||
| return [ | |||||
| ArrayValidationTestCase( | |||||
| SegmentType.ARRAY_ANY, | |||||
| [1, "string", 3.14, {"key": "value"}, True], | |||||
| ArrayValidation.NONE, | |||||
| True, | |||||
| "Mixed types with NONE validation", | |||||
| ), | |||||
| ArrayValidationTestCase( | |||||
| SegmentType.ARRAY_ANY, | |||||
| [1, "string", 3.14, {"key": "value"}, True], | |||||
| ArrayValidation.FIRST, | |||||
| True, | |||||
| "Mixed types with FIRST validation", | |||||
| ), | |||||
| ArrayValidationTestCase( | |||||
| SegmentType.ARRAY_ANY, | |||||
| [1, "string", 3.14, {"key": "value"}, True], | |||||
| ArrayValidation.ALL, | |||||
| True, | |||||
| "Mixed types with ALL validation", | |||||
| ), | |||||
| ArrayValidationTestCase( | |||||
| SegmentType.ARRAY_ANY, [None, None, None], ArrayValidation.ALL, True, "All None values" | |||||
| ), | |||||
| ] | |||||
| def get_array_string_validation_none_cases() -> list[ArrayValidationTestCase]: | |||||
| """Get test cases for ARRAY_STRING validation with NONE strategy.""" | |||||
| return [ | |||||
| ArrayValidationTestCase( | |||||
| SegmentType.ARRAY_STRING, | |||||
| ["hello", "world"], | |||||
| ArrayValidation.NONE, | |||||
| True, | |||||
| "Valid strings with NONE validation", | |||||
| ), | |||||
| ArrayValidationTestCase( | |||||
| SegmentType.ARRAY_STRING, | |||||
| [123, 456], | |||||
| ArrayValidation.NONE, | |||||
| True, | |||||
| "Invalid elements with NONE validation", | |||||
| ), | |||||
| ArrayValidationTestCase( | |||||
| SegmentType.ARRAY_STRING, | |||||
| ["valid", 123, True], | |||||
| ArrayValidation.NONE, | |||||
| True, | |||||
| "Mixed types with NONE validation", | |||||
| ), | |||||
| ] | |||||
| def get_array_string_validation_first_cases() -> list[ArrayValidationTestCase]: | |||||
| """Get test cases for ARRAY_STRING validation with FIRST strategy.""" | |||||
| return [ | |||||
| ArrayValidationTestCase( | |||||
| SegmentType.ARRAY_STRING, ["hello", "world"], ArrayValidation.FIRST, True, "All valid strings" | |||||
| ), | |||||
| ArrayValidationTestCase( | |||||
| SegmentType.ARRAY_STRING, | |||||
| ["hello", 123, True], | |||||
| ArrayValidation.FIRST, | |||||
| True, | |||||
| "First valid, others invalid", | |||||
| ), | |||||
| ArrayValidationTestCase( | |||||
| SegmentType.ARRAY_STRING, | |||||
| [123, "hello", "world"], | |||||
| ArrayValidation.FIRST, | |||||
| False, | |||||
| "First invalid, others valid", | |||||
| ), | |||||
| ArrayValidationTestCase(SegmentType.ARRAY_STRING, [None, "hello"], ArrayValidation.FIRST, False, "First None"), | |||||
| ] | |||||
| def get_array_string_validation_all_cases() -> list[ArrayValidationTestCase]: | |||||
| """Get test cases for ARRAY_STRING validation with ALL strategy.""" | |||||
| return [ | |||||
| ArrayValidationTestCase( | |||||
| SegmentType.ARRAY_STRING, ["hello", "world", "test"], ArrayValidation.ALL, True, "All valid strings" | |||||
| ), | |||||
| ArrayValidationTestCase( | |||||
| SegmentType.ARRAY_STRING, ["hello", 123, "world"], ArrayValidation.ALL, False, "One invalid element" | |||||
| ), | |||||
| ArrayValidationTestCase( | |||||
| SegmentType.ARRAY_STRING, [123, 456, 789], ArrayValidation.ALL, False, "All invalid elements" | |||||
| ), | |||||
| ArrayValidationTestCase( | |||||
| SegmentType.ARRAY_STRING, ["valid", None, "also_valid"], ArrayValidation.ALL, False, "Contains None" | |||||
| ), | |||||
| ] | |||||
| def get_array_number_validation_cases() -> list[ArrayValidationTestCase]: | |||||
| """Get test cases for ARRAY_NUMBER validation with different strategies.""" | |||||
| return [ | |||||
| # NONE strategy | |||||
| ArrayValidationTestCase( | |||||
| SegmentType.ARRAY_NUMBER, [1, 2.5, 3], ArrayValidation.NONE, True, "Valid numbers with NONE" | |||||
| ), | |||||
| ArrayValidationTestCase( | |||||
| SegmentType.ARRAY_NUMBER, ["not", "numbers"], ArrayValidation.NONE, True, "Invalid elements with NONE" | |||||
| ), | |||||
| # FIRST strategy | |||||
| ArrayValidationTestCase( | |||||
| SegmentType.ARRAY_NUMBER, [42, "not a number"], ArrayValidation.FIRST, True, "First valid number" | |||||
| ), | |||||
| ArrayValidationTestCase( | |||||
| SegmentType.ARRAY_NUMBER, ["not a number", 42], ArrayValidation.FIRST, False, "First invalid" | |||||
| ), | |||||
| ArrayValidationTestCase( | |||||
| SegmentType.ARRAY_NUMBER, [3.14, 2.71, 1.41], ArrayValidation.FIRST, True, "All valid floats" | |||||
| ), | |||||
| # ALL strategy | |||||
| ArrayValidationTestCase( | |||||
| SegmentType.ARRAY_NUMBER, [1, 2, 3, 4.5], ArrayValidation.ALL, True, "All valid numbers" | |||||
| ), | |||||
| ArrayValidationTestCase( | |||||
| SegmentType.ARRAY_NUMBER, [1, "invalid", 3], ArrayValidation.ALL, False, "One invalid element" | |||||
| ), | |||||
| ArrayValidationTestCase( | |||||
| SegmentType.ARRAY_NUMBER, | |||||
| [float("inf"), float("-inf"), float("nan")], | |||||
| ArrayValidation.ALL, | |||||
| True, | |||||
| "Special float values", | |||||
| ), | |||||
| ] | |||||
| def get_array_object_validation_cases() -> list[ArrayValidationTestCase]: | |||||
| """Get test cases for ARRAY_OBJECT validation with different strategies.""" | |||||
| return [ | |||||
| # NONE strategy | |||||
| ArrayValidationTestCase( | |||||
| SegmentType.ARRAY_OBJECT, [{}, {"key": "value"}], ArrayValidation.NONE, True, "Valid objects with NONE" | |||||
| ), | |||||
| ArrayValidationTestCase( | |||||
| SegmentType.ARRAY_OBJECT, ["not", "objects"], ArrayValidation.NONE, True, "Invalid elements with NONE" | |||||
| ), | |||||
| # FIRST strategy | |||||
| ArrayValidationTestCase( | |||||
| SegmentType.ARRAY_OBJECT, | |||||
| [{"valid": "object"}, "not an object"], | |||||
| ArrayValidation.FIRST, | |||||
| True, | |||||
| "First valid object", | |||||
| ), | |||||
| ArrayValidationTestCase( | |||||
| SegmentType.ARRAY_OBJECT, | |||||
| ["not an object", {"valid": "object"}], | |||||
| ArrayValidation.FIRST, | |||||
| False, | |||||
| "First invalid", | |||||
| ), | |||||
| # ALL strategy | |||||
| ArrayValidationTestCase( | |||||
| SegmentType.ARRAY_OBJECT, | |||||
| [{}, {"a": 1}, {"nested": {"key": "value"}}], | |||||
| ArrayValidation.ALL, | |||||
| True, | |||||
| "All valid objects", | |||||
| ), | |||||
| ArrayValidationTestCase( | |||||
| SegmentType.ARRAY_OBJECT, | |||||
| [{"valid": "object"}, "invalid", {"another": "object"}], | |||||
| ArrayValidation.ALL, | |||||
| False, | |||||
| "One invalid element", | |||||
| ), | |||||
| ] | |||||
| def get_array_file_validation_cases() -> list[ArrayValidationTestCase]: | |||||
| """Get test cases for ARRAY_FILE validation with different strategies.""" | |||||
| file1 = create_test_file(filename="file1.txt") | |||||
| file2 = create_test_file(filename="file2.txt") | |||||
| return [ | |||||
| # NONE strategy | |||||
| ArrayValidationTestCase( | |||||
| SegmentType.ARRAY_FILE, [file1, file2], ArrayValidation.NONE, True, "Valid files with NONE" | |||||
| ), | |||||
| ArrayValidationTestCase( | |||||
| SegmentType.ARRAY_FILE, ["not", "files"], ArrayValidation.NONE, True, "Invalid elements with NONE" | |||||
| ), | |||||
| # FIRST strategy | |||||
| ArrayValidationTestCase( | |||||
| SegmentType.ARRAY_FILE, [file1, "not a file"], ArrayValidation.FIRST, True, "First valid file" | |||||
| ), | |||||
| ArrayValidationTestCase( | |||||
| SegmentType.ARRAY_FILE, ["not a file", file1], ArrayValidation.FIRST, False, "First invalid" | |||||
| ), | |||||
| # ALL strategy | |||||
| ArrayValidationTestCase(SegmentType.ARRAY_FILE, [file1, file2], ArrayValidation.ALL, True, "All valid files"), | |||||
| ArrayValidationTestCase( | |||||
| SegmentType.ARRAY_FILE, [file1, "invalid", file2], ArrayValidation.ALL, False, "One invalid element" | |||||
| ), | |||||
| ] | |||||
| def get_array_boolean_validation_cases() -> list[ArrayValidationTestCase]: | |||||
| """Get test cases for ARRAY_BOOLEAN validation with different strategies.""" | |||||
| return [ | |||||
| # NONE strategy | |||||
| ArrayValidationTestCase( | |||||
| SegmentType.ARRAY_BOOLEAN, [True, False, True], ArrayValidation.NONE, True, "Valid booleans with NONE" | |||||
| ), | |||||
| ArrayValidationTestCase( | |||||
| SegmentType.ARRAY_BOOLEAN, [1, 0, "true"], ArrayValidation.NONE, True, "Invalid elements with NONE" | |||||
| ), | |||||
| # FIRST strategy | |||||
| ArrayValidationTestCase( | |||||
| SegmentType.ARRAY_BOOLEAN, [True, 1, 0], ArrayValidation.FIRST, True, "First valid boolean" | |||||
| ), | |||||
| ArrayValidationTestCase( | |||||
| SegmentType.ARRAY_BOOLEAN, [1, True, False], ArrayValidation.FIRST, False, "First invalid (integer 1)" | |||||
| ), | |||||
| ArrayValidationTestCase( | |||||
| SegmentType.ARRAY_BOOLEAN, [0, True, False], ArrayValidation.FIRST, False, "First invalid (integer 0)" | |||||
| ), | |||||
| # ALL strategy | |||||
| ArrayValidationTestCase( | |||||
| SegmentType.ARRAY_BOOLEAN, [True, False, True, False], ArrayValidation.ALL, True, "All valid booleans" | |||||
| ), | |||||
| ArrayValidationTestCase( | |||||
| SegmentType.ARRAY_BOOLEAN, [True, 1, False], ArrayValidation.ALL, False, "One invalid element (integer)" | |||||
| ), | |||||
| ArrayValidationTestCase( | |||||
| SegmentType.ARRAY_BOOLEAN, | |||||
| [True, "false", False], | |||||
| ArrayValidation.ALL, | |||||
| False, | |||||
| "One invalid element (string)", | |||||
| ), | |||||
| ] | |||||
| class TestSegmentTypeIsValid: | |||||
| """Test suite for SegmentType.is_valid method covering all non-array types.""" | |||||
| @pytest.mark.parametrize("case", get_boolean_cases(), ids=lambda case: case.description) | |||||
| def test_boolean_validation(self, case): | |||||
| assert case.segment_type.is_valid(case.value) == case.expected | |||||
| @pytest.mark.parametrize("case", get_number_cases(), ids=lambda case: case.description) | |||||
| def test_number_validation(self, case: ValidationTestCase): | |||||
| assert case.segment_type.is_valid(case.value) == case.expected | |||||
| @pytest.mark.parametrize("case", get_string_cases(), ids=lambda case: case.description) | |||||
| def test_string_validation(self, case): | |||||
| assert case.segment_type.is_valid(case.value) == case.expected | |||||
| @pytest.mark.parametrize("case", get_object_cases(), ids=lambda case: case.description) | |||||
| def test_object_validation(self, case): | |||||
| assert case.segment_type.is_valid(case.value) == case.expected | |||||
| @pytest.mark.parametrize("case", get_secret_cases(), ids=lambda case: case.description) | |||||
| def test_secret_validation(self, case): | |||||
| assert case.segment_type.is_valid(case.value) == case.expected | |||||
| @pytest.mark.parametrize("case", get_file_cases(), ids=lambda case: case.description) | |||||
| def test_file_validation(self, case): | |||||
| assert case.segment_type.is_valid(case.value) == case.expected | |||||
| @pytest.mark.parametrize("case", get_none_cases(), ids=lambda case: case.description) | |||||
| def test_none_validation_valid_cases(self, case): | |||||
| assert case.segment_type.is_valid(case.value) == case.expected | |||||
| def test_unsupported_segment_type_raises_assertion_error(self): | |||||
| """Test that unsupported SegmentType values raise AssertionError.""" | |||||
| # GROUP is not handled in is_valid method | |||||
| with pytest.raises(AssertionError, match="this statement should be unreachable"): | |||||
| SegmentType.GROUP.is_valid("any value") | |||||
| class TestSegmentTypeArrayValidation: | |||||
| """Test suite for SegmentType._validate_array method and array type validation.""" | |||||
| def test_array_validation_non_list_values(self): | |||||
| """Test that non-list values return False for all array types.""" | |||||
| array_types = [ | |||||
| SegmentType.ARRAY_ANY, | |||||
| SegmentType.ARRAY_STRING, | |||||
| SegmentType.ARRAY_NUMBER, | |||||
| SegmentType.ARRAY_OBJECT, | |||||
| SegmentType.ARRAY_FILE, | |||||
| SegmentType.ARRAY_BOOLEAN, | |||||
| ] | |||||
| non_list_values = [ | |||||
| "not a list", | |||||
| 123, | |||||
| 3.14, | |||||
| True, | |||||
| None, | |||||
| {"key": "value"}, | |||||
| create_test_file(), | |||||
| ] | |||||
| for array_type in array_types: | |||||
| for value in non_list_values: | |||||
| assert array_type.is_valid(value) is False, f"{array_type} should reject {type(value).__name__}" | |||||
| def test_empty_array_validation(self): | |||||
| """Test that empty arrays are valid for all array types regardless of validation strategy.""" | |||||
| array_types = [ | |||||
| SegmentType.ARRAY_ANY, | |||||
| SegmentType.ARRAY_STRING, | |||||
| SegmentType.ARRAY_NUMBER, | |||||
| SegmentType.ARRAY_OBJECT, | |||||
| SegmentType.ARRAY_FILE, | |||||
| SegmentType.ARRAY_BOOLEAN, | |||||
| ] | |||||
| validation_strategies = [ArrayValidation.NONE, ArrayValidation.FIRST, ArrayValidation.ALL] | |||||
| for array_type in array_types: | |||||
| for strategy in validation_strategies: | |||||
| assert array_type.is_valid([], strategy) is True, ( | |||||
| f"{array_type} should accept empty array with {strategy}" | |||||
| ) | |||||
| @pytest.mark.parametrize("case", get_array_any_validation_cases(), ids=lambda case: case.description) | |||||
| def test_array_any_validation(self, case): | |||||
| """Test ARRAY_ANY validation accepts any list regardless of content.""" | |||||
| assert case.segment_type.is_valid(case.value, case.array_validation) == case.expected | |||||
| @pytest.mark.parametrize("case", get_array_string_validation_none_cases(), ids=lambda case: case.description) | |||||
| def test_array_string_validation_with_none_strategy(self, case): | |||||
| """Test ARRAY_STRING validation with NONE strategy (no element validation).""" | |||||
| assert case.segment_type.is_valid(case.value, case.array_validation) == case.expected | |||||
| @pytest.mark.parametrize("case", get_array_string_validation_first_cases(), ids=lambda case: case.description) | |||||
| def test_array_string_validation_with_first_strategy(self, case): | |||||
| """Test ARRAY_STRING validation with FIRST strategy (validate first element only).""" | |||||
| assert case.segment_type.is_valid(case.value, case.array_validation) == case.expected | |||||
| @pytest.mark.parametrize("case", get_array_string_validation_all_cases(), ids=lambda case: case.description) | |||||
| def test_array_string_validation_with_all_strategy(self, case): | |||||
| """Test ARRAY_STRING validation with ALL strategy (validate all elements).""" | |||||
| assert case.segment_type.is_valid(case.value, case.array_validation) == case.expected | |||||
| @pytest.mark.parametrize("case", get_array_number_validation_cases(), ids=lambda case: case.description) | |||||
| def test_array_number_validation_with_different_strategies(self, case): | |||||
| """Test ARRAY_NUMBER validation with different validation strategies.""" | |||||
| assert case.segment_type.is_valid(case.value, case.array_validation) == case.expected | |||||
| @pytest.mark.parametrize("case", get_array_object_validation_cases(), ids=lambda case: case.description) | |||||
| def test_array_object_validation_with_different_strategies(self, case): | |||||
| """Test ARRAY_OBJECT validation with different validation strategies.""" | |||||
| assert case.segment_type.is_valid(case.value, case.array_validation) == case.expected | |||||
| @pytest.mark.parametrize("case", get_array_file_validation_cases(), ids=lambda case: case.description) | |||||
| def test_array_file_validation_with_different_strategies(self, case): | |||||
| """Test ARRAY_FILE validation with different validation strategies.""" | |||||
| assert case.segment_type.is_valid(case.value, case.array_validation) == case.expected | |||||
| @pytest.mark.parametrize("case", get_array_boolean_validation_cases(), ids=lambda case: case.description) | |||||
| def test_array_boolean_validation_with_different_strategies(self, case): | |||||
| """Test ARRAY_BOOLEAN validation with different validation strategies.""" | |||||
| assert case.segment_type.is_valid(case.value, case.array_validation) == case.expected | |||||
| def test_default_array_validation_strategy(self): | |||||
| """Test that default array validation strategy is FIRST.""" | |||||
| # When no array_validation parameter is provided, it should default to FIRST | |||||
| assert SegmentType.ARRAY_STRING.is_valid(["valid", 123]) is False # First element valid | |||||
| assert SegmentType.ARRAY_STRING.is_valid([123, "valid"]) is False # First element invalid | |||||
| assert SegmentType.ARRAY_NUMBER.is_valid([42, "invalid"]) is False # First element valid | |||||
| assert SegmentType.ARRAY_NUMBER.is_valid(["invalid", 42]) is False # First element invalid | |||||
| def test_array_validation_edge_cases(self): | |||||
| """Test edge cases for array validation.""" | |||||
| # Test with nested arrays (should be invalid for specific array types) | |||||
| nested_array = [["nested", "array"], ["another", "nested"]] | |||||
| assert SegmentType.ARRAY_STRING.is_valid(nested_array, ArrayValidation.FIRST) is False | |||||
| assert SegmentType.ARRAY_STRING.is_valid(nested_array, ArrayValidation.ALL) is False | |||||
| assert SegmentType.ARRAY_ANY.is_valid(nested_array, ArrayValidation.ALL) is True | |||||
| # Test with very large arrays (performance consideration) | |||||
| large_valid_array = ["string"] * 1000 | |||||
| large_mixed_array = ["string"] * 999 + [123] # Last element invalid | |||||
| assert SegmentType.ARRAY_STRING.is_valid(large_valid_array, ArrayValidation.ALL) is True | |||||
| assert SegmentType.ARRAY_STRING.is_valid(large_mixed_array, ArrayValidation.ALL) is False | |||||
| assert SegmentType.ARRAY_STRING.is_valid(large_mixed_array, ArrayValidation.FIRST) is True | |||||
| class TestSegmentTypeValidationIntegration: | |||||
| """Integration tests for SegmentType validation covering interactions between methods.""" | |||||
| def test_non_array_types_ignore_array_validation_parameter(self): | |||||
| """Test that non-array types ignore the array_validation parameter.""" | |||||
| non_array_types = [ | |||||
| SegmentType.STRING, | |||||
| SegmentType.NUMBER, | |||||
| SegmentType.BOOLEAN, | |||||
| SegmentType.OBJECT, | |||||
| SegmentType.SECRET, | |||||
| SegmentType.FILE, | |||||
| SegmentType.NONE, | |||||
| ] | |||||
| for segment_type in non_array_types: | |||||
| # Create appropriate valid value for each type | |||||
| valid_value: Any | |||||
| if segment_type == SegmentType.STRING: | |||||
| valid_value = "test" | |||||
| elif segment_type == SegmentType.NUMBER: | |||||
| valid_value = 42 | |||||
| elif segment_type == SegmentType.BOOLEAN: | |||||
| valid_value = True | |||||
| elif segment_type == SegmentType.OBJECT: | |||||
| valid_value = {"key": "value"} | |||||
| elif segment_type == SegmentType.SECRET: | |||||
| valid_value = "secret" | |||||
| elif segment_type == SegmentType.FILE: | |||||
| valid_value = create_test_file() | |||||
| elif segment_type == SegmentType.NONE: | |||||
| valid_value = None | |||||
| else: | |||||
| continue # Skip unsupported types | |||||
| # All array validation strategies should give the same result | |||||
| result_none = segment_type.is_valid(valid_value, ArrayValidation.NONE) | |||||
| result_first = segment_type.is_valid(valid_value, ArrayValidation.FIRST) | |||||
| result_all = segment_type.is_valid(valid_value, ArrayValidation.ALL) | |||||
| assert result_none == result_first == result_all == True, ( | |||||
| f"{segment_type} should ignore array_validation parameter" | |||||
| ) | |||||
| def test_comprehensive_type_coverage(self): | |||||
| """Test that all SegmentType enum values are covered in validation tests.""" | |||||
| all_segment_types = set(SegmentType) | |||||
| # Types that should be handled by is_valid method | |||||
| handled_types = { | |||||
| # Non-array types | |||||
| SegmentType.STRING, | |||||
| SegmentType.NUMBER, | |||||
| SegmentType.BOOLEAN, | |||||
| SegmentType.OBJECT, | |||||
| SegmentType.SECRET, | |||||
| SegmentType.FILE, | |||||
| SegmentType.NONE, | |||||
| # Array types | |||||
| SegmentType.ARRAY_ANY, | |||||
| SegmentType.ARRAY_STRING, | |||||
| SegmentType.ARRAY_NUMBER, | |||||
| SegmentType.ARRAY_OBJECT, | |||||
| SegmentType.ARRAY_FILE, | |||||
| SegmentType.ARRAY_BOOLEAN, | |||||
| } | |||||
| # Types that are not handled by is_valid (should raise AssertionError) | |||||
| unhandled_types = { | |||||
| SegmentType.GROUP, | |||||
| SegmentType.INTEGER, # Handled by NUMBER validation logic | |||||
| SegmentType.FLOAT, # Handled by NUMBER validation logic | |||||
| } | |||||
| # Verify all types are accounted for | |||||
| assert handled_types | unhandled_types == all_segment_types, "All SegmentType values should be categorized" | |||||
| # Test that handled types work correctly | |||||
| for segment_type in handled_types: | |||||
| if segment_type.is_array_type(): | |||||
| # Test with empty array (should always be valid) | |||||
| assert segment_type.is_valid([]) is True, f"{segment_type} should accept empty array" | |||||
| else: | |||||
| # Test with appropriate valid value | |||||
| if segment_type == SegmentType.STRING: | |||||
| assert segment_type.is_valid("test") is True | |||||
| elif segment_type == SegmentType.NUMBER: | |||||
| assert segment_type.is_valid(42) is True | |||||
| elif segment_type == SegmentType.BOOLEAN: | |||||
| assert segment_type.is_valid(True) is True | |||||
| elif segment_type == SegmentType.OBJECT: | |||||
| assert segment_type.is_valid({}) is True | |||||
| elif segment_type == SegmentType.SECRET: | |||||
| assert segment_type.is_valid("secret") is True | |||||
| elif segment_type == SegmentType.FILE: | |||||
| assert segment_type.is_valid(create_test_file()) is True | |||||
| elif segment_type == SegmentType.NONE: | |||||
| assert segment_type.is_valid(None) is True | |||||
| def test_boolean_vs_integer_type_distinction(self): | |||||
| """Test the important distinction between boolean and integer types in validation.""" | |||||
| # This tests the comment in the code about bool being a subclass of int | |||||
| # Boolean type should only accept actual booleans, not integers | |||||
| assert SegmentType.BOOLEAN.is_valid(True) is True | |||||
| assert SegmentType.BOOLEAN.is_valid(False) is True | |||||
| assert SegmentType.BOOLEAN.is_valid(1) is False # Integer 1, not boolean | |||||
| assert SegmentType.BOOLEAN.is_valid(0) is False # Integer 0, not boolean | |||||
| # Number type should accept both integers and floats, including booleans (since bool is subclass of int) | |||||
| assert SegmentType.NUMBER.is_valid(42) is True | |||||
| assert SegmentType.NUMBER.is_valid(3.14) is True | |||||
| assert SegmentType.NUMBER.is_valid(True) is True # bool is subclass of int | |||||
| assert SegmentType.NUMBER.is_valid(False) is True # bool is subclass of int | |||||
| def test_array_validation_recursive_behavior(self): | |||||
| """Test that array validation correctly handles recursive validation calls.""" | |||||
| # When validating array elements, _validate_array calls is_valid recursively | |||||
| # with ArrayValidation.NONE to avoid infinite recursion | |||||
| # Test nested validation doesn't cause issues | |||||
| nested_arrays = [["inner", "array"], ["another", "inner"]] | |||||
| # ARRAY_ANY should accept nested arrays | |||||
| assert SegmentType.ARRAY_ANY.is_valid(nested_arrays, ArrayValidation.ALL) is True | |||||
| # ARRAY_STRING should reject nested arrays (first element is not a string) | |||||
| assert SegmentType.ARRAY_STRING.is_valid(nested_arrays, ArrayValidation.FIRST) is False | |||||
| assert SegmentType.ARRAY_STRING.is_valid(nested_arrays, ArrayValidation.ALL) is False |
| from core.variables.types import SegmentType | |||||
| from core.workflow.nodes.parameter_extractor.entities import ParameterConfig | |||||
| class TestParameterConfig: | |||||
| def test_select_type(self): | |||||
| data = { | |||||
| "name": "yes_or_no", | |||||
| "type": "select", | |||||
| "options": ["yes", "no"], | |||||
| "description": "a simple select made of `yes` and `no`", | |||||
| "required": True, | |||||
| } | |||||
| pc = ParameterConfig.model_validate(data) | |||||
| assert pc.type == SegmentType.STRING | |||||
| assert pc.options == data["options"] | |||||
| def test_validate_bool_type(self): | |||||
| data = { | |||||
| "name": "boolean", | |||||
| "type": "bool", | |||||
| "description": "a simple boolean parameter", | |||||
| "required": True, | |||||
| } | |||||
| pc = ParameterConfig.model_validate(data) | |||||
| assert pc.type == SegmentType.BOOLEAN |
| """ | |||||
| Test cases for ParameterExtractorNode._validate_result and _transform_result methods. | |||||
| """ | |||||
| from dataclasses import dataclass | |||||
| from typing import Any | |||||
| import pytest | |||||
| from core.model_runtime.entities import LLMMode | |||||
| from core.variables.types import SegmentType | |||||
| from core.workflow.nodes.llm import ModelConfig, VisionConfig | |||||
| from core.workflow.nodes.parameter_extractor.entities import ParameterConfig, ParameterExtractorNodeData | |||||
| from core.workflow.nodes.parameter_extractor.exc import ( | |||||
| InvalidNumberOfParametersError, | |||||
| InvalidSelectValueError, | |||||
| InvalidValueTypeError, | |||||
| RequiredParameterMissingError, | |||||
| ) | |||||
| from core.workflow.nodes.parameter_extractor.parameter_extractor_node import ParameterExtractorNode | |||||
| from factories.variable_factory import build_segment_with_type | |||||
| @dataclass | |||||
| class ValidTestCase: | |||||
| """Test case data for valid scenarios.""" | |||||
| name: str | |||||
| parameters: list[ParameterConfig] | |||||
| result: dict[str, Any] | |||||
| def get_name(self) -> str: | |||||
| return self.name | |||||
| @dataclass | |||||
| class ErrorTestCase: | |||||
| """Test case data for error scenarios.""" | |||||
| name: str | |||||
| parameters: list[ParameterConfig] | |||||
| result: dict[str, Any] | |||||
| expected_exception: type[Exception] | |||||
| expected_message: str | |||||
| def get_name(self) -> str: | |||||
| return self.name | |||||
| @dataclass | |||||
| class TransformTestCase: | |||||
| """Test case data for transformation scenarios.""" | |||||
| name: str | |||||
| parameters: list[ParameterConfig] | |||||
| input_result: dict[str, Any] | |||||
| expected_result: dict[str, Any] | |||||
| def get_name(self) -> str: | |||||
| return self.name | |||||
| class TestParameterExtractorNodeMethods: | |||||
| """Test helper class that provides access to the methods under test.""" | |||||
| def validate_result(self, data: ParameterExtractorNodeData, result: dict[str, Any]) -> dict[str, Any]: | |||||
| """Wrapper to call _validate_result method.""" | |||||
| node = ParameterExtractorNode.__new__(ParameterExtractorNode) | |||||
| return node._validate_result(data=data, result=result) | |||||
| def transform_result(self, data: ParameterExtractorNodeData, result: dict[str, Any]) -> dict[str, Any]: | |||||
| """Wrapper to call _transform_result method.""" | |||||
| node = ParameterExtractorNode.__new__(ParameterExtractorNode) | |||||
| return node._transform_result(data=data, result=result) | |||||
| class TestValidateResult: | |||||
| """Test cases for _validate_result method.""" | |||||
| @staticmethod | |||||
| def get_valid_test_cases() -> list[ValidTestCase]: | |||||
| """Get test cases that should pass validation.""" | |||||
| return [ | |||||
| ValidTestCase( | |||||
| name="single_string_parameter", | |||||
| parameters=[ParameterConfig(name="name", type=SegmentType.STRING, description="Name", required=True)], | |||||
| result={"name": "John"}, | |||||
| ), | |||||
| ValidTestCase( | |||||
| name="single_number_parameter_int", | |||||
| parameters=[ParameterConfig(name="age", type=SegmentType.NUMBER, description="Age", required=True)], | |||||
| result={"age": 25}, | |||||
| ), | |||||
| ValidTestCase( | |||||
| name="single_number_parameter_float", | |||||
| parameters=[ParameterConfig(name="price", type=SegmentType.NUMBER, description="Price", required=True)], | |||||
| result={"price": 19.99}, | |||||
| ), | |||||
| ValidTestCase( | |||||
| name="single_bool_parameter_true", | |||||
| parameters=[ | |||||
| ParameterConfig(name="active", type=SegmentType.BOOLEAN, description="Active", required=True) | |||||
| ], | |||||
| result={"active": True}, | |||||
| ), | |||||
| ValidTestCase( | |||||
| name="single_bool_parameter_true", | |||||
| parameters=[ | |||||
| ParameterConfig(name="active", type=SegmentType.BOOLEAN, description="Active", required=True) | |||||
| ], | |||||
| result={"active": True}, | |||||
| ), | |||||
| ValidTestCase( | |||||
| name="single_bool_parameter_false", | |||||
| parameters=[ | |||||
| ParameterConfig(name="active", type=SegmentType.BOOLEAN, description="Active", required=True) | |||||
| ], | |||||
| result={"active": False}, | |||||
| ), | |||||
| ValidTestCase( | |||||
| name="select_parameter_valid_option", | |||||
| parameters=[ | |||||
| ParameterConfig( | |||||
| name="status", | |||||
| type="select", # pyright: ignore[reportArgumentType] | |||||
| description="Status", | |||||
| required=True, | |||||
| options=["active", "inactive"], | |||||
| ) | |||||
| ], | |||||
| result={"status": "active"}, | |||||
| ), | |||||
| ValidTestCase( | |||||
| name="array_string_parameter", | |||||
| parameters=[ | |||||
| ParameterConfig(name="tags", type=SegmentType.ARRAY_STRING, description="Tags", required=True) | |||||
| ], | |||||
| result={"tags": ["tag1", "tag2", "tag3"]}, | |||||
| ), | |||||
| ValidTestCase( | |||||
| name="array_number_parameter", | |||||
| parameters=[ | |||||
| ParameterConfig(name="scores", type=SegmentType.ARRAY_NUMBER, description="Scores", required=True) | |||||
| ], | |||||
| result={"scores": [85, 92.5, 78]}, | |||||
| ), | |||||
| ValidTestCase( | |||||
| name="array_object_parameter", | |||||
| parameters=[ | |||||
| ParameterConfig(name="items", type=SegmentType.ARRAY_OBJECT, description="Items", required=True) | |||||
| ], | |||||
| result={"items": [{"name": "item1"}, {"name": "item2"}]}, | |||||
| ), | |||||
| ValidTestCase( | |||||
| name="multiple_parameters", | |||||
| parameters=[ | |||||
| ParameterConfig(name="name", type=SegmentType.STRING, description="Name", required=True), | |||||
| ParameterConfig(name="age", type=SegmentType.NUMBER, description="Age", required=True), | |||||
| ParameterConfig(name="active", type=SegmentType.BOOLEAN, description="Active", required=True), | |||||
| ], | |||||
| result={"name": "John", "age": 25, "active": True}, | |||||
| ), | |||||
| ValidTestCase( | |||||
| name="optional_parameter_present", | |||||
| parameters=[ | |||||
| ParameterConfig(name="name", type=SegmentType.STRING, description="Name", required=True), | |||||
| ParameterConfig(name="nickname", type=SegmentType.STRING, description="Nickname", required=False), | |||||
| ], | |||||
| result={"name": "John", "nickname": "Johnny"}, | |||||
| ), | |||||
| ValidTestCase( | |||||
| name="empty_array_parameter", | |||||
| parameters=[ | |||||
| ParameterConfig(name="tags", type=SegmentType.ARRAY_STRING, description="Tags", required=True) | |||||
| ], | |||||
| result={"tags": []}, | |||||
| ), | |||||
| ] | |||||
| @staticmethod | |||||
| def get_error_test_cases() -> list[ErrorTestCase]: | |||||
| """Get test cases that should raise exceptions.""" | |||||
| return [ | |||||
| ErrorTestCase( | |||||
| name="invalid_number_of_parameters_too_few", | |||||
| parameters=[ | |||||
| ParameterConfig(name="name", type=SegmentType.STRING, description="Name", required=True), | |||||
| ParameterConfig(name="age", type=SegmentType.NUMBER, description="Age", required=True), | |||||
| ], | |||||
| result={"name": "John"}, | |||||
| expected_exception=InvalidNumberOfParametersError, | |||||
| expected_message="Invalid number of parameters", | |||||
| ), | |||||
| ErrorTestCase( | |||||
| name="invalid_number_of_parameters_too_many", | |||||
| parameters=[ParameterConfig(name="name", type=SegmentType.STRING, description="Name", required=True)], | |||||
| result={"name": "John", "age": 25}, | |||||
| expected_exception=InvalidNumberOfParametersError, | |||||
| expected_message="Invalid number of parameters", | |||||
| ), | |||||
| ErrorTestCase( | |||||
| name="invalid_string_value_none", | |||||
| parameters=[ | |||||
| ParameterConfig(name="name", type=SegmentType.STRING, description="Name", required=True), | |||||
| ], | |||||
| result={"name": None}, # Parameter present but None value, will trigger type check first | |||||
| expected_exception=InvalidValueTypeError, | |||||
| expected_message="Invalid value for parameter name, expected segment type: string, actual_type: none", | |||||
| ), | |||||
| ErrorTestCase( | |||||
| name="invalid_select_value", | |||||
| parameters=[ | |||||
| ParameterConfig( | |||||
| name="status", | |||||
| type="select", # type: ignore | |||||
| description="Status", | |||||
| required=True, | |||||
| options=["active", "inactive"], | |||||
| ) | |||||
| ], | |||||
| result={"status": "pending"}, | |||||
| expected_exception=InvalidSelectValueError, | |||||
| expected_message="Invalid `select` value for parameter status", | |||||
| ), | |||||
| ErrorTestCase( | |||||
| name="invalid_number_value_string", | |||||
| parameters=[ParameterConfig(name="age", type=SegmentType.NUMBER, description="Age", required=True)], | |||||
| result={"age": "twenty-five"}, | |||||
| expected_exception=InvalidValueTypeError, | |||||
| expected_message="Invalid value for parameter age, expected segment type: number, actual_type: string", | |||||
| ), | |||||
| ErrorTestCase( | |||||
| name="invalid_bool_value_string", | |||||
| parameters=[ | |||||
| ParameterConfig(name="active", type=SegmentType.BOOLEAN, description="Active", required=True) | |||||
| ], | |||||
| result={"active": "yes"}, | |||||
| expected_exception=InvalidValueTypeError, | |||||
| expected_message=( | |||||
| "Invalid value for parameter active, expected segment type: boolean, actual_type: string" | |||||
| ), | |||||
| ), | |||||
| ErrorTestCase( | |||||
| name="invalid_string_value_number", | |||||
| parameters=[ | |||||
| ParameterConfig( | |||||
| name="description", type=SegmentType.STRING, description="Description", required=True | |||||
| ) | |||||
| ], | |||||
| result={"description": 123}, | |||||
| expected_exception=InvalidValueTypeError, | |||||
| expected_message=( | |||||
| "Invalid value for parameter description, expected segment type: string, actual_type: integer" | |||||
| ), | |||||
| ), | |||||
| ErrorTestCase( | |||||
| name="invalid_array_value_not_list", | |||||
| parameters=[ | |||||
| ParameterConfig(name="tags", type=SegmentType.ARRAY_STRING, description="Tags", required=True) | |||||
| ], | |||||
| result={"tags": "tag1,tag2,tag3"}, | |||||
| expected_exception=InvalidValueTypeError, | |||||
| expected_message=( | |||||
| "Invalid value for parameter tags, expected segment type: array[string], actual_type: string" | |||||
| ), | |||||
| ), | |||||
| ErrorTestCase( | |||||
| name="invalid_array_number_wrong_element_type", | |||||
| parameters=[ | |||||
| ParameterConfig(name="scores", type=SegmentType.ARRAY_NUMBER, description="Scores", required=True) | |||||
| ], | |||||
| result={"scores": [85, "ninety-two", 78]}, | |||||
| expected_exception=InvalidValueTypeError, | |||||
| expected_message=( | |||||
| "Invalid value for parameter scores, expected segment type: array[number], actual_type: array[any]" | |||||
| ), | |||||
| ), | |||||
| ErrorTestCase( | |||||
| name="invalid_array_string_wrong_element_type", | |||||
| parameters=[ | |||||
| ParameterConfig(name="tags", type=SegmentType.ARRAY_STRING, description="Tags", required=True) | |||||
| ], | |||||
| result={"tags": ["tag1", 123, "tag3"]}, | |||||
| expected_exception=InvalidValueTypeError, | |||||
| expected_message=( | |||||
| "Invalid value for parameter tags, expected segment type: array[string], actual_type: array[any]" | |||||
| ), | |||||
| ), | |||||
| ErrorTestCase( | |||||
| name="invalid_array_object_wrong_element_type", | |||||
| parameters=[ | |||||
| ParameterConfig(name="items", type=SegmentType.ARRAY_OBJECT, description="Items", required=True) | |||||
| ], | |||||
| result={"items": [{"name": "item1"}, "item2"]}, | |||||
| expected_exception=InvalidValueTypeError, | |||||
| expected_message=( | |||||
| "Invalid value for parameter items, expected segment type: array[object], actual_type: array[any]" | |||||
| ), | |||||
| ), | |||||
| ErrorTestCase( | |||||
| name="required_parameter_missing", | |||||
| parameters=[ | |||||
| ParameterConfig(name="name", type=SegmentType.STRING, description="Name", required=True), | |||||
| ParameterConfig(name="age", type=SegmentType.NUMBER, description="Age", required=False), | |||||
| ], | |||||
| result={"age": 25, "other": "value"}, # Missing required 'name' parameter, but has correct count | |||||
| expected_exception=RequiredParameterMissingError, | |||||
| expected_message="Parameter name is required", | |||||
| ), | |||||
| ] | |||||
| @pytest.mark.parametrize("test_case", get_valid_test_cases(), ids=ValidTestCase.get_name) | |||||
| def test_validate_result_valid_cases(self, test_case): | |||||
| """Test _validate_result with valid inputs.""" | |||||
| helper = TestParameterExtractorNodeMethods() | |||||
| node_data = ParameterExtractorNodeData( | |||||
| title="Test Node", | |||||
| model=ModelConfig(provider="openai", name="gpt-3.5-turbo", mode=LLMMode.CHAT, completion_params={}), | |||||
| query=["test_query"], | |||||
| parameters=test_case.parameters, | |||||
| reasoning_mode="function_call", | |||||
| vision=VisionConfig(), | |||||
| ) | |||||
| result = helper.validate_result(data=node_data, result=test_case.result) | |||||
| assert result == test_case.result, f"Failed for case: {test_case.name}" | |||||
| @pytest.mark.parametrize("test_case", get_error_test_cases(), ids=ErrorTestCase.get_name) | |||||
| def test_validate_result_error_cases(self, test_case): | |||||
| """Test _validate_result with invalid inputs that should raise exceptions.""" | |||||
| helper = TestParameterExtractorNodeMethods() | |||||
| node_data = ParameterExtractorNodeData( | |||||
| title="Test Node", | |||||
| model=ModelConfig(provider="openai", name="gpt-3.5-turbo", mode=LLMMode.CHAT, completion_params={}), | |||||
| query=["test_query"], | |||||
| parameters=test_case.parameters, | |||||
| reasoning_mode="function_call", | |||||
| vision=VisionConfig(), | |||||
| ) | |||||
| with pytest.raises(test_case.expected_exception) as exc_info: | |||||
| helper.validate_result(data=node_data, result=test_case.result) | |||||
| assert test_case.expected_message in str(exc_info.value), f"Failed for case: {test_case.name}" | |||||
| class TestTransformResult: | |||||
| """Test cases for _transform_result method.""" | |||||
| @staticmethod | |||||
| def get_transform_test_cases() -> list[TransformTestCase]: | |||||
| """Get test cases for result transformation.""" | |||||
| return [ | |||||
| # String parameter transformation | |||||
| TransformTestCase( | |||||
| name="string_parameter_present", | |||||
| parameters=[ParameterConfig(name="name", type=SegmentType.STRING, description="Name", required=True)], | |||||
| input_result={"name": "John"}, | |||||
| expected_result={"name": "John"}, | |||||
| ), | |||||
| TransformTestCase( | |||||
| name="string_parameter_missing", | |||||
| parameters=[ParameterConfig(name="name", type=SegmentType.STRING, description="Name", required=True)], | |||||
| input_result={}, | |||||
| expected_result={"name": ""}, | |||||
| ), | |||||
| # Number parameter transformation | |||||
| TransformTestCase( | |||||
| name="number_parameter_int_present", | |||||
| parameters=[ParameterConfig(name="age", type=SegmentType.NUMBER, description="Age", required=True)], | |||||
| input_result={"age": 25}, | |||||
| expected_result={"age": 25}, | |||||
| ), | |||||
| TransformTestCase( | |||||
| name="number_parameter_float_present", | |||||
| parameters=[ParameterConfig(name="price", type=SegmentType.NUMBER, description="Price", required=True)], | |||||
| input_result={"price": 19.99}, | |||||
| expected_result={"price": 19.99}, | |||||
| ), | |||||
| TransformTestCase( | |||||
| name="number_parameter_missing", | |||||
| parameters=[ParameterConfig(name="age", type=SegmentType.NUMBER, description="Age", required=True)], | |||||
| input_result={}, | |||||
| expected_result={"age": 0}, | |||||
| ), | |||||
| # Bool parameter transformation | |||||
| TransformTestCase( | |||||
| name="bool_parameter_missing", | |||||
| parameters=[ | |||||
| ParameterConfig(name="active", type=SegmentType.BOOLEAN, description="Active", required=True) | |||||
| ], | |||||
| input_result={}, | |||||
| expected_result={"active": False}, | |||||
| ), | |||||
| # Select parameter transformation | |||||
| TransformTestCase( | |||||
| name="select_parameter_present", | |||||
| parameters=[ | |||||
| ParameterConfig( | |||||
| name="status", | |||||
| type="select", # type: ignore | |||||
| description="Status", | |||||
| required=True, | |||||
| options=["active", "inactive"], | |||||
| ) | |||||
| ], | |||||
| input_result={"status": "active"}, | |||||
| expected_result={"status": "active"}, | |||||
| ), | |||||
| TransformTestCase( | |||||
| name="select_parameter_missing", | |||||
| parameters=[ | |||||
| ParameterConfig( | |||||
| name="status", | |||||
| type="select", # type: ignore | |||||
| description="Status", | |||||
| required=True, | |||||
| options=["active", "inactive"], | |||||
| ) | |||||
| ], | |||||
| input_result={}, | |||||
| expected_result={"status": ""}, | |||||
| ), | |||||
| # Array parameter transformation - present cases | |||||
| TransformTestCase( | |||||
| name="array_string_parameter_present", | |||||
| parameters=[ | |||||
| ParameterConfig(name="tags", type=SegmentType.ARRAY_STRING, description="Tags", required=True) | |||||
| ], | |||||
| input_result={"tags": ["tag1", "tag2"]}, | |||||
| expected_result={ | |||||
| "tags": build_segment_with_type(segment_type=SegmentType.ARRAY_STRING, value=["tag1", "tag2"]) | |||||
| }, | |||||
| ), | |||||
| TransformTestCase( | |||||
| name="array_number_parameter_present", | |||||
| parameters=[ | |||||
| ParameterConfig(name="scores", type=SegmentType.ARRAY_NUMBER, description="Scores", required=True) | |||||
| ], | |||||
| input_result={"scores": [85, 92.5]}, | |||||
| expected_result={ | |||||
| "scores": build_segment_with_type(segment_type=SegmentType.ARRAY_NUMBER, value=[85, 92.5]) | |||||
| }, | |||||
| ), | |||||
| TransformTestCase( | |||||
| name="array_number_parameter_with_string_conversion", | |||||
| parameters=[ | |||||
| ParameterConfig(name="scores", type=SegmentType.ARRAY_NUMBER, description="Scores", required=True) | |||||
| ], | |||||
| input_result={"scores": [85, "92.5", "78"]}, | |||||
| expected_result={ | |||||
| "scores": build_segment_with_type(segment_type=SegmentType.ARRAY_NUMBER, value=[85, 92.5, 78]) | |||||
| }, | |||||
| ), | |||||
| TransformTestCase( | |||||
| name="array_object_parameter_present", | |||||
| parameters=[ | |||||
| ParameterConfig(name="items", type=SegmentType.ARRAY_OBJECT, description="Items", required=True) | |||||
| ], | |||||
| input_result={"items": [{"name": "item1"}, {"name": "item2"}]}, | |||||
| expected_result={ | |||||
| "items": build_segment_with_type( | |||||
| segment_type=SegmentType.ARRAY_OBJECT, value=[{"name": "item1"}, {"name": "item2"}] | |||||
| ) | |||||
| }, | |||||
| ), | |||||
| # Array parameter transformation - missing cases | |||||
| TransformTestCase( | |||||
| name="array_string_parameter_missing", | |||||
| parameters=[ | |||||
| ParameterConfig(name="tags", type=SegmentType.ARRAY_STRING, description="Tags", required=True) | |||||
| ], | |||||
| input_result={}, | |||||
| expected_result={"tags": build_segment_with_type(segment_type=SegmentType.ARRAY_STRING, value=[])}, | |||||
| ), | |||||
| TransformTestCase( | |||||
| name="array_number_parameter_missing", | |||||
| parameters=[ | |||||
| ParameterConfig(name="scores", type=SegmentType.ARRAY_NUMBER, description="Scores", required=True) | |||||
| ], | |||||
| input_result={}, | |||||
| expected_result={"scores": build_segment_with_type(segment_type=SegmentType.ARRAY_NUMBER, value=[])}, | |||||
| ), | |||||
| TransformTestCase( | |||||
| name="array_object_parameter_missing", | |||||
| parameters=[ | |||||
| ParameterConfig(name="items", type=SegmentType.ARRAY_OBJECT, description="Items", required=True) | |||||
| ], | |||||
| input_result={}, | |||||
| expected_result={"items": build_segment_with_type(segment_type=SegmentType.ARRAY_OBJECT, value=[])}, | |||||
| ), | |||||
| # Multiple parameters transformation | |||||
| TransformTestCase( | |||||
| name="multiple_parameters_mixed", | |||||
| parameters=[ | |||||
| ParameterConfig(name="name", type=SegmentType.STRING, description="Name", required=True), | |||||
| ParameterConfig(name="age", type=SegmentType.NUMBER, description="Age", required=True), | |||||
| ParameterConfig(name="active", type=SegmentType.BOOLEAN, description="Active", required=True), | |||||
| ParameterConfig(name="tags", type=SegmentType.ARRAY_STRING, description="Tags", required=True), | |||||
| ], | |||||
| input_result={"name": "John", "age": 25}, | |||||
| expected_result={ | |||||
| "name": "John", | |||||
| "age": 25, | |||||
| "active": False, | |||||
| "tags": build_segment_with_type(segment_type=SegmentType.ARRAY_STRING, value=[]), | |||||
| }, | |||||
| ), | |||||
| # Number parameter transformation with string conversion | |||||
| TransformTestCase( | |||||
| name="number_parameter_string_to_float", | |||||
| parameters=[ParameterConfig(name="price", type=SegmentType.NUMBER, description="Price", required=True)], | |||||
| input_result={"price": "19.99"}, | |||||
| expected_result={"price": 19.99}, # String not converted, falls back to default | |||||
| ), | |||||
| TransformTestCase( | |||||
| name="number_parameter_string_to_int", | |||||
| parameters=[ParameterConfig(name="age", type=SegmentType.NUMBER, description="Age", required=True)], | |||||
| input_result={"age": "25"}, | |||||
| expected_result={"age": 25}, # String not converted, falls back to default | |||||
| ), | |||||
| TransformTestCase( | |||||
| name="number_parameter_invalid_string", | |||||
| parameters=[ParameterConfig(name="age", type=SegmentType.NUMBER, description="Age", required=True)], | |||||
| input_result={"age": "invalid_number"}, | |||||
| expected_result={"age": 0}, # Invalid string conversion fails, falls back to default | |||||
| ), | |||||
| TransformTestCase( | |||||
| name="number_parameter_non_string_non_number", | |||||
| parameters=[ParameterConfig(name="age", type=SegmentType.NUMBER, description="Age", required=True)], | |||||
| input_result={"age": ["not_a_number"]}, # Non-string, non-number value | |||||
| expected_result={"age": 0}, # Falls back to default | |||||
| ), | |||||
| TransformTestCase( | |||||
| name="array_number_parameter_with_invalid_string_conversion", | |||||
| parameters=[ | |||||
| ParameterConfig(name="scores", type=SegmentType.ARRAY_NUMBER, description="Scores", required=True) | |||||
| ], | |||||
| input_result={"scores": [85, "invalid", "78"]}, | |||||
| expected_result={ | |||||
| "scores": build_segment_with_type( | |||||
| segment_type=SegmentType.ARRAY_NUMBER, value=[85, 78] | |||||
| ) # Invalid string skipped | |||||
| }, | |||||
| ), | |||||
| ] | |||||
| @pytest.mark.parametrize("test_case", get_transform_test_cases(), ids=TransformTestCase.get_name) | |||||
| def test_transform_result_cases(self, test_case): | |||||
| """Test _transform_result with various inputs.""" | |||||
| helper = TestParameterExtractorNodeMethods() | |||||
| node_data = ParameterExtractorNodeData( | |||||
| title="Test Node", | |||||
| model=ModelConfig(provider="openai", name="gpt-3.5-turbo", mode=LLMMode.CHAT, completion_params={}), | |||||
| query=["test_query"], | |||||
| parameters=test_case.parameters, | |||||
| reasoning_mode="function_call", | |||||
| vision=VisionConfig(), | |||||
| ) | |||||
| result = helper.transform_result(data=node_data, result=test_case.input_result) | |||||
| assert result == test_case.expected_result, ( | |||||
| f"Failed for case: {test_case.name}. Expected: {test_case.expected_result}, Got: {result}" | |||||
| ) |
| import uuid | import uuid | ||||
| from unittest.mock import MagicMock, Mock | from unittest.mock import MagicMock, Mock | ||||
| import pytest | |||||
| from core.app.entities.app_invoke_entities import InvokeFrom | from core.app.entities.app_invoke_entities import InvokeFrom | ||||
| from core.file import File, FileTransferMethod, FileType | from core.file import File, FileTransferMethod, FileType | ||||
| from core.variables import ArrayFileSegment | from core.variables import ArrayFileSegment | ||||
| assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED | assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED | ||||
| assert result.outputs is not None | assert result.outputs is not None | ||||
| assert result.outputs["result"] is True | assert result.outputs["result"] is True | ||||
| def _get_test_conditions() -> list: | |||||
| conditions = [ | |||||
| # Test boolean "is" operator | |||||
| {"comparison_operator": "is", "variable_selector": ["start", "bool_true"], "value": "true"}, | |||||
| # Test boolean "is not" operator | |||||
| {"comparison_operator": "is not", "variable_selector": ["start", "bool_false"], "value": "true"}, | |||||
| # Test boolean "=" operator | |||||
| {"comparison_operator": "=", "variable_selector": ["start", "bool_true"], "value": "1"}, | |||||
| # Test boolean "≠" operator | |||||
| {"comparison_operator": "≠", "variable_selector": ["start", "bool_false"], "value": "1"}, | |||||
| # Test boolean "not null" operator | |||||
| {"comparison_operator": "not null", "variable_selector": ["start", "bool_true"]}, | |||||
| # Test boolean array "contains" operator | |||||
| {"comparison_operator": "contains", "variable_selector": ["start", "bool_array"], "value": "true"}, | |||||
| # Test boolean "in" operator | |||||
| { | |||||
| "comparison_operator": "in", | |||||
| "variable_selector": ["start", "bool_true"], | |||||
| "value": ["true", "false"], | |||||
| }, | |||||
| ] | |||||
| return [Condition.model_validate(i) for i in conditions] | |||||
| def _get_condition_test_id(c: Condition): | |||||
| return c.comparison_operator | |||||
| @pytest.mark.parametrize("condition", _get_test_conditions(), ids=_get_condition_test_id) | |||||
| def test_execute_if_else_boolean_conditions(condition: Condition): | |||||
| """Test IfElseNode with boolean conditions using various operators""" | |||||
| graph_config = {"edges": [], "nodes": [{"data": {"type": "start"}, "id": "start"}]} | |||||
| graph = Graph.init(graph_config=graph_config) | |||||
| init_params = GraphInitParams( | |||||
| tenant_id="1", | |||||
| app_id="1", | |||||
| workflow_type=WorkflowType.WORKFLOW, | |||||
| workflow_id="1", | |||||
| graph_config=graph_config, | |||||
| user_id="1", | |||||
| user_from=UserFrom.ACCOUNT, | |||||
| invoke_from=InvokeFrom.DEBUGGER, | |||||
| call_depth=0, | |||||
| ) | |||||
| # construct variable pool with boolean values | |||||
| pool = VariablePool( | |||||
| system_variables=SystemVariable(files=[], user_id="aaa"), | |||||
| ) | |||||
| pool.add(["start", "bool_true"], True) | |||||
| pool.add(["start", "bool_false"], False) | |||||
| pool.add(["start", "bool_array"], [True, False, True]) | |||||
| pool.add(["start", "mixed_array"], [True, "false", 1, 0]) | |||||
| node_data = { | |||||
| "title": "Boolean Test", | |||||
| "type": "if-else", | |||||
| "logical_operator": "and", | |||||
| "conditions": [condition.model_dump()], | |||||
| } | |||||
| node = IfElseNode( | |||||
| id=str(uuid.uuid4()), | |||||
| graph_init_params=init_params, | |||||
| graph=graph, | |||||
| graph_runtime_state=GraphRuntimeState(variable_pool=pool, start_at=time.perf_counter()), | |||||
| config={"id": "if-else", "data": node_data}, | |||||
| ) | |||||
| node.init_node_data(node_data) | |||||
| # Mock db.session.close() | |||||
| db.session.close = MagicMock() | |||||
| # execute node | |||||
| result = node._run() | |||||
| assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED | |||||
| assert result.outputs is not None | |||||
| assert result.outputs["result"] is True | |||||
| def test_execute_if_else_boolean_false_conditions(): | |||||
| """Test IfElseNode with boolean conditions that should evaluate to false""" | |||||
| graph_config = {"edges": [], "nodes": [{"data": {"type": "start"}, "id": "start"}]} | |||||
| graph = Graph.init(graph_config=graph_config) | |||||
| init_params = GraphInitParams( | |||||
| tenant_id="1", | |||||
| app_id="1", | |||||
| workflow_type=WorkflowType.WORKFLOW, | |||||
| workflow_id="1", | |||||
| graph_config=graph_config, | |||||
| user_id="1", | |||||
| user_from=UserFrom.ACCOUNT, | |||||
| invoke_from=InvokeFrom.DEBUGGER, | |||||
| call_depth=0, | |||||
| ) | |||||
| # construct variable pool with boolean values | |||||
| pool = VariablePool( | |||||
| system_variables=SystemVariable(files=[], user_id="aaa"), | |||||
| ) | |||||
| pool.add(["start", "bool_true"], True) | |||||
| pool.add(["start", "bool_false"], False) | |||||
| pool.add(["start", "bool_array"], [True, False, True]) | |||||
| node_data = { | |||||
| "title": "Boolean False Test", | |||||
| "type": "if-else", | |||||
| "logical_operator": "or", | |||||
| "conditions": [ | |||||
| # Test boolean "is" operator (should be false) | |||||
| {"comparison_operator": "is", "variable_selector": ["start", "bool_true"], "value": "false"}, | |||||
| # Test boolean "=" operator (should be false) | |||||
| {"comparison_operator": "=", "variable_selector": ["start", "bool_false"], "value": "1"}, | |||||
| # Test boolean "not contains" operator (should be false) | |||||
| { | |||||
| "comparison_operator": "not contains", | |||||
| "variable_selector": ["start", "bool_array"], | |||||
| "value": "true", | |||||
| }, | |||||
| ], | |||||
| } | |||||
| node = IfElseNode( | |||||
| id=str(uuid.uuid4()), | |||||
| graph_init_params=init_params, | |||||
| graph=graph, | |||||
| graph_runtime_state=GraphRuntimeState(variable_pool=pool, start_at=time.perf_counter()), | |||||
| config={ | |||||
| "id": "if-else", | |||||
| "data": node_data, | |||||
| }, | |||||
| ) | |||||
| node.init_node_data(node_data) | |||||
| # Mock db.session.close() | |||||
| db.session.close = MagicMock() | |||||
| # execute node | |||||
| result = node._run() | |||||
| assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED | |||||
| assert result.outputs is not None | |||||
| assert result.outputs["result"] is False | |||||
| def test_execute_if_else_boolean_cases_structure(): | |||||
| """Test IfElseNode with boolean conditions using the new cases structure""" | |||||
| graph_config = {"edges": [], "nodes": [{"data": {"type": "start"}, "id": "start"}]} | |||||
| graph = Graph.init(graph_config=graph_config) | |||||
| init_params = GraphInitParams( | |||||
| tenant_id="1", | |||||
| app_id="1", | |||||
| workflow_type=WorkflowType.WORKFLOW, | |||||
| workflow_id="1", | |||||
| graph_config=graph_config, | |||||
| user_id="1", | |||||
| user_from=UserFrom.ACCOUNT, | |||||
| invoke_from=InvokeFrom.DEBUGGER, | |||||
| call_depth=0, | |||||
| ) | |||||
| # construct variable pool with boolean values | |||||
| pool = VariablePool( | |||||
| system_variables=SystemVariable(files=[], user_id="aaa"), | |||||
| ) | |||||
| pool.add(["start", "bool_true"], True) | |||||
| pool.add(["start", "bool_false"], False) | |||||
| node_data = { | |||||
| "title": "Boolean Cases Test", | |||||
| "type": "if-else", | |||||
| "cases": [ | |||||
| { | |||||
| "case_id": "true", | |||||
| "logical_operator": "and", | |||||
| "conditions": [ | |||||
| { | |||||
| "comparison_operator": "is", | |||||
| "variable_selector": ["start", "bool_true"], | |||||
| "value": "true", | |||||
| }, | |||||
| { | |||||
| "comparison_operator": "is not", | |||||
| "variable_selector": ["start", "bool_false"], | |||||
| "value": "true", | |||||
| }, | |||||
| ], | |||||
| } | |||||
| ], | |||||
| } | |||||
| node = IfElseNode( | |||||
| id=str(uuid.uuid4()), | |||||
| graph_init_params=init_params, | |||||
| graph=graph, | |||||
| graph_runtime_state=GraphRuntimeState(variable_pool=pool, start_at=time.perf_counter()), | |||||
| config={"id": "if-else", "data": node_data}, | |||||
| ) | |||||
| node.init_node_data(node_data) | |||||
| # Mock db.session.close() | |||||
| db.session.close = MagicMock() | |||||
| # execute node | |||||
| result = node._run() | |||||
| assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED | |||||
| assert result.outputs is not None | |||||
| assert result.outputs["result"] is True | |||||
| assert result.outputs["selected_case_id"] == "true" |
| FilterCondition, | FilterCondition, | ||||
| Limit, | Limit, | ||||
| ListOperatorNodeData, | ListOperatorNodeData, | ||||
| OrderBy, | |||||
| Order, | |||||
| OrderByConfig, | |||||
| ) | ) | ||||
| from core.workflow.nodes.list_operator.exc import InvalidKeyError | from core.workflow.nodes.list_operator.exc import InvalidKeyError | ||||
| from core.workflow.nodes.list_operator.node import ListOperatorNode, _get_file_extract_string_func | from core.workflow.nodes.list_operator.node import ListOperatorNode, _get_file_extract_string_func | ||||
| FilterCondition(key="type", comparison_operator="in", value=[FileType.IMAGE, FileType.DOCUMENT]) | FilterCondition(key="type", comparison_operator="in", value=[FileType.IMAGE, FileType.DOCUMENT]) | ||||
| ], | ], | ||||
| ), | ), | ||||
| "order_by": OrderBy(enabled=False, value="asc"), | |||||
| "order_by": OrderByConfig(enabled=False, value=Order.ASC), | |||||
| "limit": Limit(enabled=False, size=0), | "limit": Limit(enabled=False, size=0), | ||||
| "extract_by": ExtractConfig(enabled=False, serial="1"), | "extract_by": ExtractConfig(enabled=False, serial="1"), | ||||
| "title": "Test Title", | "title": "Test Title", |
| ArrayNumberSegment, | ArrayNumberSegment, | ||||
| ArrayObjectSegment, | ArrayObjectSegment, | ||||
| ArrayStringSegment, | ArrayStringSegment, | ||||
| BooleanSegment, | |||||
| FileSegment, | FileSegment, | ||||
| FloatSegment, | FloatSegment, | ||||
| IntegerSegment, | IntegerSegment, | ||||
| NoneSegment, | NoneSegment, | ||||
| ObjectSegment, | ObjectSegment, | ||||
| Segment, | |||||
| StringSegment, | StringSegment, | ||||
| ) | ) | ||||
| from core.variables.types import SegmentType | from core.variables.types import SegmentType | ||||
| from factories import variable_factory | from factories import variable_factory | ||||
| from factories.variable_factory import TypeMismatchError, build_segment_with_type | |||||
| from factories.variable_factory import TypeMismatchError, build_segment, build_segment_with_type | |||||
| def test_string_variable(): | def test_string_variable(): | ||||
| assert isinstance(variable.value[1], float) | assert isinstance(variable.value[1], float) | ||||
| def test_build_segment_scalar_values(): | |||||
| @dataclass | |||||
| class TestCase: | |||||
| value: Any | |||||
| expected: Segment | |||||
| description: str | |||||
| cases = [ | |||||
| TestCase( | |||||
| value=True, | |||||
| expected=BooleanSegment(value=True), | |||||
| description="build_segment with boolean should yield BooleanSegment", | |||||
| ) | |||||
| ] | |||||
| for idx, c in enumerate(cases, 1): | |||||
| seg = build_segment(c.value) | |||||
| assert seg == c.expected, f"Test case {idx} failed: {c.description}" | |||||
| def test_array_object_variable(): | def test_array_object_variable(): | ||||
| mapping = { | mapping = { | ||||
| "id": str(uuid4()), | "id": str(uuid4()), | ||||
| f"but got: {error_message}" | f"but got: {error_message}" | ||||
| ) | ) | ||||
| def test_build_segment_boolean_type_note(self): | |||||
| """Note: Boolean values are actually handled as integers in Python, so they don't raise ValueError.""" | |||||
| # Boolean values in Python are subclasses of int, so they get processed as integers | |||||
| # True becomes IntegerSegment(value=1) and False becomes IntegerSegment(value=0) | |||||
| def test_build_segment_boolean_type(self): | |||||
| """Test that Boolean values are correctly handled as boolean type, not integers.""" | |||||
| # Boolean values should now be processed as BooleanSegment, not IntegerSegment | |||||
| # This is because the bool check now comes before the int check in build_segment | |||||
| true_segment = variable_factory.build_segment(True) | true_segment = variable_factory.build_segment(True) | ||||
| false_segment = variable_factory.build_segment(False) | false_segment = variable_factory.build_segment(False) | ||||
| # Verify they are processed as integers, not as errors | |||||
| assert true_segment.value == 1, "Test case 1 (boolean_true): Expected True to be processed as integer 1" | |||||
| assert false_segment.value == 0, "Test case 2 (boolean_false): Expected False to be processed as integer 0" | |||||
| assert true_segment.value_type == SegmentType.INTEGER | |||||
| assert false_segment.value_type == SegmentType.INTEGER | |||||
| # Verify they are processed as booleans, not integers | |||||
| assert true_segment.value is True, "Test case 1 (boolean_true): Expected True to be processed as boolean True" | |||||
| assert false_segment.value is False, ( | |||||
| "Test case 2 (boolean_false): Expected False to be processed as boolean False" | |||||
| ) | |||||
| assert true_segment.value_type == SegmentType.BOOLEAN | |||||
| assert false_segment.value_type == SegmentType.BOOLEAN | |||||
| # Test array of booleans | |||||
| bool_array_segment = variable_factory.build_segment([True, False, True]) | |||||
| assert bool_array_segment.value_type == SegmentType.ARRAY_BOOLEAN | |||||
| assert bool_array_segment.value == [True, False, True] |
| #!/usr/bin/env python3 | |||||
| """ | |||||
| Simple test to verify boolean classes can be imported correctly. | |||||
| """ | |||||
| import sys | |||||
| import os | |||||
| # Add the api directory to the Python path | |||||
| sys.path.insert(0, os.path.join(os.path.dirname(__file__), "api")) | |||||
| try: | |||||
| # Test that we can import the boolean classes | |||||
| from core.variables.segments import BooleanSegment, ArrayBooleanSegment | |||||
| from core.variables.variables import BooleanVariable, ArrayBooleanVariable | |||||
| from core.variables.types import SegmentType | |||||
| print("✅ Successfully imported BooleanSegment") | |||||
| print("✅ Successfully imported ArrayBooleanSegment") | |||||
| print("✅ Successfully imported BooleanVariable") | |||||
| print("✅ Successfully imported ArrayBooleanVariable") | |||||
| print("✅ Successfully imported SegmentType") | |||||
| # Test that the segment types exist | |||||
| print(f"✅ SegmentType.BOOLEAN = {SegmentType.BOOLEAN}") | |||||
| print(f"✅ SegmentType.ARRAY_BOOLEAN = {SegmentType.ARRAY_BOOLEAN}") | |||||
| # Test creating boolean segments directly | |||||
| bool_seg = BooleanSegment(value=True) | |||||
| print(f"✅ Created BooleanSegment: {bool_seg}") | |||||
| print(f" Value type: {bool_seg.value_type}") | |||||
| print(f" Value: {bool_seg.value}") | |||||
| array_bool_seg = ArrayBooleanSegment(value=[True, False, True]) | |||||
| print(f"✅ Created ArrayBooleanSegment: {array_bool_seg}") | |||||
| print(f" Value type: {array_bool_seg.value_type}") | |||||
| print(f" Value: {array_bool_seg.value}") | |||||
| print("\n🎉 All boolean class imports and basic functionality work correctly!") | |||||
| except ImportError as e: | |||||
| print(f"❌ Import error: {e}") | |||||
| except Exception as e: | |||||
| print(f"❌ Error: {e}") | |||||
| import traceback | |||||
| traceback.print_exc() |
| #!/usr/bin/env python3 | |||||
| """ | |||||
| Simple test script to verify boolean condition support in IfElseNode | |||||
| """ | |||||
| import sys | |||||
| import os | |||||
| # Add the api directory to the Python path | |||||
| sys.path.insert(0, os.path.join(os.path.dirname(__file__), "api")) | |||||
| from core.workflow.utils.condition.processor import ( | |||||
| ConditionProcessor, | |||||
| _evaluate_condition, | |||||
| ) | |||||
| def test_boolean_conditions(): | |||||
| """Test boolean condition evaluation""" | |||||
| print("Testing boolean condition support...") | |||||
| # Test boolean "is" operator | |||||
| result = _evaluate_condition(value=True, operator="is", expected="true") | |||||
| assert result == True, f"Expected True, got {result}" | |||||
| print("✓ Boolean 'is' with True value passed") | |||||
| result = _evaluate_condition(value=False, operator="is", expected="false") | |||||
| assert result == True, f"Expected True, got {result}" | |||||
| print("✓ Boolean 'is' with False value passed") | |||||
| # Test boolean "is not" operator | |||||
| result = _evaluate_condition(value=True, operator="is not", expected="false") | |||||
| assert result == True, f"Expected True, got {result}" | |||||
| print("✓ Boolean 'is not' with True value passed") | |||||
| result = _evaluate_condition(value=False, operator="is not", expected="true") | |||||
| assert result == True, f"Expected True, got {result}" | |||||
| print("✓ Boolean 'is not' with False value passed") | |||||
| # Test boolean "=" operator | |||||
| result = _evaluate_condition(value=True, operator="=", expected="1") | |||||
| assert result == True, f"Expected True, got {result}" | |||||
| print("✓ Boolean '=' with True=1 passed") | |||||
| result = _evaluate_condition(value=False, operator="=", expected="0") | |||||
| assert result == True, f"Expected True, got {result}" | |||||
| print("✓ Boolean '=' with False=0 passed") | |||||
| # Test boolean "≠" operator | |||||
| result = _evaluate_condition(value=True, operator="≠", expected="0") | |||||
| assert result == True, f"Expected True, got {result}" | |||||
| print("✓ Boolean '≠' with True≠0 passed") | |||||
| result = _evaluate_condition(value=False, operator="≠", expected="1") | |||||
| assert result == True, f"Expected True, got {result}" | |||||
| print("✓ Boolean '≠' with False≠1 passed") | |||||
| # Test boolean "in" operator | |||||
| result = _evaluate_condition(value=True, operator="in", expected=["true", "false"]) | |||||
| assert result == True, f"Expected True, got {result}" | |||||
| print("✓ Boolean 'in' with True in array passed") | |||||
| result = _evaluate_condition(value=False, operator="in", expected=["true", "false"]) | |||||
| assert result == True, f"Expected True, got {result}" | |||||
| print("✓ Boolean 'in' with False in array passed") | |||||
| # Test boolean "not in" operator | |||||
| result = _evaluate_condition(value=True, operator="not in", expected=["false", "0"]) | |||||
| assert result == True, f"Expected True, got {result}" | |||||
| print("✓ Boolean 'not in' with True not in [false, 0] passed") | |||||
| # Test boolean "null" and "not null" operators | |||||
| result = _evaluate_condition(value=True, operator="not null", expected=None) | |||||
| assert result == True, f"Expected True, got {result}" | |||||
| print("✓ Boolean 'not null' with True passed") | |||||
| result = _evaluate_condition(value=False, operator="not null", expected=None) | |||||
| assert result == True, f"Expected True, got {result}" | |||||
| print("✓ Boolean 'not null' with False passed") | |||||
| print("\n🎉 All boolean condition tests passed!") | |||||
| def test_backward_compatibility(): | |||||
| """Test that existing string and number conditions still work""" | |||||
| print("\nTesting backward compatibility...") | |||||
| # Test string conditions | |||||
| result = _evaluate_condition(value="hello", operator="is", expected="hello") | |||||
| assert result == True, f"Expected True, got {result}" | |||||
| print("✓ String 'is' condition still works") | |||||
| result = _evaluate_condition(value="hello", operator="contains", expected="ell") | |||||
| assert result == True, f"Expected True, got {result}" | |||||
| print("✓ String 'contains' condition still works") | |||||
| # Test number conditions | |||||
| result = _evaluate_condition(value=42, operator="=", expected="42") | |||||
| assert result == True, f"Expected True, got {result}" | |||||
| print("✓ Number '=' condition still works") | |||||
| result = _evaluate_condition(value=42, operator=">", expected="40") | |||||
| assert result == True, f"Expected True, got {result}" | |||||
| print("✓ Number '>' condition still works") | |||||
| print("✓ Backward compatibility maintained!") | |||||
| if __name__ == "__main__": | |||||
| try: | |||||
| test_boolean_conditions() | |||||
| test_backward_compatibility() | |||||
| print( | |||||
| "\n✅ All tests passed! Boolean support has been successfully added to IfElseNode." | |||||
| ) | |||||
| except Exception as e: | |||||
| print(f"\n❌ Test failed: {e}") | |||||
| sys.exit(1) |
| #!/usr/bin/env python3 | |||||
| """ | |||||
| Test script to verify the boolean array comparison fix in condition processor. | |||||
| """ | |||||
| import sys | |||||
| import os | |||||
| # Add the api directory to the Python path | |||||
| sys.path.insert(0, os.path.join(os.path.dirname(__file__), "api")) | |||||
| from core.workflow.utils.condition.processor import ( | |||||
| _assert_contains, | |||||
| _assert_not_contains, | |||||
| ) | |||||
| def test_boolean_array_contains(): | |||||
| """Test that boolean arrays work correctly with string comparisons.""" | |||||
| # Test case 1: Boolean array [True, False, True] contains "true" | |||||
| bool_array = [True, False, True] | |||||
| # Should return True because "true" converts to True and True is in the array | |||||
| result1 = _assert_contains(value=bool_array, expected="true") | |||||
| print(f"Test 1 - [True, False, True] contains 'true': {result1}") | |||||
| assert result1 == True, "Expected True but got False" | |||||
| # Should return True because "false" converts to False and False is in the array | |||||
| result2 = _assert_contains(value=bool_array, expected="false") | |||||
| print(f"Test 2 - [True, False, True] contains 'false': {result2}") | |||||
| assert result2 == True, "Expected True but got False" | |||||
| # Test case 2: Boolean array [True, True] does not contain "false" | |||||
| bool_array2 = [True, True] | |||||
| result3 = _assert_contains(value=bool_array2, expected="false") | |||||
| print(f"Test 3 - [True, True] contains 'false': {result3}") | |||||
| assert result3 == False, "Expected False but got True" | |||||
| # Test case 3: Test not_contains | |||||
| result4 = _assert_not_contains(value=bool_array2, expected="false") | |||||
| print(f"Test 4 - [True, True] not contains 'false': {result4}") | |||||
| assert result4 == True, "Expected True but got False" | |||||
| result5 = _assert_not_contains(value=bool_array, expected="true") | |||||
| print(f"Test 5 - [True, False, True] not contains 'true': {result5}") | |||||
| assert result5 == False, "Expected False but got True" | |||||
| # Test case 4: Test with different string representations | |||||
| result6 = _assert_contains( | |||||
| value=bool_array, expected="1" | |||||
| ) # "1" should convert to True | |||||
| print(f"Test 6 - [True, False, True] contains '1': {result6}") | |||||
| assert result6 == True, "Expected True but got False" | |||||
| result7 = _assert_contains( | |||||
| value=bool_array, expected="0" | |||||
| ) # "0" should convert to False | |||||
| print(f"Test 7 - [True, False, True] contains '0': {result7}") | |||||
| assert result7 == True, "Expected True but got False" | |||||
| print("\n✅ All boolean array comparison tests passed!") | |||||
| if __name__ == "__main__": | |||||
| test_boolean_array_contains() |
| #!/usr/bin/env python3 | |||||
| """ | |||||
| Simple test script to verify boolean type inference in variable factory. | |||||
| """ | |||||
| import sys | |||||
| import os | |||||
| # Add the api directory to the Python path | |||||
| sys.path.insert(0, os.path.join(os.path.dirname(__file__), "api")) | |||||
| try: | |||||
| from factories.variable_factory import build_segment, segment_to_variable | |||||
| from core.variables.segments import BooleanSegment, ArrayBooleanSegment | |||||
| from core.variables.variables import BooleanVariable, ArrayBooleanVariable | |||||
| from core.variables.types import SegmentType | |||||
| def test_boolean_inference(): | |||||
| print("Testing boolean type inference...") | |||||
| # Test single boolean values | |||||
| true_segment = build_segment(True) | |||||
| false_segment = build_segment(False) | |||||
| print(f"True value: {true_segment}") | |||||
| print(f"Type: {type(true_segment)}") | |||||
| print(f"Value type: {true_segment.value_type}") | |||||
| print(f"Is BooleanSegment: {isinstance(true_segment, BooleanSegment)}") | |||||
| print(f"\nFalse value: {false_segment}") | |||||
| print(f"Type: {type(false_segment)}") | |||||
| print(f"Value type: {false_segment.value_type}") | |||||
| print(f"Is BooleanSegment: {isinstance(false_segment, BooleanSegment)}") | |||||
| # Test array of booleans | |||||
| bool_array_segment = build_segment([True, False, True]) | |||||
| print(f"\nBoolean array: {bool_array_segment}") | |||||
| print(f"Type: {type(bool_array_segment)}") | |||||
| print(f"Value type: {bool_array_segment.value_type}") | |||||
| print( | |||||
| f"Is ArrayBooleanSegment: {isinstance(bool_array_segment, ArrayBooleanSegment)}" | |||||
| ) | |||||
| # Test empty boolean array | |||||
| empty_bool_array = build_segment([]) | |||||
| print(f"\nEmpty array: {empty_bool_array}") | |||||
| print(f"Type: {type(empty_bool_array)}") | |||||
| print(f"Value type: {empty_bool_array.value_type}") | |||||
| # Test segment to variable conversion | |||||
| bool_var = segment_to_variable( | |||||
| segment=true_segment, selector=["test", "bool_var"], name="test_boolean" | |||||
| ) | |||||
| print(f"\nBoolean variable: {bool_var}") | |||||
| print(f"Type: {type(bool_var)}") | |||||
| print(f"Is BooleanVariable: {isinstance(bool_var, BooleanVariable)}") | |||||
| array_bool_var = segment_to_variable( | |||||
| segment=bool_array_segment, | |||||
| selector=["test", "array_bool_var"], | |||||
| name="test_array_boolean", | |||||
| ) | |||||
| print(f"\nArray boolean variable: {array_bool_var}") | |||||
| print(f"Type: {type(array_bool_var)}") | |||||
| print( | |||||
| f"Is ArrayBooleanVariable: {isinstance(array_bool_var, ArrayBooleanVariable)}" | |||||
| ) | |||||
| # Test that bool comes before int (critical ordering) | |||||
| print(f"\nTesting bool vs int precedence:") | |||||
| print(f"True is instance of bool: {isinstance(True, bool)}") | |||||
| print(f"True is instance of int: {isinstance(True, int)}") | |||||
| print(f"False is instance of bool: {isinstance(False, bool)}") | |||||
| print(f"False is instance of int: {isinstance(False, int)}") | |||||
| # Verify that boolean values are correctly inferred as boolean, not int | |||||
| assert true_segment.value_type == SegmentType.BOOLEAN, ( | |||||
| "True should be inferred as BOOLEAN" | |||||
| ) | |||||
| assert false_segment.value_type == SegmentType.BOOLEAN, ( | |||||
| "False should be inferred as BOOLEAN" | |||||
| ) | |||||
| assert bool_array_segment.value_type == SegmentType.ARRAY_BOOLEAN, ( | |||||
| "Boolean array should be inferred as ARRAY_BOOLEAN" | |||||
| ) | |||||
| print("\n✅ All boolean inference tests passed!") | |||||
| if __name__ == "__main__": | |||||
| test_boolean_inference() | |||||
| except ImportError as e: | |||||
| print(f"Import error: {e}") | |||||
| print("Make sure you're running this from the correct directory") | |||||
| except Exception as e: | |||||
| print(f"Error: {e}") | |||||
| import traceback | |||||
| traceback.print_exc() |
| #!/usr/bin/env python3 | |||||
| """ | |||||
| Test script to verify boolean support in VariableAssigner node | |||||
| """ | |||||
| import sys | |||||
| import os | |||||
| # Add the api directory to the Python path | |||||
| sys.path.insert(0, os.path.join(os.path.dirname(__file__), "api")) | |||||
| from core.variables import SegmentType | |||||
| from core.workflow.nodes.variable_assigner.v2.helpers import ( | |||||
| is_operation_supported, | |||||
| is_constant_input_supported, | |||||
| is_input_value_valid, | |||||
| ) | |||||
| from core.workflow.nodes.variable_assigner.v2.enums import Operation | |||||
| from core.workflow.nodes.variable_assigner.v2.constants import EMPTY_VALUE_MAPPING | |||||
| def test_boolean_operation_support(): | |||||
| """Test that boolean types support the correct operations""" | |||||
| print("Testing boolean operation support...") | |||||
| # Boolean should support SET, OVER_WRITE, and CLEAR | |||||
| assert is_operation_supported( | |||||
| variable_type=SegmentType.BOOLEAN, operation=Operation.SET | |||||
| ) | |||||
| assert is_operation_supported( | |||||
| variable_type=SegmentType.BOOLEAN, operation=Operation.OVER_WRITE | |||||
| ) | |||||
| assert is_operation_supported( | |||||
| variable_type=SegmentType.BOOLEAN, operation=Operation.CLEAR | |||||
| ) | |||||
| # Boolean should NOT support arithmetic operations | |||||
| assert not is_operation_supported( | |||||
| variable_type=SegmentType.BOOLEAN, operation=Operation.ADD | |||||
| ) | |||||
| assert not is_operation_supported( | |||||
| variable_type=SegmentType.BOOLEAN, operation=Operation.SUBTRACT | |||||
| ) | |||||
| assert not is_operation_supported( | |||||
| variable_type=SegmentType.BOOLEAN, operation=Operation.MULTIPLY | |||||
| ) | |||||
| assert not is_operation_supported( | |||||
| variable_type=SegmentType.BOOLEAN, operation=Operation.DIVIDE | |||||
| ) | |||||
| # Boolean should NOT support array operations | |||||
| assert not is_operation_supported( | |||||
| variable_type=SegmentType.BOOLEAN, operation=Operation.APPEND | |||||
| ) | |||||
| assert not is_operation_supported( | |||||
| variable_type=SegmentType.BOOLEAN, operation=Operation.EXTEND | |||||
| ) | |||||
| print("✓ Boolean operation support tests passed") | |||||
| def test_array_boolean_operation_support(): | |||||
| """Test that array boolean types support the correct operations""" | |||||
| print("Testing array boolean operation support...") | |||||
| # Array boolean should support APPEND, EXTEND, SET, OVER_WRITE, CLEAR | |||||
| assert is_operation_supported( | |||||
| variable_type=SegmentType.ARRAY_BOOLEAN, operation=Operation.APPEND | |||||
| ) | |||||
| assert is_operation_supported( | |||||
| variable_type=SegmentType.ARRAY_BOOLEAN, operation=Operation.EXTEND | |||||
| ) | |||||
| assert is_operation_supported( | |||||
| variable_type=SegmentType.ARRAY_BOOLEAN, operation=Operation.OVER_WRITE | |||||
| ) | |||||
| assert is_operation_supported( | |||||
| variable_type=SegmentType.ARRAY_BOOLEAN, operation=Operation.CLEAR | |||||
| ) | |||||
| assert is_operation_supported( | |||||
| variable_type=SegmentType.ARRAY_BOOLEAN, operation=Operation.REMOVE_FIRST | |||||
| ) | |||||
| assert is_operation_supported( | |||||
| variable_type=SegmentType.ARRAY_BOOLEAN, operation=Operation.REMOVE_LAST | |||||
| ) | |||||
| # Array boolean should NOT support arithmetic operations | |||||
| assert not is_operation_supported( | |||||
| variable_type=SegmentType.ARRAY_BOOLEAN, operation=Operation.ADD | |||||
| ) | |||||
| assert not is_operation_supported( | |||||
| variable_type=SegmentType.ARRAY_BOOLEAN, operation=Operation.SUBTRACT | |||||
| ) | |||||
| assert not is_operation_supported( | |||||
| variable_type=SegmentType.ARRAY_BOOLEAN, operation=Operation.MULTIPLY | |||||
| ) | |||||
| assert not is_operation_supported( | |||||
| variable_type=SegmentType.ARRAY_BOOLEAN, operation=Operation.DIVIDE | |||||
| ) | |||||
| print("✓ Array boolean operation support tests passed") | |||||
| def test_boolean_constant_input_support(): | |||||
| """Test that boolean types support constant input for correct operations""" | |||||
| print("Testing boolean constant input support...") | |||||
| # Boolean should support constant input for SET and OVER_WRITE | |||||
| assert is_constant_input_supported( | |||||
| variable_type=SegmentType.BOOLEAN, operation=Operation.SET | |||||
| ) | |||||
| assert is_constant_input_supported( | |||||
| variable_type=SegmentType.BOOLEAN, operation=Operation.OVER_WRITE | |||||
| ) | |||||
| # Boolean should NOT support constant input for arithmetic operations | |||||
| assert not is_constant_input_supported( | |||||
| variable_type=SegmentType.BOOLEAN, operation=Operation.ADD | |||||
| ) | |||||
| print("✓ Boolean constant input support tests passed") | |||||
| def test_boolean_input_validation(): | |||||
| """Test that boolean input validation works correctly""" | |||||
| print("Testing boolean input validation...") | |||||
| # Boolean values should be valid for boolean type | |||||
| assert is_input_value_valid( | |||||
| variable_type=SegmentType.BOOLEAN, operation=Operation.SET, value=True | |||||
| ) | |||||
| assert is_input_value_valid( | |||||
| variable_type=SegmentType.BOOLEAN, operation=Operation.SET, value=False | |||||
| ) | |||||
| assert is_input_value_valid( | |||||
| variable_type=SegmentType.BOOLEAN, operation=Operation.OVER_WRITE, value=True | |||||
| ) | |||||
| # Non-boolean values should be invalid for boolean type | |||||
| assert not is_input_value_valid( | |||||
| variable_type=SegmentType.BOOLEAN, operation=Operation.SET, value="true" | |||||
| ) | |||||
| assert not is_input_value_valid( | |||||
| variable_type=SegmentType.BOOLEAN, operation=Operation.SET, value=1 | |||||
| ) | |||||
| assert not is_input_value_valid( | |||||
| variable_type=SegmentType.BOOLEAN, operation=Operation.SET, value=0 | |||||
| ) | |||||
| print("✓ Boolean input validation tests passed") | |||||
| def test_array_boolean_input_validation(): | |||||
| """Test that array boolean input validation works correctly""" | |||||
| print("Testing array boolean input validation...") | |||||
| # Boolean values should be valid for array boolean append | |||||
| assert is_input_value_valid( | |||||
| variable_type=SegmentType.ARRAY_BOOLEAN, operation=Operation.APPEND, value=True | |||||
| ) | |||||
| assert is_input_value_valid( | |||||
| variable_type=SegmentType.ARRAY_BOOLEAN, operation=Operation.APPEND, value=False | |||||
| ) | |||||
| # Boolean arrays should be valid for extend/overwrite | |||||
| assert is_input_value_valid( | |||||
| variable_type=SegmentType.ARRAY_BOOLEAN, | |||||
| operation=Operation.EXTEND, | |||||
| value=[True, False, True], | |||||
| ) | |||||
| assert is_input_value_valid( | |||||
| variable_type=SegmentType.ARRAY_BOOLEAN, | |||||
| operation=Operation.OVER_WRITE, | |||||
| value=[False, False], | |||||
| ) | |||||
| # Non-boolean values should be invalid | |||||
| assert not is_input_value_valid( | |||||
| variable_type=SegmentType.ARRAY_BOOLEAN, | |||||
| operation=Operation.APPEND, | |||||
| value="true", | |||||
| ) | |||||
| assert not is_input_value_valid( | |||||
| variable_type=SegmentType.ARRAY_BOOLEAN, | |||||
| operation=Operation.EXTEND, | |||||
| value=[True, "false"], | |||||
| ) | |||||
| print("✓ Array boolean input validation tests passed") | |||||
| def test_empty_value_mapping(): | |||||
| """Test that empty value mapping includes boolean types""" | |||||
| print("Testing empty value mapping...") | |||||
| # Check that boolean types have correct empty values | |||||
| assert SegmentType.BOOLEAN in EMPTY_VALUE_MAPPING | |||||
| assert EMPTY_VALUE_MAPPING[SegmentType.BOOLEAN] is False | |||||
| assert SegmentType.ARRAY_BOOLEAN in EMPTY_VALUE_MAPPING | |||||
| assert EMPTY_VALUE_MAPPING[SegmentType.ARRAY_BOOLEAN] == [] | |||||
| print("✓ Empty value mapping tests passed") | |||||
| def main(): | |||||
| """Run all tests""" | |||||
| print("Running VariableAssigner boolean support tests...\n") | |||||
| try: | |||||
| test_boolean_operation_support() | |||||
| test_array_boolean_operation_support() | |||||
| test_boolean_constant_input_support() | |||||
| test_boolean_input_validation() | |||||
| test_array_boolean_input_validation() | |||||
| test_empty_value_mapping() | |||||
| print( | |||||
| "\n🎉 All tests passed! Boolean support has been successfully added to VariableAssigner." | |||||
| ) | |||||
| except Exception as e: | |||||
| print(f"\n❌ Test failed: {e}") | |||||
| import traceback | |||||
| traceback.print_exc() | |||||
| sys.exit(1) | |||||
| if __name__ == "__main__": | |||||
| main() |
| export const jsonObjectWrap = { | |||||
| type: 'object', | |||||
| properties: {}, | |||||
| required: [], | |||||
| additionalProperties: true, | |||||
| } | |||||
| export const jsonConfigPlaceHolder = JSON.stringify( | |||||
| { | |||||
| foo: { | |||||
| type: 'string', | |||||
| }, | |||||
| bar: { | |||||
| type: 'object', | |||||
| properties: { | |||||
| sub: { | |||||
| type: 'number', | |||||
| }, | |||||
| }, | |||||
| required: [], | |||||
| additionalProperties: true, | |||||
| }, | |||||
| }, null, 2, | |||||
| ) |
| import type { FC } from 'react' | import type { FC } from 'react' | ||||
| import React from 'react' | import React from 'react' | ||||
| import cn from '@/utils/classnames' | import cn from '@/utils/classnames' | ||||
| import { useTranslation } from 'react-i18next' | |||||
| type Props = { | type Props = { | ||||
| className?: string | className?: string | ||||
| title: string | title: string | ||||
| isOptional?: boolean | |||||
| children: React.JSX.Element | children: React.JSX.Element | ||||
| } | } | ||||
| const Field: FC<Props> = ({ | const Field: FC<Props> = ({ | ||||
| className, | className, | ||||
| title, | title, | ||||
| isOptional, | |||||
| children, | children, | ||||
| }) => { | }) => { | ||||
| const { t } = useTranslation() | |||||
| return ( | return ( | ||||
| <div className={cn(className)}> | <div className={cn(className)}> | ||||
| <div className='system-sm-semibold leading-8 text-text-secondary'>{title}</div> | |||||
| <div className='system-sm-semibold leading-8 text-text-secondary'> | |||||
| {title} | |||||
| {isOptional && <span className='system-xs-regular ml-1 text-text-tertiary'>({t('appDebug.variableConfig.optional')})</span>} | |||||
| </div> | |||||
| <div>{children}</div> | <div>{children}</div> | ||||
| </div> | </div> | ||||
| ) | ) |
| 'use client' | 'use client' | ||||
| import type { ChangeEvent, FC } from 'react' | import type { ChangeEvent, FC } from 'react' | ||||
| import React, { useCallback, useEffect, useRef, useState } from 'react' | |||||
| import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' | |||||
| import { useTranslation } from 'react-i18next' | import { useTranslation } from 'react-i18next' | ||||
| import { useContext } from 'use-context-selector' | import { useContext } from 'use-context-selector' | ||||
| import produce from 'immer' | import produce from 'immer' | ||||
| import ModalFoot from '../modal-foot' | import ModalFoot from '../modal-foot' | ||||
| import ConfigSelect from '../config-select' | import ConfigSelect from '../config-select' | ||||
| import ConfigString from '../config-string' | import ConfigString from '../config-string' | ||||
| import SelectTypeItem from '../select-type-item' | |||||
| import Field from './field' | import Field from './field' | ||||
| import Input from '@/app/components/base/input' | import Input from '@/app/components/base/input' | ||||
| import Toast from '@/app/components/base/toast' | import Toast from '@/app/components/base/toast' | ||||
| import Checkbox from '@/app/components/base/checkbox' | import Checkbox from '@/app/components/base/checkbox' | ||||
| import { DEFAULT_FILE_UPLOAD_SETTING } from '@/app/components/workflow/constants' | import { DEFAULT_FILE_UPLOAD_SETTING } from '@/app/components/workflow/constants' | ||||
| import { DEFAULT_VALUE_MAX_LEN } from '@/config' | import { DEFAULT_VALUE_MAX_LEN } from '@/config' | ||||
| import type { Item as SelectItem } from './type-select' | |||||
| import TypeSelector from './type-select' | |||||
| import { SimpleSelect } from '@/app/components/base/select' | import { SimpleSelect } from '@/app/components/base/select' | ||||
| import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor' | |||||
| import { CodeLanguage } from '@/app/components/workflow/nodes/code/types' | |||||
| import { jsonConfigPlaceHolder, jsonObjectWrap } from './config' | |||||
| import { useStore as useAppStore } from '@/app/components/app/store' | |||||
| import Textarea from '@/app/components/base/textarea' | import Textarea from '@/app/components/base/textarea' | ||||
| import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader' | import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader' | ||||
| import { TransferMethod } from '@/types/app' | import { TransferMethod } from '@/types/app' | ||||
| const [tempPayload, setTempPayload] = useState<InputVar>(payload || getNewVarInWorkflow('') as any) | const [tempPayload, setTempPayload] = useState<InputVar>(payload || getNewVarInWorkflow('') as any) | ||||
| const { type, label, variable, options, max_length } = tempPayload | const { type, label, variable, options, max_length } = tempPayload | ||||
| const modalRef = useRef<HTMLDivElement>(null) | const modalRef = useRef<HTMLDivElement>(null) | ||||
| const appDetail = useAppStore(state => state.appDetail) | |||||
| const isBasicApp = appDetail?.mode !== 'advanced-chat' && appDetail?.mode !== 'workflow' | |||||
| const isSupportJSON = false | |||||
| const jsonSchemaStr = useMemo(() => { | |||||
| const isJsonObject = type === InputVarType.jsonObject | |||||
| if (!isJsonObject || !tempPayload.json_schema) | |||||
| return '' | |||||
| try { | |||||
| return JSON.stringify(JSON.parse(tempPayload.json_schema).properties, null, 2) | |||||
| } | |||||
| catch (_e) { | |||||
| return '' | |||||
| } | |||||
| }, [tempPayload.json_schema]) | |||||
| useEffect(() => { | useEffect(() => { | ||||
| // To fix the first input element auto focus, then directly close modal will raise error | // To fix the first input element auto focus, then directly close modal will raise error | ||||
| if (isShow) | if (isShow) | ||||
| } | } | ||||
| }, []) | }, []) | ||||
| const handleTypeChange = useCallback((type: InputVarType) => { | |||||
| return () => { | |||||
| const newPayload = produce(tempPayload, (draft) => { | |||||
| draft.type = type | |||||
| // Clear default value when switching types | |||||
| draft.default = undefined | |||||
| if ([InputVarType.singleFile, InputVarType.multiFiles].includes(type)) { | |||||
| (Object.keys(DEFAULT_FILE_UPLOAD_SETTING)).forEach((key) => { | |||||
| if (key !== 'max_length') | |||||
| (draft as any)[key] = (DEFAULT_FILE_UPLOAD_SETTING as any)[key] | |||||
| }) | |||||
| if (type === InputVarType.multiFiles) | |||||
| draft.max_length = DEFAULT_FILE_UPLOAD_SETTING.max_length | |||||
| } | |||||
| if (type === InputVarType.paragraph) | |||||
| draft.max_length = DEFAULT_VALUE_MAX_LEN | |||||
| }) | |||||
| setTempPayload(newPayload) | |||||
| const handleJSONSchemaChange = useCallback((value: string) => { | |||||
| try { | |||||
| const v = JSON.parse(value) | |||||
| const res = { | |||||
| ...jsonObjectWrap, | |||||
| properties: v, | |||||
| } | |||||
| handlePayloadChange('json_schema')(JSON.stringify(res, null, 2)) | |||||
| } | } | ||||
| catch (_e) { | |||||
| return null | |||||
| } | |||||
| }, [handlePayloadChange]) | |||||
| const selectOptions: SelectItem[] = [ | |||||
| { | |||||
| name: t('appDebug.variableConfig.text-input'), | |||||
| value: InputVarType.textInput, | |||||
| }, | |||||
| { | |||||
| name: t('appDebug.variableConfig.paragraph'), | |||||
| value: InputVarType.paragraph, | |||||
| }, | |||||
| { | |||||
| name: t('appDebug.variableConfig.select'), | |||||
| value: InputVarType.select, | |||||
| }, | |||||
| { | |||||
| name: t('appDebug.variableConfig.number'), | |||||
| value: InputVarType.number, | |||||
| }, | |||||
| { | |||||
| name: t('appDebug.variableConfig.checkbox'), | |||||
| value: InputVarType.checkbox, | |||||
| }, | |||||
| ...(supportFile ? [ | |||||
| { | |||||
| name: t('appDebug.variableConfig.single-file'), | |||||
| value: InputVarType.singleFile, | |||||
| }, | |||||
| { | |||||
| name: t('appDebug.variableConfig.multi-files'), | |||||
| value: InputVarType.multiFiles, | |||||
| }, | |||||
| ] : []), | |||||
| ...((!isBasicApp && isSupportJSON) ? [{ | |||||
| name: t('appDebug.variableConfig.json'), | |||||
| value: InputVarType.jsonObject, | |||||
| }] : []), | |||||
| ] | |||||
| const handleTypeChange = useCallback((item: SelectItem) => { | |||||
| const type = item.value as InputVarType | |||||
| const newPayload = produce(tempPayload, (draft) => { | |||||
| draft.type = type | |||||
| if ([InputVarType.singleFile, InputVarType.multiFiles].includes(type)) { | |||||
| (Object.keys(DEFAULT_FILE_UPLOAD_SETTING)).forEach((key) => { | |||||
| if (key !== 'max_length') | |||||
| (draft as any)[key] = (DEFAULT_FILE_UPLOAD_SETTING as any)[key] | |||||
| }) | |||||
| if (type === InputVarType.multiFiles) | |||||
| draft.max_length = DEFAULT_FILE_UPLOAD_SETTING.max_length | |||||
| } | |||||
| if (type === InputVarType.paragraph) | |||||
| draft.max_length = DEFAULT_VALUE_MAX_LEN | |||||
| }) | |||||
| setTempPayload(newPayload) | |||||
| }, [tempPayload]) | }, [tempPayload]) | ||||
| const handleVarKeyBlur = useCallback((e: any) => { | const handleVarKeyBlur = useCallback((e: any) => { | ||||
| if (!isVariableNameValid) | if (!isVariableNameValid) | ||||
| return | return | ||||
| // TODO: check if key already exists. should the consider the edit case | |||||
| // if (varKeys.map(key => key?.trim()).includes(tempPayload.variable.trim())) { | |||||
| // Toast.notify({ | |||||
| // type: 'error', | |||||
| // message: t('appDebug.varKeyError.keyAlreadyExists', { key: tempPayload.variable }), | |||||
| // }) | |||||
| // return | |||||
| // } | |||||
| if (!tempPayload.label) { | if (!tempPayload.label) { | ||||
| Toast.notify({ type: 'error', message: t('appDebug.variableConfig.errorMsg.labelNameRequired') }) | Toast.notify({ type: 'error', message: t('appDebug.variableConfig.errorMsg.labelNameRequired') }) | ||||
| return | return | ||||
| > | > | ||||
| <div className='mb-8' ref={modalRef} tabIndex={-1}> | <div className='mb-8' ref={modalRef} tabIndex={-1}> | ||||
| <div className='space-y-2'> | <div className='space-y-2'> | ||||
| <Field title={t('appDebug.variableConfig.fieldType')}> | <Field title={t('appDebug.variableConfig.fieldType')}> | ||||
| <div className='grid grid-cols-3 gap-2'> | |||||
| <SelectTypeItem type={InputVarType.textInput} selected={type === InputVarType.textInput} onClick={handleTypeChange(InputVarType.textInput)} /> | |||||
| <SelectTypeItem type={InputVarType.paragraph} selected={type === InputVarType.paragraph} onClick={handleTypeChange(InputVarType.paragraph)} /> | |||||
| <SelectTypeItem type={InputVarType.select} selected={type === InputVarType.select} onClick={handleTypeChange(InputVarType.select)} /> | |||||
| <SelectTypeItem type={InputVarType.number} selected={type === InputVarType.number} onClick={handleTypeChange(InputVarType.number)} /> | |||||
| {supportFile && <> | |||||
| <SelectTypeItem type={InputVarType.singleFile} selected={type === InputVarType.singleFile} onClick={handleTypeChange(InputVarType.singleFile)} /> | |||||
| <SelectTypeItem type={InputVarType.multiFiles} selected={type === InputVarType.multiFiles} onClick={handleTypeChange(InputVarType.multiFiles)} /> | |||||
| </>} | |||||
| </div> | |||||
| <TypeSelector value={type} items={selectOptions} onSelect={handleTypeChange} /> | |||||
| </Field> | </Field> | ||||
| <Field title={t('appDebug.variableConfig.varName')}> | <Field title={t('appDebug.variableConfig.varName')}> | ||||
| </> | </> | ||||
| )} | )} | ||||
| {type === InputVarType.jsonObject && ( | |||||
| <Field title={t('appDebug.variableConfig.jsonSchema')} isOptional> | |||||
| <CodeEditor | |||||
| language={CodeLanguage.json} | |||||
| value={jsonSchemaStr} | |||||
| onChange={handleJSONSchemaChange} | |||||
| noWrapper | |||||
| className='bg h-[80px] overflow-y-auto rounded-[10px] bg-components-input-bg-normal p-1' | |||||
| placeholder={ | |||||
| <div className='whitespace-pre'>{jsonConfigPlaceHolder}</div> | |||||
| } | |||||
| /> | |||||
| </Field> | |||||
| )} | |||||
| <div className='!mt-5 flex h-6 items-center space-x-2'> | <div className='!mt-5 flex h-6 items-center space-x-2'> | ||||
| <Checkbox checked={tempPayload.required} disabled={tempPayload.hide} onCheck={() => handlePayloadChange('required')(!tempPayload.required)} /> | <Checkbox checked={tempPayload.required} disabled={tempPayload.hide} onCheck={() => handlePayloadChange('required')(!tempPayload.required)} /> | ||||
| <span className='system-sm-semibold text-text-secondary'>{t('appDebug.variableConfig.required')}</span> | <span className='system-sm-semibold text-text-secondary'>{t('appDebug.variableConfig.required')}</span> |
| 'use client' | |||||
| import type { FC } from 'react' | |||||
| import React, { useState } from 'react' | |||||
| import { ChevronDownIcon } from '@heroicons/react/20/solid' | |||||
| import classNames from '@/utils/classnames' | |||||
| import { | |||||
| PortalToFollowElem, | |||||
| PortalToFollowElemContent, | |||||
| PortalToFollowElemTrigger, | |||||
| } from '@/app/components/base/portal-to-follow-elem' | |||||
| import InputVarTypeIcon from '@/app/components/workflow/nodes/_base/components/input-var-type-icon' | |||||
| import type { InputVarType } from '@/app/components/workflow/types' | |||||
| import cn from '@/utils/classnames' | |||||
| import Badge from '@/app/components/base/badge' | |||||
| import { inputVarTypeToVarType } from '@/app/components/workflow/nodes/_base/components/variable/utils' | |||||
| export type Item = { | |||||
| value: InputVarType | |||||
| name: string | |||||
| } | |||||
| type Props = { | |||||
| value: string | number | |||||
| onSelect: (value: Item) => void | |||||
| items: Item[] | |||||
| popupClassName?: string | |||||
| popupInnerClassName?: string | |||||
| readonly?: boolean | |||||
| hideChecked?: boolean | |||||
| } | |||||
| const TypeSelector: FC<Props> = ({ | |||||
| value, | |||||
| onSelect, | |||||
| items, | |||||
| popupInnerClassName, | |||||
| readonly, | |||||
| }) => { | |||||
| const [open, setOpen] = useState(false) | |||||
| const selectedItem = value ? items.find(item => item.value === value) : undefined | |||||
| return ( | |||||
| <PortalToFollowElem | |||||
| open={open} | |||||
| onOpenChange={setOpen} | |||||
| placement='bottom-start' | |||||
| offset={4} | |||||
| > | |||||
| <PortalToFollowElemTrigger onClick={() => !readonly && setOpen(v => !v)} className='w-full'> | |||||
| <div | |||||
| className={classNames(`group flex h-9 items-center justify-between rounded-lg border-0 bg-components-input-bg-normal px-2 text-sm hover:bg-state-base-hover-alt ${readonly ? 'cursor-not-allowed' : 'cursor-pointer'}`)} | |||||
| title={selectedItem?.name} | |||||
| > | |||||
| <div className='flex items-center'> | |||||
| <InputVarTypeIcon type={selectedItem?.value as InputVarType} className='size-4 shrink-0 text-text-secondary' /> | |||||
| <span | |||||
| className={` | |||||
| ml-1.5 ${!selectedItem?.name && 'text-components-input-text-placeholder'} | |||||
| `} | |||||
| > | |||||
| {selectedItem?.name} | |||||
| </span> | |||||
| </div> | |||||
| <div className='flex items-center space-x-1'> | |||||
| <Badge uppercase={false}>{inputVarTypeToVarType(selectedItem?.value as InputVarType)}</Badge> | |||||
| <ChevronDownIcon className={cn('h-4 w-4 shrink-0 text-text-quaternary group-hover:text-text-secondary', open && 'text-text-secondary')} /> | |||||
| </div> | |||||
| </div> | |||||
| </PortalToFollowElemTrigger> | |||||
| <PortalToFollowElemContent className='z-[61]'> | |||||
| <div | |||||
| className={classNames('w-[432px] rounded-md border-[0.5px] border-components-panel-border bg-components-panel-bg px-1 py-1 text-base shadow-lg focus:outline-none sm:text-sm', popupInnerClassName)} | |||||
| > | |||||
| {items.map((item: Item) => ( | |||||
| <div | |||||
| key={item.value} | |||||
| className={'flex h-9 cursor-pointer items-center justify-between rounded-lg px-2 text-text-secondary hover:bg-state-base-hover'} | |||||
| title={item.name} | |||||
| onClick={() => { | |||||
| onSelect(item) | |||||
| setOpen(false) | |||||
| }} | |||||
| > | |||||
| <div className='flex items-center space-x-2'> | |||||
| <InputVarTypeIcon type={item.value} className='size-4 shrink-0 text-text-secondary' /> | |||||
| <span title={item.name}>{item.name}</span> | |||||
| </div> | |||||
| <Badge uppercase={false}>{inputVarTypeToVarType(item.value)}</Badge> | |||||
| </div> | |||||
| ))} | |||||
| </div> | |||||
| </PortalToFollowElemContent> | |||||
| </PortalToFollowElem> | |||||
| ) | |||||
| } | |||||
| export default TypeSelector |
| import Tooltip from '@/app/components/base/tooltip' | import Tooltip from '@/app/components/base/tooltip' | ||||
| import type { PromptVariable } from '@/models/debug' | import type { PromptVariable } from '@/models/debug' | ||||
| import { DEFAULT_VALUE_MAX_LEN } from '@/config' | import { DEFAULT_VALUE_MAX_LEN } from '@/config' | ||||
| import { getNewVar } from '@/utils/var' | |||||
| import { getNewVar, hasDuplicateStr } from '@/utils/var' | |||||
| import Toast from '@/app/components/base/toast' | import Toast from '@/app/components/base/toast' | ||||
| import Confirm from '@/app/components/base/confirm' | import Confirm from '@/app/components/base/confirm' | ||||
| import ConfigContext from '@/context/debug-configuration' | import ConfigContext from '@/context/debug-configuration' | ||||
| delete draft[currIndex].options | delete draft[currIndex].options | ||||
| }) | }) | ||||
| const newList = newPromptVariables | |||||
| let errorMsgKey = '' | |||||
| let typeName = '' | |||||
| if (hasDuplicateStr(newList.map(item => item.key))) { | |||||
| errorMsgKey = 'appDebug.varKeyError.keyAlreadyExists' | |||||
| typeName = 'appDebug.variableConfig.varName' | |||||
| } | |||||
| else if (hasDuplicateStr(newList.map(item => item.name as string))) { | |||||
| errorMsgKey = 'appDebug.varKeyError.keyAlreadyExists' | |||||
| typeName = 'appDebug.variableConfig.labelName' | |||||
| } | |||||
| if (errorMsgKey) { | |||||
| Toast.notify({ | |||||
| type: 'error', | |||||
| message: t(errorMsgKey, { key: t(typeName) }), | |||||
| }) | |||||
| return false | |||||
| } | |||||
| onPromptVariablesChange?.(newPromptVariables) | onPromptVariablesChange?.(newPromptVariables) | ||||
| return true | |||||
| } | } | ||||
| const { setShowExternalDataToolModal } = useModalContext() | const { setShowExternalDataToolModal } = useModalContext() | ||||
| const handleConfig = ({ key, type, index, name, config, icon, icon_background }: ExternalDataToolParams) => { | const handleConfig = ({ key, type, index, name, config, icon, icon_background }: ExternalDataToolParams) => { | ||||
| // setCurrKey(key) | // setCurrKey(key) | ||||
| setCurrIndex(index) | setCurrIndex(index) | ||||
| if (type !== 'string' && type !== 'paragraph' && type !== 'select' && type !== 'number') { | |||||
| if (type !== 'string' && type !== 'paragraph' && type !== 'select' && type !== 'number' && type !== 'checkbox') { | |||||
| handleOpenExternalDataToolModal({ key, type, index, name, config, icon, icon_background }, promptVariables) | handleOpenExternalDataToolModal({ key, type, index, name, config, icon, icon_background }, promptVariables) | ||||
| return | return | ||||
| } | } | ||||
| isShow={isShowEditModal} | isShow={isShowEditModal} | ||||
| onClose={hideEditModal} | onClose={hideEditModal} | ||||
| onConfirm={(item) => { | onConfirm={(item) => { | ||||
| updatePromptVariableItem(item) | |||||
| const isValid = updatePromptVariableItem(item) | |||||
| if (!isValid) return | |||||
| hideEditModal() | hideEditModal() | ||||
| }} | }} | ||||
| varKeys={promptVariables.map(v => v.key)} | varKeys={promptVariables.map(v => v.key)} |
| <SelectItem type={InputVarType.paragraph} value='paragraph' text={t('appDebug.variableConfig.paragraph')} onClick={handleChange}></SelectItem> | <SelectItem type={InputVarType.paragraph} value='paragraph' text={t('appDebug.variableConfig.paragraph')} onClick={handleChange}></SelectItem> | ||||
| <SelectItem type={InputVarType.select} value='select' text={t('appDebug.variableConfig.select')} onClick={handleChange}></SelectItem> | <SelectItem type={InputVarType.select} value='select' text={t('appDebug.variableConfig.select')} onClick={handleChange}></SelectItem> | ||||
| <SelectItem type={InputVarType.number} value='number' text={t('appDebug.variableConfig.number')} onClick={handleChange}></SelectItem> | <SelectItem type={InputVarType.number} value='number' text={t('appDebug.variableConfig.number')} onClick={handleChange}></SelectItem> | ||||
| <SelectItem type={InputVarType.checkbox} value='checkbox' text={t('appDebug.variableConfig.checkbox')} onClick={handleChange}></SelectItem> | |||||
| </div> | </div> | ||||
| <div className='h-px border-t border-components-panel-border'></div> | <div className='h-px border-t border-components-panel-border'></div> | ||||
| <div className='p-1'> | <div className='p-1'> |
| return t('tools.setBuiltInTools.number') | return t('tools.setBuiltInTools.number') | ||||
| if (type === 'text-input') | if (type === 'text-input') | ||||
| return t('tools.setBuiltInTools.string') | return t('tools.setBuiltInTools.string') | ||||
| if (type === 'checkbox') | |||||
| return 'boolean' | |||||
| if (type === 'file') | if (type === 'file') | ||||
| return t('tools.setBuiltInTools.file') | return t('tools.setBuiltInTools.file') | ||||
| return type | return type |
| import { DEFAULT_VALUE_MAX_LEN } from '@/config' | import { DEFAULT_VALUE_MAX_LEN } from '@/config' | ||||
| import type { Inputs } from '@/models/debug' | import type { Inputs } from '@/models/debug' | ||||
| import cn from '@/utils/classnames' | import cn from '@/utils/classnames' | ||||
| import BoolInput from '@/app/components/workflow/nodes/_base/components/before-run-form/bool-input' | |||||
| type Props = { | type Props = { | ||||
| inputs: Inputs | inputs: Inputs | ||||
| return obj | return obj | ||||
| })() | })() | ||||
| const handleInputValueChange = (key: string, value: string) => { | |||||
| const handleInputValueChange = (key: string, value: string | boolean) => { | |||||
| if (!(key in promptVariableObj)) | if (!(key in promptVariableObj)) | ||||
| return | return | ||||
| className='mb-4 last-of-type:mb-0' | className='mb-4 last-of-type:mb-0' | ||||
| > | > | ||||
| <div> | <div> | ||||
| {type !== 'checkbox' && ( | |||||
| <div className='system-sm-semibold mb-1 flex h-6 items-center gap-1 text-text-secondary'> | <div className='system-sm-semibold mb-1 flex h-6 items-center gap-1 text-text-secondary'> | ||||
| <div className='truncate'>{name || key}</div> | <div className='truncate'>{name || key}</div> | ||||
| {!required && <span className='system-xs-regular text-text-tertiary'>{t('workflow.panel.optional')}</span>} | {!required && <span className='system-xs-regular text-text-tertiary'>{t('workflow.panel.optional')}</span>} | ||||
| </div> | </div> | ||||
| )} | |||||
| <div className='grow'> | <div className='grow'> | ||||
| {type === 'string' && ( | {type === 'string' && ( | ||||
| <Input | <Input | ||||
| maxLength={max_length || DEFAULT_VALUE_MAX_LEN} | maxLength={max_length || DEFAULT_VALUE_MAX_LEN} | ||||
| /> | /> | ||||
| )} | )} | ||||
| {type === 'checkbox' && ( | |||||
| <BoolInput | |||||
| name={name || key} | |||||
| value={!!inputs[key]} | |||||
| required={required} | |||||
| onChange={(value) => { handleInputValueChange(key, value) }} | |||||
| /> | |||||
| )} | |||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| </div> | </div> |
| import TooltipPlus from '@/app/components/base/tooltip' | import TooltipPlus from '@/app/components/base/tooltip' | ||||
| import ActionButton, { ActionButtonState } from '@/app/components/base/action-button' | import ActionButton, { ActionButtonState } from '@/app/components/base/action-button' | ||||
| import type { ModelConfig as BackendModelConfig, VisionFile, VisionSettings } from '@/types/app' | import type { ModelConfig as BackendModelConfig, VisionFile, VisionSettings } from '@/types/app' | ||||
| import { promptVariablesToUserInputsForm } from '@/utils/model-config' | |||||
| import { formatBooleanInputs, promptVariablesToUserInputsForm } from '@/utils/model-config' | |||||
| import TextGeneration from '@/app/components/app/text-generate/item' | import TextGeneration from '@/app/components/app/text-generate/item' | ||||
| import { IS_CE_EDITION } from '@/config' | import { IS_CE_EDITION } from '@/config' | ||||
| import type { Inputs } from '@/models/debug' | import type { Inputs } from '@/models/debug' | ||||
| } | } | ||||
| const data: Record<string, any> = { | const data: Record<string, any> = { | ||||
| inputs, | |||||
| inputs: formatBooleanInputs(modelConfig.configs.prompt_variables, inputs), | |||||
| model_config: postModelConfig, | model_config: postModelConfig, | ||||
| } | } | ||||
| useModelListAndDefaultModelAndCurrentProviderAndModel, | useModelListAndDefaultModelAndCurrentProviderAndModel, | ||||
| useTextGenerationCurrentProviderAndModelAndModelList, | useTextGenerationCurrentProviderAndModelAndModelList, | ||||
| } from '@/app/components/header/account-setting/model-provider-page/hooks' | } from '@/app/components/header/account-setting/model-provider-page/hooks' | ||||
| import { fetchCollectionList } from '@/service/tools' | |||||
| import type { Collection } from '@/app/components/tools/types' | import type { Collection } from '@/app/components/tools/types' | ||||
| import { useStore as useAppStore } from '@/app/components/app/store' | import { useStore as useAppStore } from '@/app/components/app/store' | ||||
| import { | import { | ||||
| import { MittProvider } from '@/context/mitt-context' | import { MittProvider } from '@/context/mitt-context' | ||||
| import { fetchAndMergeValidCompletionParams } from '@/utils/completion-params' | import { fetchAndMergeValidCompletionParams } from '@/utils/completion-params' | ||||
| import Toast from '@/app/components/base/toast' | import Toast from '@/app/components/base/toast' | ||||
| import { fetchCollectionList } from '@/service/tools' | |||||
| import { useAppContext } from '@/context/app-context' | import { useAppContext } from '@/context/app-context' | ||||
| type PublishConfig = { | type PublishConfig = { |
| import { DEFAULT_VALUE_MAX_LEN } from '@/config' | import { DEFAULT_VALUE_MAX_LEN } from '@/config' | ||||
| import { useStore as useAppStore } from '@/app/components/app/store' | import { useStore as useAppStore } from '@/app/components/app/store' | ||||
| import cn from '@/utils/classnames' | import cn from '@/utils/classnames' | ||||
| import BoolInput from '@/app/components/workflow/nodes/_base/components/before-run-form/bool-input' | |||||
| export type IPromptValuePanelProps = { | export type IPromptValuePanelProps = { | ||||
| appType: AppType | appType: AppType | ||||
| else { return !modelConfig.configs.prompt_template } | else { return !modelConfig.configs.prompt_template } | ||||
| }, [chatPromptConfig.prompt, completionPromptConfig.prompt?.text, isAdvancedMode, mode, modelConfig.configs.prompt_template, modelModeType]) | }, [chatPromptConfig.prompt, completionPromptConfig.prompt?.text, isAdvancedMode, mode, modelConfig.configs.prompt_template, modelModeType]) | ||||
| const handleInputValueChange = (key: string, value: string) => { | |||||
| const handleInputValueChange = (key: string, value: string | boolean) => { | |||||
| if (!(key in promptVariableObj)) | if (!(key in promptVariableObj)) | ||||
| return | return | ||||
| className='mb-4 last-of-type:mb-0' | className='mb-4 last-of-type:mb-0' | ||||
| > | > | ||||
| <div> | <div> | ||||
| <div className='system-sm-semibold mb-1 flex h-6 items-center gap-1 text-text-secondary'> | |||||
| <div className='truncate'>{name || key}</div> | |||||
| {!required && <span className='system-xs-regular text-text-tertiary'>{t('workflow.panel.optional')}</span>} | |||||
| </div> | |||||
| {type !== 'checkbox' && ( | |||||
| <div className='system-sm-semibold mb-1 flex h-6 items-center gap-1 text-text-secondary'> | |||||
| <div className='truncate'>{name || key}</div> | |||||
| {!required && <span className='system-xs-regular text-text-tertiary'>{t('workflow.panel.optional')}</span>} | |||||
| </div> | |||||
| )} | |||||
| <div className='grow'> | <div className='grow'> | ||||
| {type === 'string' && ( | {type === 'string' && ( | ||||
| <Input | <Input | ||||
| maxLength={max_length || DEFAULT_VALUE_MAX_LEN} | maxLength={max_length || DEFAULT_VALUE_MAX_LEN} | ||||
| /> | /> | ||||
| )} | )} | ||||
| {type === 'checkbox' && ( | |||||
| <BoolInput | |||||
| name={name || key} | |||||
| value={!!inputs[key]} | |||||
| required={required} | |||||
| onChange={(value) => { handleInputValueChange(key, value) }} | |||||
| /> | |||||
| )} | |||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| </div> | </div> |
| import { Markdown } from '@/app/components/base/markdown' | import { Markdown } from '@/app/components/base/markdown' | ||||
| import cn from '@/utils/classnames' | import cn from '@/utils/classnames' | ||||
| import type { FileEntity } from '../../file-uploader/types' | import type { FileEntity } from '../../file-uploader/types' | ||||
| import { formatBooleanInputs } from '@/utils/model-config' | |||||
| import Avatar from '../../avatar' | import Avatar from '../../avatar' | ||||
| const ChatWrapper = () => { | const ChatWrapper = () => { | ||||
| let hasEmptyInput = '' | let hasEmptyInput = '' | ||||
| let fileIsUploading = false | let fileIsUploading = false | ||||
| const requiredVars = inputsForms.filter(({ required }) => required) | |||||
| const requiredVars = inputsForms.filter(({ required, type }) => required && type !== InputVarType.checkbox) | |||||
| if (requiredVars.length) { | if (requiredVars.length) { | ||||
| requiredVars.forEach(({ variable, label, type }) => { | requiredVars.forEach(({ variable, label, type }) => { | ||||
| if (hasEmptyInput) | if (hasEmptyInput) | ||||
| const data: any = { | const data: any = { | ||||
| query: message, | query: message, | ||||
| files, | files, | ||||
| inputs: currentConversationId ? currentConversationInputs : newConversationInputs, | |||||
| inputs: formatBooleanInputs(inputsForms, currentConversationId ? currentConversationInputs : newConversationInputs), | |||||
| conversation_id: currentConversationId, | conversation_id: currentConversationId, | ||||
| parent_message_id: (isRegenerate ? parentAnswer?.id : getLastAnswer(chatList)?.id) || null, | parent_message_id: (isRegenerate ? parentAnswer?.id : getLastAnswer(chatList)?.id) || null, | ||||
| } | } |
| type: 'number', | type: 'number', | ||||
| } | } | ||||
| } | } | ||||
| if(item.checkbox) { | |||||
| return { | |||||
| ...item.checkbox, | |||||
| default: false, | |||||
| type: 'checkbox', | |||||
| } | |||||
| } | |||||
| if (item.select) { | if (item.select) { | ||||
| const isInputInOptions = item.select.options.includes(initInputs[item.select.variable]) | const isInputInOptions = item.select.options.includes(initInputs[item.select.variable]) | ||||
| return { | return { | ||||
| } | } | ||||
| } | } | ||||
| if (item.json_object) { | |||||
| return { | |||||
| ...item.json_object, | |||||
| type: 'json_object', | |||||
| } | |||||
| } | |||||
| let value = initInputs[item['text-input'].variable] | let value = initInputs[item['text-input'].variable] | ||||
| if (value && item['text-input'].max_length && value.length > item['text-input'].max_length) | if (value && item['text-input'].max_length && value.length > item['text-input'].max_length) | ||||
| value = value.slice(0, item['text-input'].max_length) | value = value.slice(0, item['text-input'].max_length) | ||||
| let hasEmptyInput = '' | let hasEmptyInput = '' | ||||
| let fileIsUploading = false | let fileIsUploading = false | ||||
| const requiredVars = inputsForms.filter(({ required }) => required) | |||||
| const requiredVars = inputsForms.filter(({ required, type }) => required && type !== InputVarType.checkbox) | |||||
| if (requiredVars.length) { | if (requiredVars.length) { | ||||
| requiredVars.forEach(({ variable, label, type }) => { | requiredVars.forEach(({ variable, label, type }) => { | ||||
| if (hasEmptyInput) | if (hasEmptyInput) |
| import { PortalSelect } from '@/app/components/base/select' | import { PortalSelect } from '@/app/components/base/select' | ||||
| import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader' | import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader' | ||||
| import { InputVarType } from '@/app/components/workflow/types' | import { InputVarType } from '@/app/components/workflow/types' | ||||
| import BoolInput from '@/app/components/workflow/nodes/_base/components/before-run-form/bool-input' | |||||
| import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor' | |||||
| import { CodeLanguage } from '@/app/components/workflow/nodes/code/types' | |||||
| type Props = { | type Props = { | ||||
| showTip?: boolean | showTip?: boolean | ||||
| <div className='space-y-4'> | <div className='space-y-4'> | ||||
| {visibleInputsForms.map(form => ( | {visibleInputsForms.map(form => ( | ||||
| <div key={form.variable} className='space-y-1'> | <div key={form.variable} className='space-y-1'> | ||||
| <div className='flex h-6 items-center gap-1'> | |||||
| <div className='system-md-semibold text-text-secondary'>{form.label}</div> | |||||
| {!form.required && ( | |||||
| <div className='system-xs-regular text-text-tertiary'>{t('appDebug.variableTable.optional')}</div> | |||||
| )} | |||||
| </div> | |||||
| {form.type !== InputVarType.checkbox && ( | |||||
| <div className='flex h-6 items-center gap-1'> | |||||
| <div className='system-md-semibold text-text-secondary'>{form.label}</div> | |||||
| {!form.required && ( | |||||
| <div className='system-xs-regular text-text-tertiary'>{t('appDebug.variableTable.optional')}</div> | |||||
| )} | |||||
| </div> | |||||
| )} | |||||
| {form.type === InputVarType.textInput && ( | {form.type === InputVarType.textInput && ( | ||||
| <Input | <Input | ||||
| value={inputsFormValue?.[form.variable] || ''} | value={inputsFormValue?.[form.variable] || ''} | ||||
| placeholder={form.label} | placeholder={form.label} | ||||
| /> | /> | ||||
| )} | )} | ||||
| {form.type === InputVarType.checkbox && ( | |||||
| <BoolInput | |||||
| name={form.label} | |||||
| value={!!inputsFormValue?.[form.variable]} | |||||
| required={form.required} | |||||
| onChange={value => handleFormChange(form.variable, value)} | |||||
| /> | |||||
| )} | |||||
| {form.type === InputVarType.select && ( | {form.type === InputVarType.select && ( | ||||
| <PortalSelect | <PortalSelect | ||||
| popupClassName='w-[200px]' | popupClassName='w-[200px]' | ||||
| }} | }} | ||||
| /> | /> | ||||
| )} | )} | ||||
| {form.type === InputVarType.jsonObject && ( | |||||
| <CodeEditor | |||||
| language={CodeLanguage.json} | |||||
| value={inputsFormValue?.[form.variable] || ''} | |||||
| onChange={v => handleFormChange(form.variable, v)} | |||||
| noWrapper | |||||
| className='bg h-[80px] overflow-y-auto rounded-[10px] bg-components-input-bg-normal p-1' | |||||
| placeholder={ | |||||
| <div className='whitespace-pre'>{form.json_schema}</div> | |||||
| } | |||||
| /> | |||||
| )} | |||||
| </div> | </div> | ||||
| ))} | ))} | ||||
| {showTip && ( | {showTip && ( |
| const checkInputsForm = useCallback((inputs: Record<string, any>, inputsForm: InputForm[]) => { | const checkInputsForm = useCallback((inputs: Record<string, any>, inputsForm: InputForm[]) => { | ||||
| let hasEmptyInput = '' | let hasEmptyInput = '' | ||||
| let fileIsUploading = false | let fileIsUploading = false | ||||
| const requiredVars = inputsForm.filter(({ required }) => required) | |||||
| const requiredVars = inputsForm.filter(({ required, type }) => required && type !== InputVarType.checkbox) // boolean can be not checked | |||||
| if (requiredVars?.length) { | if (requiredVars?.length) { | ||||
| requiredVars.forEach(({ variable, label, type }) => { | requiredVars.forEach(({ variable, label, type }) => { |
| inputsForm.forEach((item) => { | inputsForm.forEach((item) => { | ||||
| const inputValue = inputs[item.variable] | const inputValue = inputs[item.variable] | ||||
| // set boolean type default value | |||||
| if(item.type === InputVarType.checkbox) { | |||||
| processedInputs[item.variable] = !!inputValue | |||||
| return | |||||
| } | |||||
| if (!inputValue) | if (!inputValue) | ||||
| return | return | ||||
| let hasEmptyInput = '' | let hasEmptyInput = '' | ||||
| let fileIsUploading = false | let fileIsUploading = false | ||||
| const requiredVars = inputsForms.filter(({ required }) => required) | |||||
| const requiredVars = inputsForms.filter(({ required, type }) => required && type !== InputVarType.checkbox) // boolean can be not checked | |||||
| if (requiredVars.length) { | if (requiredVars.length) { | ||||
| requiredVars.forEach(({ variable, label, type }) => { | requiredVars.forEach(({ variable, label, type }) => { | ||||
| if (hasEmptyInput) | if (hasEmptyInput) |
| type: 'number', | type: 'number', | ||||
| } | } | ||||
| } | } | ||||
| if (item.checkbox) { | |||||
| return { | |||||
| ...item.checkbox, | |||||
| default: false, | |||||
| type: 'checkbox', | |||||
| } | |||||
| } | |||||
| if (item.select) { | if (item.select) { | ||||
| const isInputInOptions = item.select.options.includes(initInputs[item.select.variable]) | const isInputInOptions = item.select.options.includes(initInputs[item.select.variable]) | ||||
| return { | return { | ||||
| } | } | ||||
| } | } | ||||
| if (item.json_object) { | |||||
| return { | |||||
| ...item.json_object, | |||||
| type: 'json_object', | |||||
| } | |||||
| } | |||||
| let value = initInputs[item['text-input'].variable] | let value = initInputs[item['text-input'].variable] | ||||
| if (value && item['text-input'].max_length && value.length > item['text-input'].max_length) | if (value && item['text-input'].max_length && value.length > item['text-input'].max_length) | ||||
| value = value.slice(0, item['text-input'].max_length) | value = value.slice(0, item['text-input'].max_length) | ||||
| let hasEmptyInput = '' | let hasEmptyInput = '' | ||||
| let fileIsUploading = false | let fileIsUploading = false | ||||
| const requiredVars = inputsForms.filter(({ required }) => required) | |||||
| const requiredVars = inputsForms.filter(({ required, type }) => required && type !== InputVarType.checkbox) | |||||
| if (requiredVars.length) { | if (requiredVars.length) { | ||||
| requiredVars.forEach(({ variable, label, type }) => { | requiredVars.forEach(({ variable, label, type }) => { | ||||
| if (hasEmptyInput) | if (hasEmptyInput) |
| import { PortalSelect } from '@/app/components/base/select' | import { PortalSelect } from '@/app/components/base/select' | ||||
| import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader' | import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader' | ||||
| import { InputVarType } from '@/app/components/workflow/types' | import { InputVarType } from '@/app/components/workflow/types' | ||||
| import BoolInput from '@/app/components/workflow/nodes/_base/components/before-run-form/bool-input' | |||||
| import { CodeLanguage } from '@/app/components/workflow/nodes/code/types' | |||||
| import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor' | |||||
| type Props = { | type Props = { | ||||
| showTip?: boolean | showTip?: boolean | ||||
| <div className='space-y-4'> | <div className='space-y-4'> | ||||
| {visibleInputsForms.map(form => ( | {visibleInputsForms.map(form => ( | ||||
| <div key={form.variable} className='space-y-1'> | <div key={form.variable} className='space-y-1'> | ||||
| {form.type !== InputVarType.checkbox && ( | |||||
| <div className='flex h-6 items-center gap-1'> | <div className='flex h-6 items-center gap-1'> | ||||
| <div className='system-md-semibold text-text-secondary'>{form.label}</div> | <div className='system-md-semibold text-text-secondary'>{form.label}</div> | ||||
| {!form.required && ( | {!form.required && ( | ||||
| <div className='system-xs-regular text-text-tertiary'>{t('appDebug.variableTable.optional')}</div> | <div className='system-xs-regular text-text-tertiary'>{t('appDebug.variableTable.optional')}</div> | ||||
| )} | )} | ||||
| </div> | </div> | ||||
| )} | |||||
| {form.type === InputVarType.textInput && ( | {form.type === InputVarType.textInput && ( | ||||
| <Input | <Input | ||||
| value={inputsFormValue?.[form.variable] || ''} | value={inputsFormValue?.[form.variable] || ''} | ||||
| placeholder={form.label} | placeholder={form.label} | ||||
| /> | /> | ||||
| )} | )} | ||||
| {form.type === InputVarType.checkbox && ( | |||||
| <BoolInput | |||||
| name={form.label} | |||||
| value={inputsFormValue?.[form.variable]} | |||||
| required={form.required} | |||||
| onChange={value => handleFormChange(form.variable, value)} | |||||
| /> | |||||
| )} | |||||
| {form.type === InputVarType.select && ( | {form.type === InputVarType.select && ( | ||||
| <PortalSelect | <PortalSelect | ||||
| popupClassName='w-[200px]' | popupClassName='w-[200px]' | ||||
| }} | }} | ||||
| /> | /> | ||||
| )} | )} | ||||
| {form.type === InputVarType.jsonObject && ( | |||||
| <CodeEditor | |||||
| language={CodeLanguage.json} | |||||
| value={inputsFormValue?.[form.variable] || ''} | |||||
| onChange={v => handleFormChange(form.variable, v)} | |||||
| noWrapper | |||||
| className='bg h-[80px] overflow-y-auto rounded-[10px] bg-components-input-bg-normal p-1' | |||||
| placeholder={ | |||||
| <div className='whitespace-pre'>{form.json_schema}</div> | |||||
| } | |||||
| /> | |||||
| )} | |||||
| </div> | </div> | ||||
| ))} | ))} | ||||
| {showTip && ( | {showTip && ( |
| secretInput = 'secret-input', | secretInput = 'secret-input', | ||||
| select = 'select', | select = 'select', | ||||
| radio = 'radio', | radio = 'radio', | ||||
| boolean = 'boolean', | |||||
| checkbox = 'checkbox', | |||||
| files = 'files', | files = 'files', | ||||
| file = 'file', | file = 'file', | ||||
| modelSelector = 'model-selector', | modelSelector = 'model-selector', |
| return mergeRegister( | return mergeRegister( | ||||
| editor.registerNodeTransform(CustomTextNode, textNode => decoratorTransform(textNode, getMatch, createCurrentBlockNode)), | editor.registerNodeTransform(CustomTextNode, textNode => decoratorTransform(textNode, getMatch, createCurrentBlockNode)), | ||||
| ) | ) | ||||
| // eslint-disable-next-line react-hooks/exhaustive-deps | |||||
| }, []) | }, []) | ||||
| return null | return null |
| return mergeRegister( | return mergeRegister( | ||||
| editor.registerNodeTransform(CustomTextNode, textNode => decoratorTransform(textNode, getMatch, createErrorMessageBlockNode)), | editor.registerNodeTransform(CustomTextNode, textNode => decoratorTransform(textNode, getMatch, createErrorMessageBlockNode)), | ||||
| ) | ) | ||||
| // eslint-disable-next-line react-hooks/exhaustive-deps | |||||
| }, []) | }, []) | ||||
| return null | return null |
| return mergeRegister( | return mergeRegister( | ||||
| editor.registerNodeTransform(CustomTextNode, textNode => decoratorTransform(textNode, getMatch, createLastRunBlockNode)), | editor.registerNodeTransform(CustomTextNode, textNode => decoratorTransform(textNode, getMatch, createLastRunBlockNode)), | ||||
| ) | ) | ||||
| // eslint-disable-next-line react-hooks/exhaustive-deps | |||||
| }, []) | }, []) | ||||
| return null | return null |
| required: false, | required: false, | ||||
| } | } | ||||
| } | } | ||||
| if(item.checkbox) { | |||||
| return { | |||||
| ...item.checkbox, | |||||
| type: 'checkbox', | |||||
| required: false, | |||||
| } | |||||
| } | |||||
| if (item.select) { | if (item.select) { | ||||
| return { | return { | ||||
| ...item.select, | ...item.select, | ||||
| } | } | ||||
| } | } | ||||
| if (item.json_object) { | |||||
| return { | |||||
| ...item.json_object, | |||||
| type: 'json_object', | |||||
| } | |||||
| } | |||||
| return { | return { | ||||
| ...item['text-input'], | ...item['text-input'], | ||||
| type: 'text-input', | type: 'text-input', |
| return t('tools.setBuiltInTools.number') | return t('tools.setBuiltInTools.number') | ||||
| if (type === 'text-input') | if (type === 'text-input') | ||||
| return t('tools.setBuiltInTools.string') | return t('tools.setBuiltInTools.string') | ||||
| if (type === 'checkbox') | |||||
| return 'boolean' | |||||
| if (type === 'file') | if (type === 'file') | ||||
| return t('tools.setBuiltInTools.file') | return t('tools.setBuiltInTools.file') | ||||
| if (type === 'array[tools]') | if (type === 'array[tools]') |
| import { | import { | ||||
| getFilesInLogs, | getFilesInLogs, | ||||
| } from '@/app/components/base/file-uploader/utils' | } from '@/app/components/base/file-uploader/utils' | ||||
| import { formatBooleanInputs } from '@/utils/model-config' | |||||
| export type IResultProps = { | export type IResultProps = { | ||||
| isWorkflow: boolean | isWorkflow: boolean | ||||
| } | } | ||||
| let hasEmptyInput = '' | let hasEmptyInput = '' | ||||
| const requiredVars = prompt_variables?.filter(({ key, name, required }) => { | |||||
| const requiredVars = prompt_variables?.filter(({ key, name, required, type }) => { | |||||
| if(type === 'boolean') | |||||
| return false // boolean input is not required | |||||
| const res = (!key || !key.trim()) || (!name || !name.trim()) || (required || required === undefined || required === null) | const res = (!key || !key.trim()) || (!name || !name.trim()) || (required || required === undefined || required === null) | ||||
| return res | return res | ||||
| }) || [] // compatible with old version | }) || [] // compatible with old version | ||||
| return | return | ||||
| const data: Record<string, any> = { | const data: Record<string, any> = { | ||||
| inputs, | |||||
| inputs: formatBooleanInputs(promptConfig?.prompt_variables, inputs), | |||||
| } | } | ||||
| if (visionConfig.enabled && completionFiles && completionFiles?.length > 0) { | if (visionConfig.enabled && completionFiles && completionFiles?.length > 0) { | ||||
| data.files = completionFiles.map((item) => { | data.files = completionFiles.map((item) => { |
| import { getProcessedFiles } from '@/app/components/base/file-uploader/utils' | import { getProcessedFiles } from '@/app/components/base/file-uploader/utils' | ||||
| import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' | import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' | ||||
| import cn from '@/utils/classnames' | import cn from '@/utils/classnames' | ||||
| import BoolInput from '@/app/components/workflow/nodes/_base/components/before-run-form/bool-input' | |||||
| import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor' | |||||
| import { CodeLanguage } from '@/app/components/workflow/nodes/code/types' | |||||
| export type IRunOnceProps = { | export type IRunOnceProps = { | ||||
| siteInfo: SiteInfo | siteInfo: SiteInfo | ||||
| {(inputs === null || inputs === undefined || Object.keys(inputs).length === 0) || !isInitialized ? null | {(inputs === null || inputs === undefined || Object.keys(inputs).length === 0) || !isInitialized ? null | ||||
| : promptConfig.prompt_variables.map(item => ( | : promptConfig.prompt_variables.map(item => ( | ||||
| <div className='mt-4 w-full' key={item.key}> | <div className='mt-4 w-full' key={item.key}> | ||||
| <label className='system-md-semibold flex h-6 items-center text-text-secondary'>{item.name}</label> | |||||
| {item.type !== 'boolean' && ( | |||||
| <label className='system-md-semibold flex h-6 items-center text-text-secondary'>{item.name}</label> | |||||
| )} | |||||
| <div className='mt-1'> | <div className='mt-1'> | ||||
| {item.type === 'select' && ( | {item.type === 'select' && ( | ||||
| <Select | <Select | ||||
| className='h-[104px] sm:text-xs' | className='h-[104px] sm:text-xs' | ||||
| placeholder={`${item.name}${!item.required ? `(${t('appDebug.variableTable.optional')})` : ''}`} | placeholder={`${item.name}${!item.required ? `(${t('appDebug.variableTable.optional')})` : ''}`} | ||||
| value={inputs[item.key]} | value={inputs[item.key]} | ||||
| onChange={(e: ChangeEvent<HTMLInputElement>) => { handleInputsChange({ ...inputsRef.current, [item.key]: e.target.value }) }} | |||||
| onChange={(e: ChangeEvent<HTMLTextAreaElement>) => { handleInputsChange({ ...inputsRef.current, [item.key]: e.target.value }) }} | |||||
| /> | /> | ||||
| )} | )} | ||||
| {item.type === 'number' && ( | {item.type === 'number' && ( | ||||
| onChange={(e: ChangeEvent<HTMLInputElement>) => { handleInputsChange({ ...inputsRef.current, [item.key]: e.target.value }) }} | onChange={(e: ChangeEvent<HTMLInputElement>) => { handleInputsChange({ ...inputsRef.current, [item.key]: e.target.value }) }} | ||||
| /> | /> | ||||
| )} | )} | ||||
| {item.type === 'boolean' && ( | |||||
| <BoolInput | |||||
| name={item.name || item.key} | |||||
| value={!!inputs[item.key]} | |||||
| required={item.required} | |||||
| onChange={(value) => { handleInputsChange({ ...inputsRef.current, [item.key]: value }) }} | |||||
| /> | |||||
| )} | |||||
| {item.type === 'file' && ( | {item.type === 'file' && ( | ||||
| <FileUploaderInAttachmentWrapper | <FileUploaderInAttachmentWrapper | ||||
| value={inputs[item.key] ? [inputs[item.key]] : []} | value={inputs[item.key] ? [inputs[item.key]] : []} | ||||
| }} | }} | ||||
| /> | /> | ||||
| )} | )} | ||||
| {item.type === 'json_object' && ( | |||||
| <CodeEditor | |||||
| language={CodeLanguage.json} | |||||
| value={inputs[item.key]} | |||||
| onChange={(value) => { handleInputsChange({ ...inputsRef.current, [item.key]: value }) }} | |||||
| noWrapper | |||||
| className='bg h-[80px] overflow-y-auto rounded-[10px] bg-components-input-bg-normal p-1' | |||||
| placeholder={ | |||||
| <div className='whitespace-pre'>{item.json_schema}</div> | |||||
| } | |||||
| /> | |||||
| )} | |||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| ))} | ))} |
| return 'text-input' | return 'text-input' | ||||
| case 'number': | case 'number': | ||||
| return 'number-input' | return 'number-input' | ||||
| case 'boolean': | |||||
| return 'checkbox' | |||||
| default: | default: | ||||
| return type | return type | ||||
| } | } |
| 'use client' | |||||
| import Checkbox from '@/app/components/base/checkbox' | |||||
| import type { FC } from 'react' | |||||
| import React, { useCallback } from 'react' | |||||
| import { useTranslation } from 'react-i18next' | |||||
| type Props = { | |||||
| name: string | |||||
| value: boolean | |||||
| required?: boolean | |||||
| onChange: (value: boolean) => void | |||||
| } | |||||
| const BoolInput: FC<Props> = ({ | |||||
| value, | |||||
| onChange, | |||||
| name, | |||||
| required, | |||||
| }) => { | |||||
| const { t } = useTranslation() | |||||
| const handleChange = useCallback(() => { | |||||
| onChange(!value) | |||||
| }, [value, onChange]) | |||||
| return ( | |||||
| <div className='flex h-6 items-center gap-2'> | |||||
| <Checkbox | |||||
| className='!h-4 !w-4' | |||||
| checked={!!value} | |||||
| onCheck={handleChange} | |||||
| /> | |||||
| <div className='system-sm-medium flex items-center gap-1 text-text-secondary'> | |||||
| {name} | |||||
| {!required && <span className='system-xs-regular text-text-tertiary'>{t('workflow.panel.optional')}</span>} | |||||
| </div> | |||||
| </div> | |||||
| ) | |||||
| } | |||||
| export default React.memo(BoolInput) |
| import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants' | import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants' | ||||
| import cn from '@/utils/classnames' | import cn from '@/utils/classnames' | ||||
| import type { FileEntity } from '@/app/components/base/file-uploader/types' | import type { FileEntity } from '@/app/components/base/file-uploader/types' | ||||
| import BoolInput from './bool-input' | |||||
| type Props = { | type Props = { | ||||
| payload: InputVar | payload: InputVar | ||||
| return '' | return '' | ||||
| })() | })() | ||||
| const isBooleanType = type === InputVarType.checkbox | |||||
| const isArrayLikeType = [InputVarType.contexts, InputVarType.iterator].includes(type) | const isArrayLikeType = [InputVarType.contexts, InputVarType.iterator].includes(type) | ||||
| const isContext = type === InputVarType.contexts | const isContext = type === InputVarType.contexts | ||||
| const isIterator = type === InputVarType.iterator | const isIterator = type === InputVarType.iterator | ||||
| return ( | return ( | ||||
| <div className={cn(className)}> | <div className={cn(className)}> | ||||
| {!isArrayLikeType && ( | |||||
| {!isArrayLikeType && !isBooleanType && ( | |||||
| <div className='system-sm-semibold mb-1 flex h-6 items-center gap-1 text-text-secondary'> | <div className='system-sm-semibold mb-1 flex h-6 items-center gap-1 text-text-secondary'> | ||||
| <div className='truncate'>{typeof payload.label === 'object' ? nodeKey : payload.label}</div> | <div className='truncate'>{typeof payload.label === 'object' ? nodeKey : payload.label}</div> | ||||
| {!payload.required && <span className='system-xs-regular text-text-tertiary'>{t('workflow.panel.optional')}</span>} | {!payload.required && <span className='system-xs-regular text-text-tertiary'>{t('workflow.panel.optional')}</span>} | ||||
| ) | ) | ||||
| } | } | ||||
| {isBooleanType && ( | |||||
| <BoolInput | |||||
| name={payload.label as string} | |||||
| value={!!value} | |||||
| required={payload.required} | |||||
| onChange={onChange} | |||||
| /> | |||||
| )} | |||||
| { | { | ||||
| type === InputVarType.json && ( | type === InputVarType.json && ( | ||||
| <CodeEditor | <CodeEditor | ||||
| /> | /> | ||||
| ) | ) | ||||
| } | } | ||||
| { type === InputVarType.jsonObject && ( | |||||
| <CodeEditor | |||||
| value={value} | |||||
| language={CodeLanguage.json} | |||||
| onChange={onChange} | |||||
| noWrapper | |||||
| className='bg h-[80px] overflow-y-auto rounded-[10px] bg-components-input-bg-normal p-1' | |||||
| placeholder={ | |||||
| <div className='whitespace-pre'>{payload.json_schema}</div> | |||||
| } | |||||
| /> | |||||
| )} | |||||
| {(type === InputVarType.singleFile) && ( | {(type === InputVarType.singleFile) && ( | ||||
| <FileUploaderInAttachmentWrapper | <FileUploaderInAttachmentWrapper | ||||
| value={singleFileValue} | value={singleFileValue} |
| } & Partial<SpecialResultPanelProps> | } & Partial<SpecialResultPanelProps> | ||||
| function formatValue(value: string | any, type: InputVarType) { | function formatValue(value: string | any, type: InputVarType) { | ||||
| if(type === InputVarType.checkbox) | |||||
| return !!value | |||||
| if(value === undefined || value === null) | if(value === undefined || value === null) | ||||
| return value | return value | ||||
| if (type === InputVarType.number) | if (type === InputVarType.number) | ||||
| form.inputs.forEach((input) => { | form.inputs.forEach((input) => { | ||||
| const value = form.values[input.variable] as any | const value = form.values[input.variable] as any | ||||
| if (!errMsg && input.required && !(input.variable in existVarValuesInForm) && (value === '' || value === undefined || value === null || (input.type === InputVarType.files && value.length === 0))) | |||||
| if (!errMsg && input.required && (input.type !== InputVarType.checkbox) && !(input.variable in existVarValuesInForm) && (value === '' || value === undefined || value === null || (input.type === InputVarType.files && value.length === 0))) | |||||
| errMsg = t('workflow.errorMsg.fieldRequired', { field: typeof input.label === 'object' ? input.label.variable : input.label }) | errMsg = t('workflow.errorMsg.fieldRequired', { field: typeof input.label === 'object' ? input.label.variable : input.label }) | ||||
| if (!errMsg && (input.type === InputVarType.singleFile || input.type === InputVarType.multiFiles) && value) { | if (!errMsg && (input.type === InputVarType.singleFile || input.type === InputVarType.multiFiles) && value) { |
| const isSelect = type === FormTypeEnum.select || type === FormTypeEnum.dynamicSelect | const isSelect = type === FormTypeEnum.select || type === FormTypeEnum.dynamicSelect | ||||
| const isAppSelector = type === FormTypeEnum.appSelector | const isAppSelector = type === FormTypeEnum.appSelector | ||||
| const isModelSelector = type === FormTypeEnum.modelSelector | const isModelSelector = type === FormTypeEnum.modelSelector | ||||
| const showTypeSwitch = isNumber || isObject || isArray | |||||
| const showTypeSwitch = isNumber || isBoolean || isObject || isArray | |||||
| const isConstant = varInput?.type === VarKindType.constant || !varInput?.type | const isConstant = varInput?.type === VarKindType.constant || !varInput?.type | ||||
| const showVariableSelector = isFile || varInput?.type === VarKindType.variable | const showVariableSelector = isFile || varInput?.type === VarKindType.variable | ||||
| 'use client' | 'use client' | ||||
| import type { FC } from 'react' | import type { FC } from 'react' | ||||
| import React from 'react' | import React from 'react' | ||||
| import { RiAlignLeft, RiCheckboxMultipleLine, RiFileCopy2Line, RiFileList2Line, RiHashtag, RiTextSnippet } from '@remixicon/react' | |||||
| import { RiAlignLeft, RiBracesLine, RiCheckboxLine, RiCheckboxMultipleLine, RiFileCopy2Line, RiFileList2Line, RiHashtag, RiTextSnippet } from '@remixicon/react' | |||||
| import { InputVarType } from '../../../types' | import { InputVarType } from '../../../types' | ||||
| type Props = { | type Props = { | ||||
| [InputVarType.paragraph]: RiAlignLeft, | [InputVarType.paragraph]: RiAlignLeft, | ||||
| [InputVarType.select]: RiCheckboxMultipleLine, | [InputVarType.select]: RiCheckboxMultipleLine, | ||||
| [InputVarType.number]: RiHashtag, | [InputVarType.number]: RiHashtag, | ||||
| [InputVarType.checkbox]: RiCheckboxLine, | |||||
| [InputVarType.jsonObject]: RiBracesLine, | |||||
| [InputVarType.singleFile]: RiFileList2Line, | [InputVarType.singleFile]: RiFileList2Line, | ||||
| [InputVarType.multiFiles]: RiFileCopy2Line, | [InputVarType.multiFiles]: RiFileCopy2Line, | ||||
| } as any)[type] || RiTextSnippet | } as any)[type] || RiTextSnippet |
| ) | ) | ||||
| } | } | ||||
| const inputVarTypeToVarType = (type: InputVarType): VarType => { | |||||
| export const inputVarTypeToVarType = (type: InputVarType): VarType => { | |||||
| return ({ | return ({ | ||||
| [InputVarType.number]: VarType.number, | [InputVarType.number]: VarType.number, | ||||
| [InputVarType.checkbox]: VarType.boolean, | |||||
| [InputVarType.singleFile]: VarType.file, | [InputVarType.singleFile]: VarType.file, | ||||
| [InputVarType.multiFiles]: VarType.arrayFile, | [InputVarType.multiFiles]: VarType.arrayFile, | ||||
| [InputVarType.jsonObject]: VarType.object, | |||||
| } as any)[type] || VarType.string | } as any)[type] || VarType.string | ||||
| } | } | ||||
| variables, | variables, | ||||
| } = data as StartNodeType | } = data as StartNodeType | ||||
| res.vars = variables.map((v) => { | res.vars = variables.map((v) => { | ||||
| return { | |||||
| const type = inputVarTypeToVarType(v.type) | |||||
| const varRes: Var = { | |||||
| variable: v.variable, | variable: v.variable, | ||||
| type: inputVarTypeToVarType(v.type), | |||||
| type, | |||||
| isParagraph: v.type === InputVarType.paragraph, | isParagraph: v.type === InputVarType.paragraph, | ||||
| isSelect: v.type === InputVarType.select, | isSelect: v.type === InputVarType.select, | ||||
| options: v.options, | options: v.options, | ||||
| required: v.required, | required: v.required, | ||||
| } | } | ||||
| try { | |||||
| if(type === VarType.object && v.json_schema) { | |||||
| varRes.children = { | |||||
| schema: JSON.parse(v.json_schema), | |||||
| } | |||||
| } | |||||
| } | |||||
| catch (error) { | |||||
| console.error('Error formatting variable:', error) | |||||
| } | |||||
| return varRes | |||||
| }) | }) | ||||
| if (isChatMode) { | if (isChatMode) { | ||||
| res.vars.push({ | res.vars.push({ | ||||
| return VarType.string | return VarType.string | ||||
| case VarType.arrayNumber: | case VarType.arrayNumber: | ||||
| return VarType.number | return VarType.number | ||||
| case VarType.arrayBoolean: | |||||
| return VarType.boolean | |||||
| case VarType.arrayObject: | case VarType.arrayObject: | ||||
| return VarType.object | return VarType.object | ||||
| case VarType.array: | case VarType.array: | ||||
| return VarType.number | return VarType.number | ||||
| case VarType.arrayObject: | case VarType.arrayObject: | ||||
| return VarType.object | return VarType.object | ||||
| case VarType.arrayBoolean: | |||||
| return VarType.boolean | |||||
| case VarType.array: | case VarType.array: | ||||
| return VarType.any | return VarType.any | ||||
| case VarType.arrayFile: | case VarType.arrayFile: |
| onChange: (value: string) => void | onChange: (value: string) => void | ||||
| } | } | ||||
| const TYPES = [VarType.string, VarType.number, VarType.arrayNumber, VarType.arrayString, VarType.arrayObject, VarType.object] | |||||
| const TYPES = [VarType.string, VarType.number, VarType.boolean, VarType.arrayNumber, VarType.arrayString, VarType.arrayBoolean, VarType.arrayObject, VarType.object] | |||||
| const VarReferencePicker: FC<Props> = ({ | const VarReferencePicker: FC<Props> = ({ | ||||
| readonly, | readonly, | ||||
| className, | className, |
| isRunAfterSingleRun={isRunAfterSingleRun} | isRunAfterSingleRun={isRunAfterSingleRun} | ||||
| updateNodeRunningStatus={updateNodeRunningStatus} | updateNodeRunningStatus={updateNodeRunningStatus} | ||||
| onSingleRunClicked={handleSingleRun} | onSingleRunClicked={handleSingleRun} | ||||
| nodeInfo={nodeInfo} | |||||
| nodeInfo={nodeInfo!} | |||||
| singleRunResult={runResult!} | singleRunResult={runResult!} | ||||
| isPaused={isPaused} | isPaused={isPaused} | ||||
| {...passedLogParams} | {...passedLogParams} |
| updateNodeRunningStatus(hidePageOneStepFinishedStatus) | updateNodeRunningStatus(hidePageOneStepFinishedStatus) | ||||
| resetHidePageStatus() | resetHidePageStatus() | ||||
| } | } | ||||
| // eslint-disable-next-line react-hooks/exhaustive-deps | |||||
| }, [isOneStepRunSucceed, isOneStepRunFailed, oneStepRunRunningStatus]) | }, [isOneStepRunSucceed, isOneStepRunFailed, oneStepRunRunningStatus]) | ||||
| useEffect(() => { | useEffect(() => { | ||||
| useEffect(() => { | useEffect(() => { | ||||
| resetHidePageStatus() | resetHidePageStatus() | ||||
| // eslint-disable-next-line react-hooks/exhaustive-deps | |||||
| }, [nodeId]) | }, [nodeId]) | ||||
| const handlePageVisibilityChange = useCallback(() => { | const handlePageVisibilityChange = useCallback(() => { | ||||
| status={isPaused ? NodeRunningStatus.Stopped : ((runResult as any).status || otherResultPanelProps.status)} | status={isPaused ? NodeRunningStatus.Stopped : ((runResult as any).status || otherResultPanelProps.status)} | ||||
| total_tokens={(runResult as any)?.execution_metadata?.total_tokens || otherResultPanelProps?.total_tokens} | total_tokens={(runResult as any)?.execution_metadata?.total_tokens || otherResultPanelProps?.total_tokens} | ||||
| created_by={(runResult as any)?.created_by_account?.created_by || otherResultPanelProps?.created_by} | created_by={(runResult as any)?.created_by_account?.created_by || otherResultPanelProps?.created_by} | ||||
| nodeInfo={nodeInfo} | |||||
| nodeInfo={runResult as NodeTracing} | |||||
| showSteps={false} | showSteps={false} | ||||
| /> | /> | ||||
| </div> | </div> |
| checkValid, | checkValid, | ||||
| } = oneStepRunRes | } = oneStepRunRes | ||||
| const nodeInfo = runResult | |||||
| const { | const { | ||||
| nodeInfo, | |||||
| ...singleRunParams | ...singleRunParams | ||||
| } = useSingleRunFormParamsHooks(blockType)({ | } = useSingleRunFormParamsHooks(blockType)({ | ||||
| id, | id, | ||||
| setTabType(TabType.lastRun) | setTabType(TabType.lastRun) | ||||
| setInitShowLastRunTab(false) | setInitShowLastRunTab(false) | ||||
| // eslint-disable-next-line react-hooks/exhaustive-deps | |||||
| }, [initShowLastRunTab]) | }, [initShowLastRunTab]) | ||||
| const invalidLastRun = useInvalidLastRun(appId!, id) | const invalidLastRun = useInvalidLastRun(appId!, id) | ||||
| return InputVarType.paragraph | return InputVarType.paragraph | ||||
| if (type === VarType.number) | if (type === VarType.number) | ||||
| return InputVarType.number | return InputVarType.number | ||||
| if (type === VarType.boolean) | |||||
| return InputVarType.checkbox | |||||
| if ([VarType.object, VarType.array, VarType.arrayNumber, VarType.arrayString, VarType.arrayObject].includes(type)) | if ([VarType.object, VarType.array, VarType.arrayNumber, VarType.arrayString, VarType.arrayObject].includes(type)) | ||||
| return InputVarType.json | return InputVarType.json | ||||
| if (type === VarType.file) | if (type === VarType.file) | ||||
| const isPaused = isPausedRef.current | const isPaused = isPausedRef.current | ||||
| // The backend don't support pause the single run, so the frontend handle the pause state. | // The backend don't support pause the single run, so the frontend handle the pause state. | ||||
| if(isPaused) | |||||
| if (isPaused) | |||||
| return | return | ||||
| const canRunLastRun = !isRunAfterSingleRun || runningStatus === NodeRunningStatus.Succeeded | const canRunLastRun = !isRunAfterSingleRun || runningStatus === NodeRunningStatus.Succeeded | ||||
| if(!canRunLastRun) { | |||||
| if (!canRunLastRun) { | |||||
| doSetRunResult(data) | doSetRunResult(data) | ||||
| return | return | ||||
| } | } | ||||
| const { getNodes } = store.getState() | const { getNodes } = store.getState() | ||||
| const nodes = getNodes() | const nodes = getNodes() | ||||
| appendNodeInspectVars(id, vars, nodes) | appendNodeInspectVars(id, vars, nodes) | ||||
| if(data?.status === NodeRunningStatus.Succeeded) { | |||||
| if (data?.status === NodeRunningStatus.Succeeded) { | |||||
| invalidLastRun() | invalidLastRun() | ||||
| if(isStartNode) | |||||
| if (isStartNode) | |||||
| invalidateSysVarValues() | invalidateSysVarValues() | ||||
| invalidateConversationVarValues() // loop, iteration, variable assigner node can update the conversation variables, but to simple the logic(some nodes may also can update in the future), all nodes refresh. | invalidateConversationVarValues() // loop, iteration, variable assigner node can update the conversation variables, but to simple the logic(some nodes may also can update in the future), all nodes refresh. | ||||
| } | } | ||||
| }) | }) | ||||
| } | } | ||||
| const checkValidWrap = () => { | const checkValidWrap = () => { | ||||
| if(!checkValid) | |||||
| if (!checkValid) | |||||
| return { isValid: true, errorMessage: '' } | return { isValid: true, errorMessage: '' } | ||||
| const res = checkValid(data, t, moreDataForCheckValid) | const res = checkValid(data, t, moreDataForCheckValid) | ||||
| if(!res.isValid) { | |||||
| handleNodeDataUpdate({ | |||||
| id, | |||||
| data: { | |||||
| ...data, | |||||
| _isSingleRun: false, | |||||
| }, | |||||
| }) | |||||
| Toast.notify({ | |||||
| type: 'error', | |||||
| message: res.errorMessage, | |||||
| }) | |||||
| if (!res.isValid) { | |||||
| handleNodeDataUpdate({ | |||||
| id, | |||||
| data: { | |||||
| ...data, | |||||
| _isSingleRun: false, | |||||
| }, | |||||
| }) | |||||
| Toast.notify({ | |||||
| type: 'error', | |||||
| message: res.errorMessage, | |||||
| }) | |||||
| } | } | ||||
| return res | return res | ||||
| } | } | ||||
| const { isValid } = checkValidWrap() | const { isValid } = checkValidWrap() | ||||
| setCanShowSingleRun(isValid) | setCanShowSingleRun(isValid) | ||||
| } | } | ||||
| // eslint-disable-next-line react-hooks/exhaustive-deps | |||||
| }, [data._isSingleRun]) | }, [data._isSingleRun]) | ||||
| useEffect(() => { | useEffect(() => { | ||||
| if (!isIteration && !isLoop) { | if (!isIteration && !isLoop) { | ||||
| const isStartNode = data.type === BlockEnum.Start | const isStartNode = data.type === BlockEnum.Start | ||||
| const postData: Record<string, any> = {} | const postData: Record<string, any> = {} | ||||
| if(isStartNode) { | |||||
| if (isStartNode) { | |||||
| const { '#sys.query#': query, '#sys.files#': files, ...inputs } = submitData | const { '#sys.query#': query, '#sys.files#': files, ...inputs } = submitData | ||||
| if(isChatMode) | |||||
| if (isChatMode) | |||||
| postData.conversation_id = '' | postData.conversation_id = '' | ||||
| postData.inputs = inputs | postData.inputs = inputs | ||||
| { | { | ||||
| onWorkflowStarted: noop, | onWorkflowStarted: noop, | ||||
| onWorkflowFinished: (params) => { | onWorkflowFinished: (params) => { | ||||
| if(isPausedRef.current) | |||||
| if (isPausedRef.current) | |||||
| return | return | ||||
| handleNodeDataUpdate({ | handleNodeDataUpdate({ | ||||
| id, | id, | ||||
| setIterationRunResult(newIterationRunResult) | setIterationRunResult(newIterationRunResult) | ||||
| }, | }, | ||||
| onError: () => { | onError: () => { | ||||
| if(isPausedRef.current) | |||||
| if (isPausedRef.current) | |||||
| return | return | ||||
| handleNodeDataUpdate({ | handleNodeDataUpdate({ | ||||
| id, | id, | ||||
| { | { | ||||
| onWorkflowStarted: noop, | onWorkflowStarted: noop, | ||||
| onWorkflowFinished: (params) => { | onWorkflowFinished: (params) => { | ||||
| if(isPausedRef.current) | |||||
| if (isPausedRef.current) | |||||
| return | return | ||||
| handleNodeDataUpdate({ | handleNodeDataUpdate({ | ||||
| id, | id, | ||||
| setLoopRunResult(newLoopRunResult) | setLoopRunResult(newLoopRunResult) | ||||
| }, | }, | ||||
| onError: () => { | onError: () => { | ||||
| if(isPausedRef.current) | |||||
| if (isPausedRef.current) | |||||
| return | return | ||||
| handleNodeDataUpdate({ | handleNodeDataUpdate({ | ||||
| id, | id, | ||||
| hasError = true | hasError = true | ||||
| invalidLastRun() | invalidLastRun() | ||||
| if (!isIteration && !isLoop) { | if (!isIteration && !isLoop) { | ||||
| if(isPausedRef.current) | |||||
| if (isPausedRef.current) | |||||
| return | return | ||||
| handleNodeDataUpdate({ | handleNodeDataUpdate({ | ||||
| id, | id, | ||||
| }) | }) | ||||
| } | } | ||||
| } | } | ||||
| if(isPausedRef.current) | |||||
| if (isPausedRef.current) | |||||
| return | return | ||||
| if (!isIteration && !isLoop && !hasError) { | if (!isIteration && !isLoop && !hasError) { | ||||
| if(isPausedRef.current) | |||||
| if (isPausedRef.current) | |||||
| return | return | ||||
| handleNodeDataUpdate({ | handleNodeDataUpdate({ | ||||
| id, | id, | ||||
| } | } | ||||
| } | } | ||||
| return { | return { | ||||
| label: item.label || item.variable, | |||||
| label: (typeof item.label === 'object' ? item.label.variable : item.label) || item.variable, | |||||
| variable: item.variable, | variable: item.variable, | ||||
| type: varTypeToInputVarType(originalVar.type, { | type: varTypeToInputVarType(originalVar.type, { | ||||
| isSelect: !!originalVar.isSelect, | isSelect: !!originalVar.isSelect, |
| useEffect(() => { | useEffect(() => { | ||||
| if (!ref?.current) return | if (!ref?.current) return | ||||
| setWrapHeight(ref.current?.clientHeight) | setWrapHeight(ref.current?.clientHeight) | ||||
| // eslint-disable-next-line react-hooks/exhaustive-deps | |||||
| }, [isExpand]) | }, [isExpand]) | ||||
| const wrapClassName = (() => { | const wrapClassName = (() => { |
| import type { AssignerNodeOperation } from '../../types' | import type { AssignerNodeOperation } from '../../types' | ||||
| import ListNoDataPlaceholder from '@/app/components/workflow/nodes/_base/components/list-no-data-placeholder' | 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 VarReferencePicker from '@/app/components/workflow/nodes/_base/components/variable/var-reference-picker' | ||||
| import type { ValueSelector, Var, VarType } from '@/app/components/workflow/types' | |||||
| import type { ValueSelector, Var } from '@/app/components/workflow/types' | |||||
| import { VarType } from '@/app/components/workflow/types' | |||||
| import { CodeLanguage } from '@/app/components/workflow/nodes/code/types' | import { CodeLanguage } from '@/app/components/workflow/nodes/code/types' | ||||
| import ActionButton from '@/app/components/base/action-button' | import ActionButton from '@/app/components/base/action-button' | ||||
| import Input from '@/app/components/base/input' | import Input from '@/app/components/base/input' | ||||
| import Textarea from '@/app/components/base/textarea' | import Textarea from '@/app/components/base/textarea' | ||||
| import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor' | import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor' | ||||
| import { noop } from 'lodash-es' | import { noop } from 'lodash-es' | ||||
| import BoolValue from '@/app/components/workflow/panel/chat-variable-panel/components/bool-value' | |||||
| type Props = { | type Props = { | ||||
| readonly: boolean | readonly: boolean | ||||
| } | } | ||||
| }, [list, onChange]) | }, [list, onChange]) | ||||
| const handleOperationChange = useCallback((index: number) => { | |||||
| const handleOperationChange = useCallback((index: number, varType: VarType) => { | |||||
| return (item: { value: string | number }) => { | return (item: { value: string | number }) => { | ||||
| const newList = produce(list, (draft) => { | const newList = produce(list, (draft) => { | ||||
| draft[index].operation = item.value as WriteMode | draft[index].operation = item.value as WriteMode | ||||
| draft[index].value = '' // Clear value when operation changes | draft[index].value = '' // Clear value when operation changes | ||||
| if (item.value === WriteMode.set || item.value === WriteMode.increment || item.value === WriteMode.decrement | if (item.value === WriteMode.set || item.value === WriteMode.increment || item.value === WriteMode.decrement | ||||
| || item.value === WriteMode.multiply || item.value === WriteMode.divide) | |||||
| || item.value === WriteMode.multiply || item.value === WriteMode.divide) { | |||||
| if(varType === VarType.boolean) | |||||
| draft[index].value = false | |||||
| draft[index].input_type = AssignerNodeInputType.constant | draft[index].input_type = AssignerNodeInputType.constant | ||||
| else | |||||
| } | |||||
| else { | |||||
| draft[index].input_type = AssignerNodeInputType.variable | draft[index].input_type = AssignerNodeInputType.variable | ||||
| } | |||||
| }) | }) | ||||
| onChange(newList) | onChange(newList) | ||||
| } | } | ||||
| }, [list, onChange]) | }, [list, onChange]) | ||||
| const handleToAssignedVarChange = useCallback((index: number) => { | const handleToAssignedVarChange = useCallback((index: number) => { | ||||
| return (value: ValueSelector | string | number) => { | |||||
| return (value: ValueSelector | string | number | boolean) => { | |||||
| const newList = produce(list, (draft) => { | const newList = produce(list, (draft) => { | ||||
| draft[index].value = value as ValueSelector | draft[index].value = value as ValueSelector | ||||
| }) | }) | ||||
| value={item.operation} | value={item.operation} | ||||
| placeholder='Operation' | placeholder='Operation' | ||||
| disabled={!item.variable_selector || item.variable_selector.length === 0} | disabled={!item.variable_selector || item.variable_selector.length === 0} | ||||
| onSelect={handleOperationChange(index)} | |||||
| onSelect={handleOperationChange(index, assignedVarType!)} | |||||
| assignedVarType={assignedVarType} | assignedVarType={assignedVarType} | ||||
| writeModeTypes={writeModeTypes} | writeModeTypes={writeModeTypes} | ||||
| writeModeTypesArr={writeModeTypesArr} | writeModeTypesArr={writeModeTypesArr} | ||||
| className='w-full' | className='w-full' | ||||
| /> | /> | ||||
| )} | )} | ||||
| {assignedVarType === 'boolean' && ( | |||||
| <BoolValue | |||||
| value={item.value as boolean} | |||||
| onChange={value => handleToAssignedVarChange(index)(value)} | |||||
| /> | |||||
| )} | |||||
| {assignedVarType === 'object' && ( | {assignedVarType === 'object' && ( | ||||
| <CodeEditor | <CodeEditor | ||||
| value={item.value as string} | value={item.value as string} |
| if (value.operation === WriteMode.set || value.operation === WriteMode.increment | if (value.operation === WriteMode.set || value.operation === WriteMode.increment | ||||
| || value.operation === WriteMode.decrement || value.operation === WriteMode.multiply | || value.operation === WriteMode.decrement || value.operation === WriteMode.multiply | ||||
| || value.operation === WriteMode.divide) { | || value.operation === WriteMode.divide) { | ||||
| if (!value.value && typeof value.value !== 'number') | |||||
| if (!value.value && value.value !== false && typeof value.value !== 'number') | |||||
| errorMessages = t(`${i18nPrefix}.fieldRequired`, { field: t('workflow.nodes.assigner.variable') }) | errorMessages = t(`${i18nPrefix}.fieldRequired`, { field: t('workflow.nodes.assigner.variable') }) | ||||
| } | } | ||||
| else if (!value.value?.length) { | else if (!value.value?.length) { |
| ] | ] | ||||
| } | } | ||||
| if (writeModeTypes && ['string', 'object'].includes(assignedVarType || '')) { | |||||
| if (writeModeTypes && ['string', 'boolean', 'object'].includes(assignedVarType || '')) { | |||||
| return writeModeTypes.map(type => ({ | return writeModeTypes.map(type => ({ | ||||
| value: type, | value: type, | ||||
| name: type, | name: type, |
| }) | }) | ||||
| syncOutputKeyOrders(defaultConfig.outputs) | syncOutputKeyOrders(defaultConfig.outputs) | ||||
| } | } | ||||
| // eslint-disable-next-line react-hooks/exhaustive-deps | |||||
| }, [defaultConfig]) | }, [defaultConfig]) | ||||
| const handleCodeChange = useCallback((code: string) => { | const handleCodeChange = useCallback((code: string) => { | ||||
| }, [allLanguageDefault, inputs, setInputs]) | }, [allLanguageDefault, inputs, setInputs]) | ||||
| const handleSyncFunctionSignature = useCallback(() => { | const handleSyncFunctionSignature = useCallback(() => { | ||||
| const generateSyncSignatureCode = (code: string) => { | |||||
| const generateSyncSignatureCode = (code: string) => { | |||||
| let mainDefRe | let mainDefRe | ||||
| let newMainDef | let newMainDef | ||||
| if (inputs.code_language === CodeLanguage.javascript) { | if (inputs.code_language === CodeLanguage.javascript) { | ||||
| }) | }) | ||||
| const filterVar = useCallback((varPayload: Var) => { | const filterVar = useCallback((varPayload: Var) => { | ||||
| return [VarType.string, VarType.number, VarType.secret, VarType.object, VarType.array, VarType.arrayNumber, VarType.arrayString, VarType.arrayObject, VarType.file, VarType.arrayFile].includes(varPayload.type) | |||||
| return [VarType.string, VarType.number, VarType.boolean, VarType.secret, VarType.object, VarType.array, VarType.arrayNumber, VarType.arrayString, VarType.arrayObject, VarType.arrayBoolean, VarType.file, VarType.arrayFile].includes(varPayload.type) | |||||
| }, []) | }, []) | ||||
| const handleCodeAndVarsChange = useCallback((code: string, inputVariables: Variable[], outputVariables: OutputVar) => { | const handleCodeAndVarsChange = useCallback((code: string, inputVariables: Variable[], outputVariables: OutputVar) => { |
| import cn from '@/utils/classnames' | import cn from '@/utils/classnames' | ||||
| import { SimpleSelect as Select } from '@/app/components/base/select' | import { SimpleSelect as Select } from '@/app/components/base/select' | ||||
| import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development' | import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development' | ||||
| import BoolValue from '@/app/components/workflow/panel/chat-variable-panel/components/bool-value' | |||||
| import { getVarType } from '@/app/components/workflow/nodes/_base/components/variable/utils' | import { getVarType } from '@/app/components/workflow/nodes/_base/components/variable/utils' | ||||
| import { useIsChatMode } from '@/app/components/workflow/hooks/use-workflow' | import { useIsChatMode } from '@/app/components/workflow/hooks/use-workflow' | ||||
| const optionNameI18NPrefix = 'workflow.nodes.ifElse.optionName' | const optionNameI18NPrefix = 'workflow.nodes.ifElse.optionName' | ||||
| const isArrayValue = fileAttr?.key === 'transfer_method' || fileAttr?.key === 'type' | const isArrayValue = fileAttr?.key === 'transfer_method' || fileAttr?.key === 'type' | ||||
| const handleUpdateConditionValue = useCallback((value: string) => { | |||||
| if (value === condition.value || (isArrayValue && value === condition.value?.[0])) | |||||
| const handleUpdateConditionValue = useCallback((value: string | boolean) => { | |||||
| if (value === condition.value || (isArrayValue && value === (condition.value as string[])?.[0])) | |||||
| return | return | ||||
| const newCondition = { | const newCondition = { | ||||
| ...condition, | ...condition, | ||||
| value: isArrayValue ? [value] : value, | |||||
| value: isArrayValue ? [value as string] : value, | |||||
| } | } | ||||
| doUpdateCondition(newCondition) | doUpdateCondition(newCondition) | ||||
| }, [condition, doUpdateCondition, isArrayValue]) | }, [condition, doUpdateCondition, isArrayValue]) | ||||
| }, [caseId, condition, conditionId, isSubVariableKey, onRemoveCondition, onRemoveSubVariableCondition]) | }, [caseId, condition, conditionId, isSubVariableKey, onRemoveCondition, onRemoveSubVariableCondition]) | ||||
| const handleVarChange = useCallback((valueSelector: ValueSelector, _varItem: Var) => { | const handleVarChange = useCallback((valueSelector: ValueSelector, _varItem: Var) => { | ||||
| const { | |||||
| conversationVariables, | |||||
| } = workflowStore.getState() | |||||
| const resolvedVarType = getVarType({ | const resolvedVarType = getVarType({ | ||||
| valueSelector, | valueSelector, | ||||
| conversationVariables, | |||||
| availableNodes, | availableNodes, | ||||
| isChatMode, | isChatMode, | ||||
| }) | }) | ||||
| const newCondition = produce(condition, (draft) => { | const newCondition = produce(condition, (draft) => { | ||||
| draft.variable_selector = valueSelector | draft.variable_selector = valueSelector | ||||
| draft.varType = resolvedVarType | draft.varType = resolvedVarType | ||||
| draft.value = '' | |||||
| draft.value = resolvedVarType === VarType.boolean ? false : '' | |||||
| draft.comparison_operator = getOperators(resolvedVarType)[0] | draft.comparison_operator = getOperators(resolvedVarType)[0] | ||||
| setTimeout(() => setControlPromptEditorRerenderKey(Date.now())) | setTimeout(() => setControlPromptEditorRerenderKey(Date.now())) | ||||
| }) | }) | ||||
| setOpen(false) | setOpen(false) | ||||
| }, [condition, doUpdateCondition, availableNodes, isChatMode, setControlPromptEditorRerenderKey]) | }, [condition, doUpdateCondition, availableNodes, isChatMode, setControlPromptEditorRerenderKey]) | ||||
| const showBooleanInput = useMemo(() => { | |||||
| if(condition.varType === VarType.boolean) | |||||
| return true | |||||
| // eslint-disable-next-line sonarjs/prefer-single-boolean-return | |||||
| if(condition.varType === VarType.arrayBoolean && [ComparisonOperator.contains, ComparisonOperator.notContains].includes(condition.comparison_operator!)) | |||||
| return true | |||||
| return false | |||||
| }, [condition]) | |||||
| return ( | return ( | ||||
| <div className={cn('mb-1 flex last-of-type:mb-0', className)}> | <div className={cn('mb-1 flex last-of-type:mb-0', className)}> | ||||
| <div className={cn( | <div className={cn( | ||||
| /> | /> | ||||
| </div> | </div> | ||||
| { | { | ||||
| !comparisonOperatorNotRequireValue(condition.comparison_operator) && !isNotInput && condition.varType !== VarType.number && ( | |||||
| !comparisonOperatorNotRequireValue(condition.comparison_operator) && !isNotInput && condition.varType !== VarType.number && !showBooleanInput && ( | |||||
| <div className='max-h-[100px] overflow-y-auto border-t border-t-divider-subtle px-2 py-1'> | <div className='max-h-[100px] overflow-y-auto border-t border-t-divider-subtle px-2 py-1'> | ||||
| <ConditionInput | <ConditionInput | ||||
| disabled={disabled} | disabled={disabled} | ||||
| </div> | </div> | ||||
| ) | ) | ||||
| } | } | ||||
| { | |||||
| !comparisonOperatorNotRequireValue(condition.comparison_operator) && !isNotInput && showBooleanInput && ( | |||||
| <div className='p-1'> | |||||
| <BoolValue | |||||
| value={condition.value as boolean} | |||||
| onChange={handleUpdateConditionValue} | |||||
| /> | |||||
| </div> | |||||
| ) | |||||
| } | |||||
| { | { | ||||
| !comparisonOperatorNotRequireValue(condition.comparison_operator) && !isNotInput && condition.varType === VarType.number && ( | !comparisonOperatorNotRequireValue(condition.comparison_operator) && !isNotInput && condition.varType === VarType.number && ( | ||||
| <div className='border-t border-t-divider-subtle px-2 py-1 pt-[3px]'> | <div className='border-t border-t-divider-subtle px-2 py-1 pt-[3px]'> |
| variableSelector: string[] | variableSelector: string[] | ||||
| labelName?: string | labelName?: string | ||||
| operator: ComparisonOperator | operator: ComparisonOperator | ||||
| value: string | string[] | |||||
| value: string | string[] | boolean | |||||
| } | } | ||||
| const ConditionValue = ({ | const ConditionValue = ({ | ||||
| variableSelector, | variableSelector, | ||||
| if (Array.isArray(value)) // transfer method | if (Array.isArray(value)) // transfer method | ||||
| return value[0] | return value[0] | ||||
| if(value === true || value === false) | |||||
| return value ? 'True' : 'False' | |||||
| return value.replace(/{{#([^#]*)#}}/g, (a, b) => { | return value.replace(/{{#([^#]*)#}}/g, (a, b) => { | ||||
| const arr: string[] = b.split('.') | const arr: string[] = b.split('.') | ||||
| if (isSystemVar(arr)) | if (isSystemVar(arr)) |
| import { BlockEnum, type NodeDefault } from '../../types' | |||||
| import { BlockEnum, type NodeDefault, VarType } from '../../types' | |||||
| import { type IfElseNodeType, LogicalOperator } from './types' | import { type IfElseNodeType, LogicalOperator } from './types' | ||||
| import { isEmptyRelatedOperator } from './utils' | import { isEmptyRelatedOperator } from './utils' | ||||
| import { ALL_CHAT_AVAILABLE_BLOCKS, ALL_COMPLETION_AVAILABLE_BLOCKS } from '@/app/components/workflow/blocks' | import { ALL_CHAT_AVAILABLE_BLOCKS, ALL_COMPLETION_AVAILABLE_BLOCKS } from '@/app/components/workflow/blocks' | ||||
| if (isEmptyRelatedOperator(c.comparison_operator!)) | if (isEmptyRelatedOperator(c.comparison_operator!)) | ||||
| return true | return true | ||||
| return !!c.value | |||||
| return (c.varType === VarType.boolean || c.varType === VarType.arrayBoolean) ? c.value === undefined : !!c.value | |||||
| }) | }) | ||||
| if (!isSet) | if (!isSet) | ||||
| errorMessages = t(`${i18nPrefix}.fieldRequired`, { field: t(`${i18nPrefix}.fields.variableValue`) }) | errorMessages = t(`${i18nPrefix}.fieldRequired`, { field: t(`${i18nPrefix}.fields.variableValue`) }) | ||||
| } | } | ||||
| else { | else { | ||||
| if (!isEmptyRelatedOperator(condition.comparison_operator!) && !condition.value) | |||||
| if (!isEmptyRelatedOperator(condition.comparison_operator!) && ((condition.varType === VarType.boolean || condition.varType === VarType.arrayBoolean) ? condition.value === undefined : !condition.value)) | |||||
| errorMessages = t(`${i18nPrefix}.fieldRequired`, { field: t(`${i18nPrefix}.fields.variableValue`) }) | errorMessages = t(`${i18nPrefix}.fieldRequired`, { field: t(`${i18nPrefix}.fields.variableValue`) }) | ||||
| } | } | ||||
| } | } |
| import type { Condition, IfElseNodeType } from './types' | import type { Condition, IfElseNodeType } from './types' | ||||
| import ConditionValue from './components/condition-value' | import ConditionValue from './components/condition-value' | ||||
| import ConditionFilesListValue from './components/condition-files-list-value' | import ConditionFilesListValue from './components/condition-files-list-value' | ||||
| import { VarType } from '../../types' | |||||
| const i18nPrefix = 'workflow.nodes.ifElse' | const i18nPrefix = 'workflow.nodes.ifElse' | ||||
| const IfElseNode: FC<NodeProps<IfElseNodeType>> = (props) => { | const IfElseNode: FC<NodeProps<IfElseNodeType>> = (props) => { | ||||
| if (!c.comparison_operator) | if (!c.comparison_operator) | ||||
| return false | return false | ||||
| if (isEmptyRelatedOperator(c.comparison_operator!)) | |||||
| return true | |||||
| return !!c.value | |||||
| return (c.varType === VarType.boolean || c.varType === VarType.arrayBoolean) ? true : !!c.value | |||||
| }) | }) | ||||
| return isSet | return isSet | ||||
| } | } | ||||
| else { | else { | ||||
| if (isEmptyRelatedOperator(condition.comparison_operator!)) | if (isEmptyRelatedOperator(condition.comparison_operator!)) | ||||
| return true | return true | ||||
| return !!condition.value | |||||
| return (condition.varType === VarType.boolean || condition.varType === VarType.arrayBoolean) ? true : !!condition.value | |||||
| } | } | ||||
| }, []) | }, []) | ||||
| const conditionNotSet = (<div className='flex h-6 items-center space-x-1 rounded-md bg-workflow-block-parma-bg px-1 text-xs font-normal text-text-secondary'> | const conditionNotSet = (<div className='flex h-6 items-center space-x-1 rounded-md bg-workflow-block-parma-bg px-1 text-xs font-normal text-text-secondary'> | ||||
| <ConditionValue | <ConditionValue | ||||
| variableSelector={condition.variable_selector!} | variableSelector={condition.variable_selector!} | ||||
| operator={condition.comparison_operator!} | operator={condition.comparison_operator!} | ||||
| value={condition.value} | |||||
| value={condition.varType === VarType.boolean ? (!condition.value ? 'False' : condition.value) : condition.value} | |||||
| /> | /> | ||||
| ) | ) | ||||
| variable_selector?: ValueSelector | variable_selector?: ValueSelector | ||||
| key?: string // sub variable key | key?: string // sub variable key | ||||
| comparison_operator?: ComparisonOperator | comparison_operator?: ComparisonOperator | ||||
| value: string | string[] | |||||
| value: string | string[] | boolean | |||||
| numberVarType?: NumberVarType | numberVarType?: NumberVarType | ||||
| sub_variable_condition?: CaseItem | sub_variable_condition?: CaseItem | ||||
| } | } |
| varType: varItem.type, | varType: varItem.type, | ||||
| variable_selector: valueSelector, | variable_selector: valueSelector, | ||||
| comparison_operator: getOperators(varItem.type, getIsVarFileAttribute(valueSelector) ? { key: valueSelector.slice(-1)[0] } : undefined)[0], | comparison_operator: getOperators(varItem.type, getIsVarFileAttribute(valueSelector) ? { key: valueSelector.slice(-1)[0] } : undefined)[0], | ||||
| value: '', | |||||
| value: (varItem.type === VarType.boolean || varItem.type === VarType.arrayBoolean) ? false : '', | |||||
| }) | }) | ||||
| } | } | ||||
| }) | }) |
| ComparisonOperator.empty, | ComparisonOperator.empty, | ||||
| ComparisonOperator.notEmpty, | ComparisonOperator.notEmpty, | ||||
| ] | ] | ||||
| case VarType.boolean: | |||||
| return [ | |||||
| ComparisonOperator.is, | |||||
| ComparisonOperator.isNot, | |||||
| ] | |||||
| case VarType.file: | case VarType.file: | ||||
| return [ | return [ | ||||
| ComparisonOperator.exists, | ComparisonOperator.exists, | ||||
| ] | ] | ||||
| case VarType.arrayString: | case VarType.arrayString: | ||||
| case VarType.arrayNumber: | case VarType.arrayNumber: | ||||
| case VarType.arrayBoolean: | |||||
| return [ | return [ | ||||
| ComparisonOperator.contains, | ComparisonOperator.contains, | ||||
| ComparisonOperator.notContains, | ComparisonOperator.notContains, |
| const { inputs, setInputs } = useNodeCrud<IterationNodeType>(id, payload) | const { inputs, setInputs } = useNodeCrud<IterationNodeType>(id, payload) | ||||
| const filterInputVar = useCallback((varPayload: Var) => { | const filterInputVar = useCallback((varPayload: Var) => { | ||||
| return [VarType.array, VarType.arrayString, VarType.arrayNumber, VarType.arrayObject, VarType.arrayFile].includes(varPayload.type) | |||||
| return [VarType.array, VarType.arrayString, VarType.arrayBoolean, VarType.arrayNumber, VarType.arrayObject, VarType.arrayFile].includes(varPayload.type) | |||||
| }, []) | }, []) | ||||
| const handleInputChange = useCallback((input: ValueSelector | string, _varKindType: VarKindType, varInfo?: Var) => { | const handleInputChange = useCallback((input: ValueSelector | string, _varKindType: VarKindType, varInfo?: Var) => { |
| import SubVariablePicker from './sub-variable-picker' | import SubVariablePicker from './sub-variable-picker' | ||||
| import { FILE_TYPE_OPTIONS, TRANSFER_METHOD } from '@/app/components/workflow/nodes/constants' | import { FILE_TYPE_OPTIONS, TRANSFER_METHOD } from '@/app/components/workflow/nodes/constants' | ||||
| import { SimpleSelect as Select } from '@/app/components/base/select' | import { SimpleSelect as Select } from '@/app/components/base/select' | ||||
| import BoolValue from '../../../panel/chat-variable-panel/components/bool-value' | |||||
| import Input from '@/app/components/workflow/nodes/_base/components/input-support-select-var' | import Input from '@/app/components/workflow/nodes/_base/components/input-support-select-var' | ||||
| import useAvailableVarList from '@/app/components/workflow/nodes/_base/hooks/use-available-var-list' | import useAvailableVarList from '@/app/components/workflow/nodes/_base/hooks/use-available-var-list' | ||||
| import cn from '@/utils/classnames' | import cn from '@/utils/classnames' | ||||
| type Props = { | type Props = { | ||||
| condition: Condition | condition: Condition | ||||
| onChange: (condition: Condition) => void | |||||
| varType: VarType | varType: VarType | ||||
| onChange: (condition: Condition) => void | |||||
| hasSubVariable: boolean | hasSubVariable: boolean | ||||
| readOnly: boolean | readOnly: boolean | ||||
| nodeId: string | nodeId: string | ||||
| const isSelect = [ComparisonOperator.in, ComparisonOperator.notIn, ComparisonOperator.allOf].includes(condition.comparison_operator) | const isSelect = [ComparisonOperator.in, ComparisonOperator.notIn, ComparisonOperator.allOf].includes(condition.comparison_operator) | ||||
| const isArrayValue = condition.key === 'transfer_method' || condition.key === 'type' | const isArrayValue = condition.key === 'transfer_method' || condition.key === 'type' | ||||
| const isBoolean = varType === VarType.boolean | |||||
| const selectOptions = useMemo(() => { | const selectOptions = useMemo(() => { | ||||
| if (isSelect) { | if (isSelect) { | ||||
| /> | /> | ||||
| ) | ) | ||||
| } | } | ||||
| else if (isBoolean) { | |||||
| inputElement = (<BoolValue | |||||
| value={condition.value as boolean} | |||||
| onChange={handleChange('value')} | |||||
| />) | |||||
| } | |||||
| else if (supportVariableInput) { | else if (supportVariableInput) { | ||||
| inputElement = ( | inputElement = ( | ||||
| <Input | <Input | ||||
| <div className='flex space-x-1'> | <div className='flex space-x-1'> | ||||
| <ConditionOperator | <ConditionOperator | ||||
| className='h-8 bg-components-input-bg-normal' | className='h-8 bg-components-input-bg-normal' | ||||
| varType={expectedVarType ?? VarType.string} | |||||
| varType={expectedVarType ?? varType ?? VarType.string} | |||||
| value={condition.comparison_operator} | value={condition.comparison_operator} | ||||
| onSelect={handleChange('comparison_operator')} | onSelect={handleChange('comparison_operator')} | ||||
| file={hasSubVariable ? { key: condition.key } : undefined} | file={hasSubVariable ? { key: condition.key } : undefined} |
| }, | }, | ||||
| checkValid(payload: ListFilterNodeType, t: any) { | checkValid(payload: ListFilterNodeType, t: any) { | ||||
| let errorMessages = '' | let errorMessages = '' | ||||
| const { variable, var_type, filter_by } = payload | |||||
| const { variable, var_type, filter_by, item_var_type } = payload | |||||
| if (!errorMessages && !variable?.length) | if (!errorMessages && !variable?.length) | ||||
| errorMessages = t(`${i18nPrefix}.fieldRequired`, { field: t('workflow.nodes.listFilter.inputVar') }) | errorMessages = t(`${i18nPrefix}.fieldRequired`, { field: t('workflow.nodes.listFilter.inputVar') }) | ||||
| if (!errorMessages && !filter_by.conditions[0]?.comparison_operator) | if (!errorMessages && !filter_by.conditions[0]?.comparison_operator) | ||||
| errorMessages = t(`${i18nPrefix}.fieldRequired`, { field: t('workflow.nodes.listFilter.filterConditionComparisonOperator') }) | errorMessages = t(`${i18nPrefix}.fieldRequired`, { field: t('workflow.nodes.listFilter.filterConditionComparisonOperator') }) | ||||
| if (!errorMessages && !comparisonOperatorNotRequireValue(filter_by.conditions[0]?.comparison_operator) && !filter_by.conditions[0]?.value) | |||||
| if (!errorMessages && !comparisonOperatorNotRequireValue(filter_by.conditions[0]?.comparison_operator) && (item_var_type === VarType.boolean ? !filter_by.conditions[0]?.value === undefined : !filter_by.conditions[0]?.value)) | |||||
| errorMessages = t(`${i18nPrefix}.fieldRequired`, { field: t('workflow.nodes.listFilter.filterConditionComparisonValue') }) | errorMessages = t(`${i18nPrefix}.fieldRequired`, { field: t('workflow.nodes.listFilter.filterConditionComparisonValue') }) | ||||
| } | } | ||||
| export type Condition = { | export type Condition = { | ||||
| key: string | key: string | ||||
| comparison_operator: ComparisonOperator | comparison_operator: ComparisonOperator | ||||
| value: string | number | string[] | |||||
| value: string | number | boolean | string[] | |||||
| } | } | ||||
| export type ListFilterNodeType = CommonNodeType & { | export type ListFilterNodeType = CommonNodeType & { |
| isChatMode, | isChatMode, | ||||
| isConstant: false, | isConstant: false, | ||||
| }) | }) | ||||
| let itemVarType = varType | |||||
| let itemVarType | |||||
| switch (varType) { | switch (varType) { | ||||
| case VarType.arrayNumber: | case VarType.arrayNumber: | ||||
| itemVarType = VarType.number | itemVarType = VarType.number | ||||
| case VarType.arrayObject: | case VarType.arrayObject: | ||||
| itemVarType = VarType.object | itemVarType = VarType.object | ||||
| break | break | ||||
| case VarType.arrayBoolean: | |||||
| itemVarType = VarType.boolean | |||||
| break | |||||
| default: | |||||
| itemVarType = varType | |||||
| } | } | ||||
| return { varType, itemVarType } | return { varType, itemVarType } | ||||
| }, [availableNodes, getCurrentVariableType, inputs.variable, isChatMode, isInIteration, iterationNode, loopNode]) | }, [availableNodes, getCurrentVariableType, inputs.variable, isChatMode, isInIteration, iterationNode, loopNode]) | ||||
| draft.filter_by.conditions = [{ | draft.filter_by.conditions = [{ | ||||
| key: (isFileArray && !draft.filter_by.conditions[0]?.key) ? 'name' : '', | key: (isFileArray && !draft.filter_by.conditions[0]?.key) ? 'name' : '', | ||||
| comparison_operator: getOperators(itemVarType, isFileArray ? { key: 'name' } : undefined)[0], | comparison_operator: getOperators(itemVarType, isFileArray ? { key: 'name' } : undefined)[0], | ||||
| value: '', | |||||
| value: itemVarType === VarType.boolean ? false : '', | |||||
| }] | }] | ||||
| if (isFileArray && draft.order_by.enabled && !draft.order_by.key) | if (isFileArray && draft.order_by.enabled && !draft.order_by.key) | ||||
| draft.order_by.key = 'name' | draft.order_by.key = 'name' | ||||
| const filterVar = useCallback((varPayload: Var) => { | const filterVar = useCallback((varPayload: Var) => { | ||||
| // Don't know the item struct of VarType.arrayObject, so not support it | // Don't know the item struct of VarType.arrayObject, so not support it | ||||
| return [VarType.arrayNumber, VarType.arrayString, VarType.arrayFile].includes(varPayload.type) | |||||
| return [VarType.arrayNumber, VarType.arrayString, VarType.arrayBoolean, VarType.arrayFile].includes(varPayload.type) | |||||
| }, []) | }, []) | ||||
| const handleFilterEnabledChange = useCallback((enabled: boolean) => { | const handleFilterEnabledChange = useCallback((enabled: boolean) => { |
| import SchemaEditor from './schema-editor' | import SchemaEditor from './schema-editor' | ||||
| import { | import { | ||||
| checkJsonSchemaDepth, | checkJsonSchemaDepth, | ||||
| convertBooleanToString, | |||||
| getValidationErrorMessage, | getValidationErrorMessage, | ||||
| jsonToSchema, | jsonToSchema, | ||||
| preValidateSchema, | preValidateSchema, | ||||
| setValidationError(`Schema exceeds maximum depth of ${JSON_SCHEMA_MAX_DEPTH}.`) | setValidationError(`Schema exceeds maximum depth of ${JSON_SCHEMA_MAX_DEPTH}.`) | ||||
| return | return | ||||
| } | } | ||||
| convertBooleanToString(schema) | |||||
| const validationErrors = validateSchemaAgainstDraft7(schema) | const validationErrors = validateSchemaAgainstDraft7(schema) | ||||
| if (validationErrors.length > 0) { | if (validationErrors.length > 0) { | ||||
| setValidationError(getValidationErrorMessage(validationErrors)) | setValidationError(getValidationErrorMessage(validationErrors)) | ||||
| setValidationError(`Schema exceeds maximum depth of ${JSON_SCHEMA_MAX_DEPTH}.`) | setValidationError(`Schema exceeds maximum depth of ${JSON_SCHEMA_MAX_DEPTH}.`) | ||||
| return | return | ||||
| } | } | ||||
| convertBooleanToString(schema) | |||||
| const validationErrors = validateSchemaAgainstDraft7(schema) | const validationErrors = validateSchemaAgainstDraft7(schema) | ||||
| if (validationErrors.length > 0) { | if (validationErrors.length > 0) { | ||||
| setValidationError(getValidationErrorMessage(validationErrors)) | setValidationError(getValidationErrorMessage(validationErrors)) |
| const TYPE_OPTIONS = [ | const TYPE_OPTIONS = [ | ||||
| { value: Type.string, text: 'string' }, | { value: Type.string, text: 'string' }, | ||||
| { value: Type.number, text: 'number' }, | { value: Type.number, text: 'number' }, | ||||
| // { value: Type.boolean, text: 'boolean' }, | |||||
| { value: Type.boolean, text: 'boolean' }, | |||||
| { value: Type.object, text: 'object' }, | { value: Type.object, text: 'object' }, | ||||
| { value: ArrayType.string, text: 'array[string]' }, | { value: ArrayType.string, text: 'array[string]' }, | ||||
| { value: ArrayType.number, text: 'array[number]' }, | { value: ArrayType.number, text: 'array[number]' }, | ||||
| // { value: ArrayType.boolean, text: 'array[boolean]' }, | |||||
| { value: ArrayType.object, text: 'array[object]' }, | { value: ArrayType.object, text: 'array[object]' }, | ||||
| ] | ] | ||||
| const MAXIMUM_DEPTH_TYPE_OPTIONS = [ | const MAXIMUM_DEPTH_TYPE_OPTIONS = [ | ||||
| { value: Type.string, text: 'string' }, | { value: Type.string, text: 'string' }, | ||||
| { value: Type.number, text: 'number' }, | { value: Type.number, text: 'number' }, | ||||
| // { value: Type.boolean, text: 'boolean' }, | |||||
| { value: Type.boolean, text: 'boolean' }, | |||||
| { value: ArrayType.string, text: 'array[string]' }, | { value: ArrayType.string, text: 'array[string]' }, | ||||
| { value: ArrayType.number, text: 'array[number]' }, | { value: ArrayType.number, text: 'array[number]' }, | ||||
| // { value: ArrayType.boolean, text: 'array[boolean]' }, | |||||
| ] | ] | ||||
| const EditCard: FC<EditCardProps> = ({ | const EditCard: FC<EditCardProps> = ({ |
| return message | return message | ||||
| } | } | ||||
| // Previous Not support boolean type, so transform boolean to string when paste it into schema editor | |||||
| export const convertBooleanToString = (schema: any) => { | export const convertBooleanToString = (schema: any) => { | ||||
| if (schema.type === Type.boolean) | if (schema.type === Type.boolean) | ||||
| schema.type = Type.string | schema.type = Type.string |
| import { SimpleSelect as Select } from '@/app/components/base/select' | import { SimpleSelect as Select } from '@/app/components/base/select' | ||||
| import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development' | import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development' | ||||
| import ConditionVarSelector from './condition-var-selector' | import ConditionVarSelector from './condition-var-selector' | ||||
| import BoolValue from '@/app/components/workflow/panel/chat-variable-panel/components/bool-value' | |||||
| const optionNameI18NPrefix = 'workflow.nodes.ifElse.optionName' | const optionNameI18NPrefix = 'workflow.nodes.ifElse.optionName' | ||||
| const isArrayValue = fileAttr?.key === 'transfer_method' || fileAttr?.key === 'type' | const isArrayValue = fileAttr?.key === 'transfer_method' || fileAttr?.key === 'type' | ||||
| const handleUpdateConditionValue = useCallback((value: string) => { | |||||
| if (value === condition.value || (isArrayValue && value === condition.value?.[0])) | |||||
| const handleUpdateConditionValue = useCallback((value: string | boolean) => { | |||||
| if (value === condition.value || (isArrayValue && value === (condition.value as string[])?.[0])) | |||||
| return | return | ||||
| const newCondition = { | const newCondition = { | ||||
| ...condition, | ...condition, | ||||
| value: isArrayValue ? [value] : value, | |||||
| value: isArrayValue ? [value as string] : value, | |||||
| } | } | ||||
| doUpdateCondition(newCondition) | doUpdateCondition(newCondition) | ||||
| }, [condition, doUpdateCondition, isArrayValue]) | }, [condition, doUpdateCondition, isArrayValue]) | ||||
| /> | /> | ||||
| </div> | </div> | ||||
| { | { | ||||
| !comparisonOperatorNotRequireValue(condition.comparison_operator) && !isNotInput && condition.varType !== VarType.number && ( | |||||
| !comparisonOperatorNotRequireValue(condition.comparison_operator) && !isNotInput && condition.varType !== VarType.number && condition.varType !== VarType.boolean && ( | |||||
| <div className='max-h-[100px] overflow-y-auto border-t border-t-divider-subtle px-2 py-1'> | <div className='max-h-[100px] overflow-y-auto border-t border-t-divider-subtle px-2 py-1'> | ||||
| <ConditionInput | <ConditionInput | ||||
| disabled={disabled} | disabled={disabled} | ||||
| </div> | </div> | ||||
| ) | ) | ||||
| } | } | ||||
| {!comparisonOperatorNotRequireValue(condition.comparison_operator) && condition.varType === VarType.boolean | |||||
| && <div className='p-1'> | |||||
| <BoolValue | |||||
| value={condition.value as boolean} | |||||
| onChange={handleUpdateConditionValue} | |||||
| /> | |||||
| </div> | |||||
| } | |||||
| { | { | ||||
| !comparisonOperatorNotRequireValue(condition.comparison_operator) && !isNotInput && condition.varType === VarType.number && ( | !comparisonOperatorNotRequireValue(condition.comparison_operator) && !isNotInput && condition.varType === VarType.number && ( | ||||
| <div className='border-t border-t-divider-subtle px-2 py-1 pt-[3px]'> | <div className='border-t border-t-divider-subtle px-2 py-1 pt-[3px]'> |
| ValueType, | ValueType, | ||||
| VarType, | VarType, | ||||
| } from '@/app/components/workflow/types' | } from '@/app/components/workflow/types' | ||||
| import BoolValue from '@/app/components/workflow/panel/chat-variable-panel/components/bool-value' | |||||
| const objectPlaceholder = `# example | |||||
| # { | |||||
| # "name": "ray", | |||||
| # "age": 20 | |||||
| # }` | |||||
| const arrayStringPlaceholder = `# example | |||||
| # [ | |||||
| # "value1", | |||||
| # "value2" | |||||
| # ]` | |||||
| const arrayNumberPlaceholder = `# example | |||||
| # [ | |||||
| # 100, | |||||
| # 200 | |||||
| # ]` | |||||
| const arrayObjectPlaceholder = `# example | |||||
| # [ | |||||
| # { | |||||
| # "name": "ray", | |||||
| # "age": 20 | |||||
| # }, | |||||
| # { | |||||
| # "name": "lily", | |||||
| # "age": 18 | |||||
| # } | |||||
| # ]` | |||||
| import { | |||||
| arrayBoolPlaceholder, | |||||
| arrayNumberPlaceholder, | |||||
| arrayObjectPlaceholder, | |||||
| arrayStringPlaceholder, | |||||
| objectPlaceholder, | |||||
| } from '@/app/components/workflow/panel/chat-variable-panel/utils' | |||||
| import ArrayBoolList from '@/app/components/workflow/panel/chat-variable-panel/components/array-bool-list' | |||||
| type FormItemProps = { | type FormItemProps = { | ||||
| nodeId: string | nodeId: string | ||||
| return arrayNumberPlaceholder | return arrayNumberPlaceholder | ||||
| if (var_type === VarType.arrayObject) | if (var_type === VarType.arrayObject) | ||||
| return arrayObjectPlaceholder | return arrayObjectPlaceholder | ||||
| if (var_type === VarType.arrayBoolean) | |||||
| return arrayBoolPlaceholder | |||||
| return objectPlaceholder | return objectPlaceholder | ||||
| }, [var_type]) | }, [var_type]) | ||||
| /> | /> | ||||
| ) | ) | ||||
| } | } | ||||
| { | |||||
| value_type === ValueType.constant && var_type === VarType.boolean && ( | |||||
| <BoolValue | |||||
| value={value} | |||||
| onChange={handleChange} | |||||
| /> | |||||
| ) | |||||
| } | |||||
| { | { | ||||
| value_type === ValueType.constant | value_type === ValueType.constant | ||||
| && (var_type === VarType.object || var_type === VarType.arrayString || var_type === VarType.arrayNumber || var_type === VarType.arrayObject) | && (var_type === VarType.object || var_type === VarType.arrayString || var_type === VarType.arrayNumber || var_type === VarType.arrayObject) | ||||
| </div> | </div> | ||||
| ) | ) | ||||
| } | } | ||||
| { | |||||
| value_type === ValueType.constant && var_type === VarType.arrayBoolean && ( | |||||
| <ArrayBoolList | |||||
| className='mt-2' | |||||
| list={value || [false]} | |||||
| onChange={handleChange} | |||||
| /> | |||||
| ) | |||||
| } | |||||
| </div> | </div> | ||||
| ) | ) | ||||
| } | } |