This commit is contained in:
Typer_Body
2026-05-08 00:56:27 +08:00
parent eb9f38b102
commit 75fdfe6806
51 changed files with 1585 additions and 1643 deletions

View File

@@ -1,4 +1,5 @@
"""Workflow node base class and decorators"""
from __future__ import annotations
import abc
@@ -13,35 +14,37 @@ if TYPE_CHECKING:
class NodePort(pydantic.BaseModel):
"""Node port definition"""
name: str
type: str = "any" # any, string, number, boolean, object, array
description: str = ""
type: str = 'any' # any, string, number, boolean, object, array
description: str = ''
required: bool = True
class NodeConfig(pydantic.BaseModel):
"""Node configuration field definition"""
name: str
type: str # string, integer, number, boolean, select, json, secret, etc.
required: bool = False
default: Any = None
description: str = ""
description: str = ''
options: Optional[list[str]] = None # For select type
# Validation
min_value: Optional[float] = None
max_value: Optional[float] = None
min_length: Optional[int] = None
max_length: Optional[int] = None
pattern: Optional[str] = None # Regex pattern
# UI hints
placeholder: str = ""
placeholder: str = ''
show_if: Optional[dict] = None # Conditional display
# Pipeline config source (for reusing Pipeline config metadata)
pipeline_config_source: Optional[str] = None # e.g., "pipeline:trigger"
# i18n support for label
label: Optional[dict[str, str]] = None # e.g., {"en_US": "Name", "zh_Hans": "名称"}
label_zh: Optional[str] = None # Chinese label
@@ -50,91 +53,87 @@ class NodeConfig(pydantic.BaseModel):
class WorkflowNode(abc.ABC):
"""Base class for all workflow nodes"""
# Node metadata
type_name: str = ""
name: str = ""
description: str = ""
category: str = "misc" # trigger, process, control, action, integration
icon: str = ""
type_name: str = ''
name: str = ''
description: str = ''
category: str = 'misc' # trigger, process, control, action, integration
icon: str = ''
# Port definitions
inputs: list[NodePort] = []
outputs: list[NodePort] = []
# Configuration schema
config_schema: list[NodeConfig] = []
# Pipeline config reuse
config_schema_source: Optional[str] = None # e.g., "pipeline:ai"
config_stages: list[str] = [] # Specific stages to reuse
def __init__(self, node_id: str, config: dict[str, Any], ap: Optional['app.Application'] = None):
"""Initialize node with ID and configuration"""
self.node_id = node_id
self.config = config
self.ap = ap # Reference to the application instance for accessing services
@abc.abstractmethod
async def execute(
self,
inputs: dict[str, Any],
context: ExecutionContext
) -> dict[str, Any]:
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
"""
Execute the node logic.
Args:
inputs: Input data from connected nodes
context: Execution context with workflow state
Returns:
Dictionary of output values
"""
pass
async def validate_inputs(self, inputs: dict[str, Any]) -> list[str]:
"""
Validate input data against port definitions.
Returns:
List of validation error messages (empty if valid)
"""
errors = []
for port in self.inputs:
if port.required and port.name not in inputs:
errors.append(f"Missing required input: {port.name}")
errors.append(f'Missing required input: {port.name}')
return errors
async def validate_config(self) -> list[str]:
"""
Validate node configuration.
Returns:
List of validation error messages (empty if valid)
"""
errors = []
for cfg in self.config_schema:
if cfg.required and cfg.name not in self.config:
errors.append(f"Missing required config: {cfg.name}")
errors.append(f'Missing required config: {cfg.name}')
elif cfg.name in self.config:
value = self.config[cfg.name]
# Type validation
if cfg.type == "integer" and not isinstance(value, int):
errors.append(f"Config {cfg.name} must be an integer")
elif cfg.type == "number" and not isinstance(value, (int, float)):
errors.append(f"Config {cfg.name} must be a number")
elif cfg.type == "boolean" and not isinstance(value, bool):
errors.append(f"Config {cfg.name} must be a boolean")
if cfg.type == 'integer' and not isinstance(value, int):
errors.append(f'Config {cfg.name} must be an integer')
elif cfg.type == 'number' and not isinstance(value, (int, float)):
errors.append(f'Config {cfg.name} must be a number')
elif cfg.type == 'boolean' and not isinstance(value, bool):
errors.append(f'Config {cfg.name} must be a boolean')
# Range validation
if cfg.min_value is not None and isinstance(value, (int, float)):
if value < cfg.min_value:
errors.append(f"Config {cfg.name} must be >= {cfg.min_value}")
errors.append(f'Config {cfg.name} must be >= {cfg.min_value}')
if cfg.max_value is not None and isinstance(value, (int, float)):
if value > cfg.max_value:
errors.append(f"Config {cfg.name} must be <= {cfg.max_value}")
errors.append(f'Config {cfg.name} must be <= {cfg.max_value}')
return errors
# Type mapping from backend to frontend DynamicFormItemType
_TYPE_MAP = {
'string': 'string',
@@ -160,26 +159,26 @@ class WorkflowNode(abc.ABC):
def get_config(self, key: str, default: Any = None) -> Any:
"""Get configuration value with default"""
return self.config.get(key, default)
@classmethod
def _config_to_schema_item(cls, cfg: NodeConfig) -> dict[str, Any]:
"""Convert a NodeConfig to frontend-compatible schema item"""
# Map type to frontend type
frontend_type = cls._TYPE_MAP.get(cfg.type, 'string')
# Build i18n label from name
label = {
'zh_Hans': cfg.name,
'en_US': cfg.name,
}
# Build i18n description
desc = cfg.description or ''
description = {
'zh_Hans': desc,
'en_US': desc,
}
result = {
'id': cfg.name,
'name': cfg.name,
@@ -189,11 +188,11 @@ class WorkflowNode(abc.ABC):
'required': cfg.required,
'default': cfg.default,
}
# Add placeholder if present
if cfg.placeholder:
result['placeholder'] = cfg.placeholder
# Add options if present
if cfg.options:
result['options'] = [
@@ -202,22 +201,22 @@ class WorkflowNode(abc.ABC):
'label': {
'zh_Hans': opt,
'en_US': opt,
}
},
}
for opt in cfg.options
]
# Add show_if if present
if cfg.show_if:
result['show_if'] = cfg.show_if
return result
@classmethod
def to_schema(cls) -> dict[str, Any]:
"""
Convert node class to JSON schema for frontend.
Returns:
Node schema dictionary
"""
@@ -235,7 +234,7 @@ class WorkflowNode(abc.ABC):
'zh_Hans': desc_zh,
'en_US': desc_en,
}
return {
'type': f'{cls.category}.{cls.type_name}',
'name': cls.name,
@@ -258,16 +257,18 @@ _pending_registrations: list[tuple[str, type[WorkflowNode]]] = []
def workflow_node(type_name: str) -> Callable[[type[WorkflowNode]], type[WorkflowNode]]:
"""
Decorator to register a workflow node type.
Usage:
@workflow_node('llm_call')
class LLMCallNode(WorkflowNode):
...
"""
def decorator(cls: type[WorkflowNode]) -> type[WorkflowNode]:
cls.type_name = type_name
_pending_registrations.append((type_name, cls))
return cls
return decorator