This commit is contained in:
Typer_Body
2026-05-18 01:47:13 +08:00
parent 27c0d344bf
commit bb7db53447
89 changed files with 1202 additions and 6883 deletions

View File

@@ -5,83 +5,38 @@ from __future__ import annotations
import abc
from typing import Any, Callable, Optional, TYPE_CHECKING
import pydantic
if TYPE_CHECKING:
from .entities import ExecutionContext
from ..core import app
class NodePort(pydantic.BaseModel):
"""Node port definition"""
name: 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 = ''
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 = ''
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
label_en: Optional[str] = None # English label
class WorkflowNode(abc.ABC):
"""Base class for all workflow nodes"""
"""Base class for all workflow nodes.
# Node metadata
Node metadata (inputs, outputs, config schema, label, icon, etc.) is
defined exclusively in YAML files under templates/metadata/nodes/.
Python subclasses only provide execution logic and runtime behaviour.
"""
# Set by @workflow_node decorator
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] = []
# Category is kept as a fallback for registry when YAML is missing
category: str = 'misc'
# 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
# Pipeline config reuse (referenced by registry merge logic)
config_schema_source: Optional[str] = None
config_stages: list[str] = []
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
self.ap = ap
@abc.abstractmethod
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
"""
Execute the node logic.
"""Execute the node logic.
Args:
inputs: Input data from connected nodes
@@ -92,171 +47,87 @@ class WorkflowNode(abc.ABC):
"""
pass
# ------------------------------------------------------------------
# Validation helpers — metadata is resolved from the registry at
# runtime so that YAML remains the single source of truth.
# ------------------------------------------------------------------
async def validate_inputs(self, inputs: dict[str, Any]) -> list[str]:
"""
Validate input data against port definitions.
"""Validate input data against YAML 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}')
metadata = self._get_metadata()
if metadata is None:
return []
errors: list[str] = []
for port in metadata.get('inputs', []):
if port.get('required', True) and port.get('name') and port['name'] not in inputs:
errors.append(f"Missing required input: {port['name']}")
return errors
async def validate_config(self) -> list[str]:
"""
Validate node configuration.
"""Validate node configuration against YAML config schema.
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}')
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')
# 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}')
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}')
return errors
metadata = self._get_metadata()
if metadata is None:
return []
# Type mapping from backend to frontend DynamicFormItemType
_TYPE_MAP = {
'string': 'string',
'integer': 'integer',
'number': 'float',
'boolean': 'boolean',
'select': 'select',
'json': 'text',
'textarea': 'text',
'secret': 'secret',
'llm-model-selector': 'llm-model-selector',
'embedding-model-selector': 'embedding-model-selector',
'rerank-model-selector': 'rerank-model-selector',
'pipeline-selector': 'pipeline-selector',
'knowledge-base-selector': 'knowledge-base-selector',
'knowledge-base-multi-selector': 'knowledge-base-multi-selector',
'bot-selector': 'bot-selector',
'tools-selector': 'tools-selector',
'model-fallback-selector': 'model-fallback-selector',
'prompt-editor': 'prompt-editor',
}
errors: list[str] = []
for cfg in metadata.get('config', []):
name = cfg.get('name', '')
if not name:
continue
required = cfg.get('required', False)
cfg_type = cfg.get('type', 'string')
if required and name not in self.config:
errors.append(f'Missing required config: {name}')
elif name in self.config:
value = self.config[name]
# Type validation
if cfg_type == 'integer' and not isinstance(value, int):
errors.append(f'Config {name} must be an integer')
elif cfg_type == 'number' and not isinstance(value, (int, float)):
errors.append(f'Config {name} must be a number')
elif cfg_type == 'boolean' and not isinstance(value, bool):
errors.append(f'Config {name} must be a boolean')
# Range validation
min_val = cfg.get('min_value')
max_val = cfg.get('max_value')
if min_val is not None and isinstance(value, (int, float)):
if value < min_val:
errors.append(f'Config {name} must be >= {min_val}')
if max_val is not None and isinstance(value, (int, float)):
if value > max_val:
errors.append(f'Config {name} must be <= {max_val}')
return errors
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,
'type': frontend_type,
'label': label,
'description': description,
'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'] = [
{
'name': opt,
'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
"""
# Build label dict for i18n support
# Use underscore format to match frontend I18nObject interface
name_zh = getattr(cls, 'name_zh', None) or cls.name
name_en = getattr(cls, 'name_en', None) or cls.name
desc_zh = getattr(cls, 'description_zh', None) or cls.description
desc_en = getattr(cls, 'description_en', None) or cls.description
label = {
'zh_Hans': name_zh,
'en_US': name_en,
}
description = {
'zh_Hans': desc_zh,
'en_US': desc_en,
}
return {
'type': f'{cls.category}.{cls.type_name}',
'name': cls.name,
'label': label,
'description': description,
'category': cls.category,
'icon': cls.icon,
'inputs': [port.model_dump() for port in cls.inputs],
'outputs': [port.model_dump() for port in cls.outputs],
'config_schema': [cls._config_to_schema_item(cfg) for cfg in cls.config_schema],
'config_schema_source': cls.config_schema_source,
'config_stages': cls.config_stages,
}
def _get_metadata(self) -> Optional[dict[str, Any]]:
"""Retrieve YAML metadata for this node from the registry."""
from .registry import NodeTypeRegistry
registry = NodeTypeRegistry.instance()
return registry.get_metadata(self.type_name)
# Registry for node type decorator
# ------------------------------------------------------------------
# Decorator and pending registration helpers
# ------------------------------------------------------------------
_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.
"""Decorator to register a workflow node type.
Usage:
@workflow_node('llm_call')