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

@@ -567,58 +567,30 @@ class WorkflowService:
return result
def _enrich_node_type_configs(self, node_types: list[dict]) -> list[dict]:
"""Enrich node schemas with node YAML config metadata if available"""
# Get workflow node configs loaded from YAML files
workflow_node_configs = getattr(self.ap, 'workflow_node_configs', {})
"""Enrich node schemas with pipeline config metadata if available.
Dedicated workflow node YAML metadata is already applied by
NodeTypeRegistry before this method runs. This method only keeps the
older pipeline metadata reuse path for nodes that explicitly request it.
"""
for node_schema in node_types:
node_type = node_schema.get('type', '')
node_schema.setdefault('config_schema', [])
# First, try to load config from dedicated node YAML file
if node_type in workflow_node_configs:
node_config = workflow_node_configs[node_type]
# Enrich inputs from YAML if Python class has empty inputs
yaml_inputs = node_config.get('inputs', [])
if yaml_inputs and not node_schema.get('inputs'):
node_schema['inputs'] = [self._normalize_port_item(inp) for inp in yaml_inputs]
# Enrich outputs from YAML if Python class has empty outputs
yaml_outputs = node_config.get('outputs', [])
if yaml_outputs and not node_schema.get('outputs'):
node_schema['outputs'] = [self._normalize_port_item(out) for out in yaml_outputs]
# Enrich config_schema from YAML
yaml_configs = node_config.get('config', [])
# Normalize and add YAML configs to config_schema
existing_names = {cfg.get('name') for cfg in node_schema.get('config_schema', [])}
for cfg in yaml_configs:
if cfg.get('name') not in existing_names:
normalized_cfg = self._normalize_config_item(cfg)
node_schema['config_schema'].append(normalized_cfg)
existing_names.add(cfg.get('name'))
# Second, try to load from pipeline config metadata (for backward compatibility)
config_schema_source = node_schema.get('config_schema_source')
config_stages = node_schema.get('config_stages', [])
if config_schema_source and config_stages:
# Get pipeline config metadata
pipeline_meta = self._get_pipeline_config_meta(config_schema_source)
if pipeline_meta:
# Extract config items from specified stages
enriched_configs = []
for stage_name in config_stages:
stage_data = self._find_stage_in_pipeline(pipeline_meta, stage_name)
if stage_data and 'config' in stage_data:
enriched_configs.extend(stage_data['config'])
# Merge with existing config_schema (avoid duplicates by name)
existing_names = {cfg.get('name') for cfg in node_schema.get('config_schema', [])}
for cfg in enriched_configs:
if cfg.get('name') not in existing_names:
# Normalize config item format for frontend compatibility
normalized_cfg = self._normalize_config_item(cfg)
node_schema['config_schema'].append(normalized_cfg)
existing_names.add(cfg.get('name'))

View File

@@ -222,51 +222,35 @@ class LoadConfigStage(stage.BootingStage):
ap.pipeline_config_meta_ai = await load_resource_yaml_template_data('metadata/pipeline/ai.yaml')
ap.pipeline_config_meta_output = await load_resource_yaml_template_data('metadata/pipeline/output.yaml')
# Load workflow node configurations from YAML files
ap.workflow_node_configs = {}
node_config_files = [
# Trigger nodes
'metadata/nodes/message_trigger.yaml',
'metadata/nodes/cron_trigger.yaml',
'metadata/nodes/webhook_trigger.yaml',
'metadata/nodes/event_trigger.yaml',
# AI/Process nodes
'metadata/nodes/llm_call.yaml',
'metadata/nodes/question_classifier.yaml',
'metadata/nodes/parameter_extractor.yaml',
'metadata/nodes/knowledge_retrieval.yaml',
'metadata/nodes/code_executor.yaml',
'metadata/nodes/data_transform.yaml',
# Control nodes
'metadata/nodes/condition.yaml',
'metadata/nodes/switch.yaml',
'metadata/nodes/loop.yaml',
'metadata/nodes/parallel.yaml',
'metadata/nodes/wait.yaml',
'metadata/nodes/end.yaml',
# Action nodes
'metadata/nodes/send_message.yaml',
'metadata/nodes/http_request.yaml',
# Integration nodes - Data & Tools
'metadata/nodes/database_query.yaml',
'metadata/nodes/redis_operation.yaml',
'metadata/nodes/mcp_tool.yaml',
'metadata/nodes/memory_store.yaml',
# Integration nodes - External services
'metadata/nodes/dify_workflow.yaml',
'metadata/nodes/dify_knowledge_query.yaml',
'metadata/nodes/n8n_workflow.yaml',
'metadata/nodes/langflow_flow.yaml',
'metadata/nodes/coze_bot.yaml',
]
for config_file in node_config_files:
try:
node_config = await load_resource_yaml_template_data(config_file)
node_name = node_config.get('name')
node_category = node_config.get('category', 'misc')
if node_name:
# Use category.name format to match node type format (e.g., integration.coze_bot)
full_type = f'{node_category}.{node_name}'
ap.workflow_node_configs[full_type] = node_config
except Exception as e:
print(f'Failed to load node config {config_file}: {e}')
# Load workflow node metadata from YAML files. YAML is the source of
# truth for workflow editor metadata; Python classes provide execution
# logic and are bound through the registry.
from langbot.pkg.workflow.metadata import NodeMetadataLoader
from langbot.pkg.workflow.registry import NodeTypeRegistry
workflow_metadata_loader = NodeMetadataLoader()
workflow_node_count = await workflow_metadata_loader.load_core_metadata()
ap.workflow_node_configs = workflow_metadata_loader.get_all_metadata()
ap.workflow_node_metadata_loader = workflow_metadata_loader
workflow_registry = NodeTypeRegistry.instance()
for node_config in ap.workflow_node_configs.values():
workflow_registry.register_metadata(node_config, source=node_config.get('_source', 'core'))
# Import node modules after metadata registration so decorators can bind
# implementations to YAML-defined canonical node types.
from langbot.pkg.workflow import nodes as workflow_nodes # noqa: F401
workflow_registry.process_pending_registrations()
workflow_load_errors = workflow_metadata_loader.get_load_errors()
if workflow_load_errors:
print(f'Workflow node metadata load errors: {len(workflow_load_errors)}')
for error in workflow_load_errors:
print(f" - {error.get('file')}: {error.get('error')}")
print(
f'Loaded {workflow_node_count} workflow node metadata files; '
f'registered {workflow_registry.metadata_count()} metadata definitions, '
f'{workflow_registry.count()} node types'
)

View File

@@ -21,12 +21,33 @@ from .entities import (
NodeStatus,
)
from .node import WorkflowNode, NodePort, NodeConfig, workflow_node
from .registry import NodeTypeRegistry
from .executor import WorkflowExecutor
from importlib import import_module
from typing import Any
# Import nodes module to trigger node registration
from . import nodes as nodes
from .node import WorkflowNode, workflow_node
def __getattr__(name: str) -> Any:
"""Lazily expose heavier workflow modules.
Loading workflow metadata should not import the executor or node modules as a
side effect. Node implementations are imported explicitly during boot after
YAML metadata has been registered.
"""
if name == 'NodeTypeRegistry':
from .registry import NodeTypeRegistry
return NodeTypeRegistry
if name == 'WorkflowExecutor':
from .executor import WorkflowExecutor
return WorkflowExecutor
if name == 'nodes':
return import_module('.nodes', __name__)
raise AttributeError(f'module {__name__!r} has no attribute {name!r}')
__all__ = [
# Entities
@@ -43,8 +64,6 @@ __all__ = [
'NodeStatus',
# Node
'WorkflowNode',
'NodePort',
'NodeConfig',
'workflow_node',
# Registry
'NodeTypeRegistry',

View File

@@ -0,0 +1,283 @@
"""Workflow node metadata loading and validation.
This module makes YAML files under ``templates/metadata/nodes`` the backend
source of truth for workflow node metadata. Python node classes still provide
execution logic, but UI-facing metadata is loaded from YAML.
"""
from __future__ import annotations
import copy
import logging
from importlib import resources
from pathlib import Path
from typing import Any, Iterable, Optional
import yaml
logger = logging.getLogger(__name__)
class MetadataLoadError(Exception):
"""Raised when a workflow node metadata file cannot be loaded."""
class MetadataValidationError(Exception):
"""Raised when workflow node metadata does not match the expected shape."""
class NodeMetadataValidator:
"""Validate workflow node metadata loaded from YAML files.
The validator is intentionally strict about the structural fields that the
editor needs, but tolerant of legacy YAML details such as missing top-level
``label`` or additional frontend field types.
"""
REQUIRED_FIELDS = ('name', 'category', 'inputs', 'outputs', 'config')
VALID_CATEGORIES = {'trigger', 'process', 'control', 'action', 'integration', 'misc'}
VALID_PORT_TYPES = {'any', 'string', 'number', 'integer', 'boolean', 'object', 'array', 'datetime', 'null'}
VALID_CONFIG_TYPES = {
'string',
'integer',
'number',
'float',
'boolean',
'select',
'json',
'textarea',
'text',
'secret',
'array[string]',
'file',
'array[file]',
'llm-model-selector',
'embedding-model-selector',
'rerank-model-selector',
'pipeline-selector',
'knowledge-base-selector',
'knowledge-base-multi-selector',
'bot-selector',
'tools-selector',
'model-fallback-selector',
'prompt-editor',
'plugin-selector',
'webhook-url',
'embed-code',
}
def validate(self, metadata: dict[str, Any]) -> list[str]:
"""Return validation errors. An empty list means the metadata is valid."""
errors: list[str] = []
if not isinstance(metadata, dict):
return ['metadata root must be a mapping']
for field in self.REQUIRED_FIELDS:
if field not in metadata:
errors.append(f'missing required field: {field}')
if errors:
return errors
name = metadata.get('name')
if not isinstance(name, str) or not name.strip():
errors.append('field "name" must be a non-empty string')
category = metadata.get('category')
if category not in self.VALID_CATEGORIES:
errors.append(f'invalid category: {category}')
errors.extend(self._validate_ports(metadata.get('inputs'), 'inputs'))
errors.extend(self._validate_ports(metadata.get('outputs'), 'outputs'))
errors.extend(self._validate_config(metadata.get('config')))
return errors
def validate_or_raise(self, metadata: dict[str, Any]) -> dict[str, Any]:
"""Validate metadata and raise ``MetadataValidationError`` on failure."""
errors = self.validate(metadata)
if errors:
node_name = metadata.get('name', 'unknown') if isinstance(metadata, dict) else 'unknown'
raise MetadataValidationError(f'invalid metadata for {node_name}: {errors}')
return metadata
def _validate_ports(self, ports: Any, field_name: str) -> list[str]:
errors: list[str] = []
if not isinstance(ports, list):
return [f'{field_name} must be a list']
seen_names: set[str] = set()
for index, port in enumerate(ports):
path = f'{field_name}[{index}]'
if not isinstance(port, dict):
errors.append(f'{path} must be a mapping')
continue
name = port.get('name')
if not isinstance(name, str) or not name:
errors.append(f'{path}.name must be a non-empty string')
continue
if name in seen_names:
errors.append(f'{path}.name duplicates "{name}"')
seen_names.add(name)
port_type = port.get('type', 'any')
if port_type not in self.VALID_PORT_TYPES:
errors.append(f'{path}.type has unsupported value "{port_type}"')
return errors
def _validate_config(self, config: Any) -> list[str]:
errors: list[str] = []
if not isinstance(config, list):
return ['config must be a list']
seen_names: set[str] = set()
for index, item in enumerate(config):
path = f'config[{index}]'
if not isinstance(item, dict):
errors.append(f'{path} must be a mapping')
continue
name = item.get('name')
if not isinstance(name, str) or not name:
errors.append(f'{path}.name must be a non-empty string')
continue
if name in seen_names:
errors.append(f'{path}.name duplicates "{name}"')
seen_names.add(name)
item_type = item.get('type', 'string')
if item_type not in self.VALID_CONFIG_TYPES:
errors.append(f'{path}.type has unsupported value "{item_type}"')
min_value = item.get('min_value')
max_value = item.get('max_value')
if isinstance(min_value, (int, float)) and isinstance(max_value, (int, float)) and min_value > max_value:
errors.append(f'{path}.min_value must be <= max_value')
return errors
class NodeMetadataLoader:
"""Load and cache workflow node metadata from YAML files."""
def __init__(self, validator: Optional[NodeMetadataValidator] = None) -> None:
self._validator = validator or NodeMetadataValidator()
self._metadata: dict[str, dict[str, Any]] = {}
self._sources: dict[str, str] = {}
self._load_errors: list[dict[str, str]] = []
async def load_core_metadata(self, resource_dir: str = 'metadata/nodes') -> int:
"""Load all core node metadata from the ``langbot.templates`` package."""
return await self.load_package_directory('langbot.templates', resource_dir, source='core')
async def load_package_directory(self, package: str, resource_dir: str, source: str = 'core') -> int:
"""Load YAML files from a package resource directory."""
try:
root = resources.files(package).joinpath(resource_dir)
yaml_files = sorted(
(item for item in root.iterdir() if item.is_file() and item.name.endswith(('.yaml', '.yml'))),
key=lambda item: item.name,
)
except Exception as exc:
raise MetadataLoadError(f'failed to scan package directory {package}:{resource_dir}: {exc}') from exc
return self._load_files(yaml_files, source=source)
async def load_directory(self, directory: str | Path, source: str) -> int:
"""Load YAML files from an external filesystem directory, e.g. a plugin."""
directory_path = Path(directory)
if not directory_path.exists():
logger.warning('Workflow metadata directory does not exist: %s', directory_path)
return 0
if not directory_path.is_dir():
raise MetadataLoadError(f'workflow metadata path is not a directory: {directory_path}')
yaml_files = sorted(directory_path.glob('*.yml')) + sorted(directory_path.glob('*.yaml'))
return self._load_files(yaml_files, source=source)
def get_metadata(self, node_type: str) -> Optional[dict[str, Any]]:
"""Return metadata by full type or short node name."""
if node_type in self._metadata:
return copy.deepcopy(self._metadata[node_type])
short_name = node_type.split('.')[-1]
for registered_type, metadata in self._metadata.items():
if registered_type.split('.')[-1] == short_name or metadata.get('name') == short_name:
return copy.deepcopy(metadata)
return None
def get_all_metadata(self) -> dict[str, dict[str, Any]]:
"""Return a deep copy of all loaded metadata keyed by canonical node type."""
return copy.deepcopy(self._metadata)
def get_load_errors(self) -> list[dict[str, str]]:
"""Return metadata files that failed to load or validate."""
return copy.deepcopy(self._load_errors)
def clear(self) -> None:
"""Clear all cached metadata and errors."""
self._metadata.clear()
self._sources.clear()
self._load_errors.clear()
def _load_files(self, yaml_files: Iterable[Any], source: str) -> int:
count = 0
for yaml_file in yaml_files:
file_name = getattr(yaml_file, 'name', str(yaml_file))
try:
metadata = self._load_yaml(yaml_file)
self._validator.validate_or_raise(metadata)
node_type = build_node_type(metadata)
if node_type in self._metadata:
existing_source = self._sources.get(node_type, 'unknown')
if existing_source == 'core' and source != 'core':
raise MetadataLoadError(
f'plugin source "{source}" attempted to override core node "{node_type}"'
)
logger.warning(
'Workflow node metadata %s from %s overrides previous source %s',
node_type,
source,
existing_source,
)
cached_metadata = copy.deepcopy(metadata)
cached_metadata['_source'] = source
cached_metadata['_file'] = file_name
self._metadata[node_type] = cached_metadata
self._sources[node_type] = source
count += 1
except Exception as exc:
self._load_errors.append({'file': file_name, 'source': source, 'error': str(exc)})
logger.error('Failed to load workflow node metadata %s: %s', file_name, exc)
return count
def _load_yaml(self, yaml_file: Any) -> dict[str, Any]:
try:
if hasattr(yaml_file, 'open'):
with yaml_file.open('r', encoding='utf-8') as file:
data = yaml.load(file, Loader=yaml.FullLoader)
else:
with open(yaml_file, 'r', encoding='utf-8') as file:
data = yaml.load(file, Loader=yaml.FullLoader)
except Exception as exc:
raise MetadataLoadError(f'failed to parse YAML: {exc}') from exc
if not isinstance(data, dict):
raise MetadataLoadError('YAML root must be a mapping')
return data
def build_node_type(metadata: dict[str, Any]) -> str:
"""Build canonical ``category.name`` node type from metadata."""
category = metadata.get('category') or 'misc'
name = metadata.get('name') or ''
return f'{category}.{name}'

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

View File

@@ -5,7 +5,7 @@ Node metadata is loaded from: ../../templates/metadata/nodes/call_pipeline.yaml
from __future__ import annotations
from typing import Any, ClassVar
from typing import Any
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
@@ -15,26 +15,13 @@ import langbot_plugin.api.entities.builtin.platform.message as platform_message
import langbot_plugin.api.entities.builtin.provider.session as provider_session
from ..entities import ExecutionContext
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
from ..node import WorkflowNode, workflow_node
@workflow_node('call_pipeline')
class CallPipelineNode(WorkflowNode):
"""Call pipeline node - invoke an existing pipeline"""
type_name = 'call_pipeline'
category = 'action'
icon = 'Workflow'
name = 'call_pipeline'
description = 'call_pipeline'
name_zh = '调用 Pipeline'
name_en = 'Call Pipeline'
description_zh = '调用现有的 Pipeline 进行处理'
description_en = 'Invoke an existing Pipeline for processing'
inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = []
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
if not self.ap:
@@ -124,7 +111,6 @@ class CallPipelineNode(WorkflowNode):
if context.message_context and context.message_context.is_group:
group = platform_entities.Group(
id=context.message_context.group_id or context.session_id or 'workflow_group',
name='Workflow Group',
permission=platform_entities.Permission.Member,
)
sender = platform_entities.GroupMember(
@@ -152,7 +138,6 @@ class CallPipelineNode(WorkflowNode):
else None,
)
class _WorkflowPipelineCaptureAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
responses: list[dict[str, Any]] = []

View File

@@ -7,29 +7,16 @@ from __future__ import annotations
import json
import re
from typing import Any, ClassVar
from typing import Any
from ..entities import ExecutionContext
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
from ..node import WorkflowNode, workflow_node
@workflow_node('code_executor')
class CodeExecutorNode(WorkflowNode):
"""Code executor node - run Python or JavaScript code"""
type_name = 'code_executor'
category = 'process'
icon = 'Code'
name = 'code_executor'
description = 'code_executor'
name_zh = '代码执行'
name_en = 'Code Executor'
description_zh = '执行自定义代码处理数据'
description_en = 'Execute custom code to process data'
inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = []
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
code = self.get_config('code', '')

View File

@@ -5,30 +5,17 @@ Node metadata is loaded from: ../../templates/metadata/nodes/condition.yaml
from __future__ import annotations
from typing import Any, ClassVar
from typing import Any
from ..entities import ExecutionContext
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
from ..node import WorkflowNode, workflow_node
from ..safe_eval import safe_eval_with_vars
@workflow_node('condition')
class ConditionNode(WorkflowNode):
"""Condition node - branch based on condition"""
type_name = 'condition'
category = 'control'
icon = 'GitBranch'
name = 'condition'
description = 'condition'
name_zh = '条件分支'
name_en = 'Condition'
description_zh = '根据条件分支工作流'
description_en = 'Branch workflow based on a condition'
inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = []
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
condition_type = self.get_config('condition_type', 'expression')

View File

@@ -5,29 +5,16 @@ Node metadata is loaded from: ../../templates/metadata/nodes/coze_bot.yaml
from __future__ import annotations
from typing import Any, ClassVar
from typing import Any
from ..entities import ExecutionContext
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
from ..node import WorkflowNode, workflow_node
@workflow_node('coze_bot')
class CozeBotNode(WorkflowNode):
"""Coze bot node - call Coze API bot"""
type_name = 'coze_bot'
category = 'integration'
icon = 'MessageSquare'
name = 'coze_bot'
description = 'coze_bot'
name_zh = 'Coze Bot'
name_en = 'Coze Bot'
description_zh = '调用扣子 Bot'
description_en = 'Call a Coze Bot'
inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = []
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
api_key = self.get_config('api_key', '')

View File

@@ -5,29 +5,16 @@ Node metadata is loaded from: ../../templates/metadata/nodes/cron_trigger.yaml
from __future__ import annotations
from typing import Any, ClassVar
from typing import Any
from ..entities import ExecutionContext
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
from ..node import WorkflowNode, workflow_node
@workflow_node('cron_trigger')
class CronTriggerNode(WorkflowNode):
"""Cron trigger node - triggers workflow on schedule"""
type_name = 'cron_trigger'
category = 'trigger'
icon = 'Timer'
name = 'cron_trigger'
description = 'cron_trigger'
name_zh = '定时触发'
name_en = 'Scheduled Trigger'
description_zh = '按定时计划触发工作流'
description_en = 'Trigger workflow on a scheduled time'
inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = []
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
from datetime import datetime

View File

@@ -5,30 +5,17 @@ Node metadata is loaded from: ../../templates/metadata/nodes/data_transform.yaml
from __future__ import annotations
from typing import Any, ClassVar
from typing import Any
from ..entities import ExecutionContext
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
from ..node import WorkflowNode, workflow_node
from ..safe_eval import safe_eval_with_vars
@workflow_node('data_transform')
class DataTransformNode(WorkflowNode):
"""Data transform node - transform data using templates or JSONPath"""
type_name = 'data_transform'
category = 'process'
icon = 'ArrowRightLeft'
name = 'data_transform'
description = 'data_transform'
name_zh = '数据转换'
name_en = 'Data Transform'
description_zh = '使用模板或 JSONPath 转换数据'
description_en = 'Transform data using templates or JSONPath'
inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = []
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
data = inputs.get('data')

View File

@@ -5,29 +5,16 @@ Node metadata is loaded from: ../../templates/metadata/nodes/database_query.yaml
from __future__ import annotations
from typing import Any, ClassVar
from typing import Any
from ..entities import ExecutionContext
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
from ..node import WorkflowNode, workflow_node
@workflow_node('database_query')
class DatabaseQueryNode(WorkflowNode):
"""Database query node - execute database queries"""
type_name = 'database_query'
category = 'integration'
icon = 'Database'
name = 'database_query'
description = 'database_query'
name_zh = '数据库查询'
name_en = 'Database Query'
description_zh = '执行数据库查询'
description_en = 'Execute database queries'
inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = []
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
connection_type = self.get_config('connection_type', 'postgresql')

View File

@@ -5,29 +5,16 @@ Node metadata is loaded from: ../../templates/metadata/nodes/dify_knowledge_quer
from __future__ import annotations
from typing import Any, ClassVar
from typing import Any
from ..entities import ExecutionContext
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
from ..node import WorkflowNode, workflow_node
@workflow_node('dify_knowledge_query')
class DifyKnowledgeQueryNode(WorkflowNode):
"""Dify knowledge base query node - query Dify knowledge base"""
type_name = 'dify_knowledge_query'
category = 'integration'
icon = 'BookOpen'
name = 'dify_knowledge_query'
description = 'dify_knowledge_query'
name_zh = 'Dify 知识库查询'
name_en = 'Dify Knowledge Query'
description_zh = '查询 Dify 知识库'
description_en = 'Query Dify knowledge base'
inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = []
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
base_url = self.get_config('base_url', 'https://api.dify.ai/v1')

View File

@@ -5,29 +5,16 @@ Node metadata is loaded from: ../../templates/metadata/nodes/dify_workflow.yaml
from __future__ import annotations
from typing import Any, ClassVar
from typing import Any
from ..entities import ExecutionContext
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
from ..node import WorkflowNode, workflow_node
@workflow_node('dify_workflow')
class DifyWorkflowNode(WorkflowNode):
"""Dify workflow node - call Dify service API"""
type_name = 'dify_workflow'
category = 'integration'
icon = 'Bot'
name = 'dify_workflow'
description = 'dify_workflow'
name_zh = 'Dify 工作流'
name_en = 'Dify Workflow'
description_zh = '调用 Dify 平台工作流'
description_en = 'Call a Dify platform workflow'
inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = []
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
base_url = self.get_config('base_url', 'https://api.dify.ai/v1')

View File

@@ -5,29 +5,16 @@ Node metadata is loaded from: ../../templates/metadata/nodes/end.yaml
from __future__ import annotations
from typing import Any, ClassVar
from typing import Any
from ..entities import ExecutionContext
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
from ..node import WorkflowNode, workflow_node
@workflow_node('end')
class EndNode(WorkflowNode):
"""End node - marks the end of workflow execution"""
type_name = 'end'
category = 'action'
icon = 'PauseCircle'
name = 'end'
description = 'end'
name_zh = '结束'
name_en = 'End'
description_zh = '结束工作流执行'
description_en = 'End the workflow execution'
inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = []
category = 'control'
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
result = inputs.get('result')

View File

@@ -5,29 +5,16 @@ Node metadata is loaded from: ../../templates/metadata/nodes/event_trigger.yaml
from __future__ import annotations
from typing import Any, ClassVar
from typing import Any
from ..entities import ExecutionContext
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
from ..node import WorkflowNode, workflow_node
@workflow_node('event_trigger')
class EventTriggerNode(WorkflowNode):
"""Event trigger node - triggers workflow on system events"""
type_name = 'event_trigger'
category = 'trigger'
icon = 'Bell'
name = 'event_trigger'
description = 'event_trigger'
name_zh = '事件触发'
name_en = 'Event Trigger'
description_zh = '当系统事件发生时触发工作流'
description_en = 'Trigger workflow when a system event occurs'
inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = []
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
from datetime import datetime

View File

@@ -5,29 +5,16 @@ Node metadata is loaded from: ../../templates/metadata/nodes/http_request.yaml
from __future__ import annotations
from typing import Any, ClassVar
from typing import Any
from ..entities import ExecutionContext
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
from ..node import WorkflowNode, workflow_node
@workflow_node('http_request')
class HTTPRequestNode(WorkflowNode):
"""HTTP request node - make HTTP API calls"""
type_name = 'http_request'
category = 'process'
icon = 'Globe'
name = 'http_request'
description = 'http_request'
name_zh = 'HTTP 请求'
name_en = 'HTTP Request'
description_zh = '向外部 API 发送 HTTP 请求'
description_en = 'Make HTTP requests to external APIs'
inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = []
category = 'action'
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
import aiohttp

View File

@@ -2,47 +2,16 @@
from __future__ import annotations
from typing import Any, ClassVar
from typing import Any
from ..entities import ExecutionContext
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
from ..node import WorkflowNode, workflow_node
@workflow_node('iterator')
class IteratorNode(WorkflowNode):
"""Iterator node - iterate over array items one by one"""
type_name = 'iterator'
category = 'control'
icon = 'Repeat'
name = 'iterator'
name_zh = '迭代器'
name_en = 'Iterator'
description = 'iterator'
description_zh = '逐个遍历数组元素'
description_en = 'Iterate over array elements one by one'
inputs: ClassVar[list[NodePort]] = [
NodePort(name='items', type='array', description='Array to iterate over', required=True),
]
outputs: ClassVar[list[NodePort]] = [
NodePort(name='item', type='any', description='Current item'),
NodePort(name='index', type='number', description='Current index'),
NodePort(name='is_first', type='boolean', description='Whether this is the first item'),
NodePort(name='is_last', type='boolean', description='Whether this is the last item'),
NodePort(name='results', type='array', description='All iteration results'),
NodePort(name='completed', type='boolean', description='Whether iteration completed'),
]
config_schema: ClassVar[list[NodeConfig]] = [
NodeConfig(
name='max_iterations',
type='integer',
required=False,
default=1000,
description='Maximum iterations (safety limit)',
label={'en_US': 'Max Iterations', 'zh_Hans': '最大迭代次数'},
),
]
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
items = inputs.get('items', [])

View File

@@ -5,29 +5,16 @@ Node metadata is loaded from: ../../templates/metadata/nodes/knowledge_retrieval
from __future__ import annotations
from typing import Any, ClassVar
from typing import Any
from ..entities import ExecutionContext
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
from ..node import WorkflowNode, workflow_node
@workflow_node('knowledge_retrieval')
class KnowledgeRetrievalNode(WorkflowNode):
"""Knowledge retrieval node - search in knowledge base"""
type_name = 'knowledge_retrieval'
category = 'process'
icon = 'Search'
name = 'knowledge_retrieval'
description = 'knowledge_retrieval'
name_zh = '知识库检索'
name_en = 'Knowledge Retrieval'
description_zh = '从知识库中检索相关信息'
description_en = 'Retrieve relevant information from knowledge bases'
inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = []
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
query = inputs.get('query', '')

View File

@@ -5,29 +5,16 @@ Node metadata is loaded from: ../../templates/metadata/nodes/langflow_flow.yaml
from __future__ import annotations
from typing import Any, ClassVar
from typing import Any
from ..entities import ExecutionContext
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
from ..node import WorkflowNode, workflow_node
@workflow_node('langflow_flow')
class LangflowFlowNode(WorkflowNode):
"""Langflow flow node - call Langflow API"""
type_name = 'langflow_flow'
category = 'integration'
icon = 'GitBranch'
name = 'langflow_flow'
description = 'langflow_flow'
name_zh = 'Langflow 流程'
name_en = 'Langflow Flow'
description_zh = '调用 Langflow 流程'
description_en = 'Call a Langflow flow'
inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = []
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
base_url = self.get_config('base_url', 'http://localhost:7860')

View File

@@ -4,81 +4,20 @@ from __future__ import annotations
import logging
import re
from typing import Any, ClassVar
from typing import Any
import langbot_plugin.api.entities.builtin.provider.message as provider_message
from ..entities import ExecutionContext
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
from ..node import WorkflowNode, workflow_node
logger = logging.getLogger(__name__)
@workflow_node('llm_call')
class LLMCallNode(WorkflowNode):
"""LLM call node - invoke large language model"""
type_name = 'llm_call'
category = 'process'
icon = 'Brain'
name = 'llm_call'
name_zh = 'LLM 调用'
name_en = 'LLM Call'
description = 'llm_call'
description_zh = '调用大语言模型生成响应'
description_en = 'Call a large language model to generate responses'
inputs: ClassVar[list[NodePort]] = [
NodePort(name='input', type='string', description='Input text to send to the model', required=False),
NodePort(name='context', type='object', description='Additional context data', required=False),
]
outputs: ClassVar[list[NodePort]] = [
NodePort(name='response', type='string', description='Model response text'),
NodePort(name='usage', type='object', description='Token usage information'),
]
config_schema: ClassVar[list[NodeConfig]] = [
NodeConfig(
name='model',
type='llm-model-selector',
required=True,
description='Select the LLM model to use',
label={'en_US': 'Model', 'zh_Hans': '模型'},
),
NodeConfig(
name='system_prompt',
type='textarea',
required=False,
default='',
description='System prompt to set model behavior',
label={'en_US': 'System Prompt', 'zh_Hans': '系统提示词'},
),
NodeConfig(
name='user_prompt_template',
type='textarea',
required=True,
default='{{input}}',
description='User prompt template with variable placeholders',
label={'en_US': 'User Prompt Template', 'zh_Hans': '用户提示词模板'},
),
NodeConfig(
name='temperature',
type='number',
required=False,
default=0.7,
description='Controls randomness (0.0-2.0)',
label={'en_US': 'Temperature', 'zh_Hans': '温度'},
min_value=0.0,
max_value=2.0,
),
NodeConfig(
name='max_tokens',
type='integer',
required=False,
default=0,
description='Max tokens to generate (0 = model default)',
label={'en_US': 'Max Tokens', 'zh_Hans': '最大令牌数'},
),
]
def _resolve_template(self, template: str, inputs: dict[str, Any], context: ExecutionContext) -> str:
"""Resolve {{variable}} placeholders in a template string."""

View File

@@ -2,54 +2,16 @@
from __future__ import annotations
from typing import Any, ClassVar
from typing import Any
from ..entities import ExecutionContext
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
from ..node import WorkflowNode, workflow_node
@workflow_node('loop')
class LoopNode(WorkflowNode):
"""Loop node - iterate over items"""
type_name = 'loop'
category = 'control'
icon = 'Repeat'
name = 'loop'
name_zh = '循环'
name_en = 'Loop'
description = 'loop'
description_zh = '遍历项目或重复直到满足条件'
description_en = 'Iterate over items or repeat until condition'
inputs: ClassVar[list[NodePort]] = [
NodePort(name='items', type='array', description='Items to iterate over', required=False),
]
outputs: ClassVar[list[NodePort]] = [
NodePort(name='item', type='any', description='Current item in iteration'),
NodePort(name='index', type='number', description='Current iteration index'),
NodePort(name='results', type='array', description='All iteration results'),
NodePort(name='completed', type='boolean', description='Whether loop completed'),
]
config_schema: ClassVar[list[NodeConfig]] = [
NodeConfig(
name='loop_type',
type='select',
required=True,
default='foreach',
description='Type of loop',
label={'en_US': 'Loop Type', 'zh_Hans': '循环类型'},
options=['foreach', 'while', 'count'],
),
NodeConfig(
name='max_iterations',
type='integer',
required=False,
default=100,
description='Maximum iterations (safety limit)',
label={'en_US': 'Max Iterations', 'zh_Hans': '最大迭代次数'},
),
]
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
items = inputs.get('items', [])

View File

@@ -9,36 +9,24 @@ The i18n for label and description is handled on the frontend side.
from __future__ import annotations
from typing import Any, ClassVar
from typing import Any
from ..entities import ExecutionContext
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
from ..node import WorkflowNode, workflow_node
@workflow_node('mcp_tool')
class MCPToolNode(WorkflowNode):
"""MCP tool node - invoke MCP (Model Context Protocol) tools"""
# Node type for registration
type_name = 'mcp_tool'
# Category and icon - these are not i18n
category = 'integration'
icon = 'Wrench'
# Name and description - i18n handled on frontend side
# Frontend will use node type key to look up translation
name = 'mcp_tool'
description = 'mcp_tool'
name_zh = 'MCP 工具'
name_en = 'MCP Tool'
description_zh = '调用 MCP 工具'
description_en = 'Invoke an MCP (Model Context Protocol) tool'
# Inputs/outputs/config - loaded from YAML at runtime
inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = []
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
"""Execute the MCP tool node

View File

@@ -5,11 +5,10 @@ Node metadata is loaded from: ../../templates/metadata/nodes/memory_store.yaml
from __future__ import annotations
from typing import Any, ClassVar
from typing import Any
from ..entities import ExecutionContext
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
from ..node import WorkflowNode, workflow_node
class MemoryHelper:
"""Helper class wrapping context.memory dict with get/set/delete/list_all/append operations"""
@@ -47,24 +46,11 @@ class MemoryHelper:
self.set(key, current, scope=scope, ttl=ttl)
return current
@workflow_node('memory_store')
class MemoryStoreNode(WorkflowNode):
"""Memory store node - store and retrieve from workflow memory"""
type_name = 'memory_store'
category = 'integration'
icon = 'HardDrive'
name = 'memory_store'
description = 'memory_store'
name_zh = '记忆存储'
name_en = 'Memory Store'
description_zh = '从工作流记忆中存储和检索数据'
description_en = 'Store and retrieve data from workflow memory'
inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = []
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
operation = self.get_config('operation', 'get')

View File

@@ -5,29 +5,16 @@ Node metadata is loaded from: ../../templates/metadata/nodes/merge.yaml
from __future__ import annotations
from typing import Any, ClassVar
from typing import Any
from ..entities import ExecutionContext
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
from ..node import WorkflowNode, workflow_node
@workflow_node('merge')
class MergeNode(WorkflowNode):
"""Merge node - combine multiple inputs"""
type_name = 'merge'
category = 'control'
icon = 'GitMerge'
name = 'merge'
description = 'merge'
name_zh = '合并'
name_en = 'Merge'
description_zh = '将多个分支合并在一起'
description_en = 'Merge multiple branches back together'
inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = []
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
strategy = self.get_config('merge_strategy', 'object')

View File

@@ -7,29 +7,16 @@ Node metadata (label, description, inputs, outputs, config) is loaded from:
from __future__ import annotations
from typing import Any, ClassVar
from typing import Any
from ..entities import ExecutionContext
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
from ..node import WorkflowNode, workflow_node
@workflow_node('message_trigger')
class MessageTriggerNode(WorkflowNode):
"""Message trigger node - triggers workflow on message arrival"""
type_name = 'message_trigger'
category = 'trigger'
icon = 'MessageSquare'
name = 'message_trigger'
description = 'message_trigger'
name_zh = '消息触发'
name_en = 'Message Trigger'
description_zh = '当收到消息时触发工作流'
description_en = 'Trigger workflow when a message is received'
inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = []
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
msg_ctx = context.message_context

View File

@@ -5,29 +5,16 @@ Node metadata is loaded from: ../../templates/metadata/nodes/n8n_workflow.yaml
from __future__ import annotations
from typing import Any, ClassVar
from typing import Any
from ..entities import ExecutionContext
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
from ..node import WorkflowNode, workflow_node
@workflow_node('n8n_workflow')
class N8nWorkflowNode(WorkflowNode):
"""n8n workflow node - call n8n workflow API"""
type_name = 'n8n_workflow'
category = 'integration'
icon = 'Workflow'
name = 'n8n_workflow'
description = 'n8n_workflow'
name_zh = 'n8n 工作流'
name_en = 'N8n Workflow'
description_zh = '通过 webhook 调用 n8n 工作流'
description_en = 'Call an n8n workflow via webhook'
inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = []
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
webhook_url = self.get_config('webhook_url', '')

View File

@@ -5,29 +5,16 @@ Node metadata is loaded from: ../../templates/metadata/nodes/opening_statement.y
from __future__ import annotations
from typing import Any, ClassVar
from typing import Any
from ..entities import ExecutionContext
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
from ..node import WorkflowNode, workflow_node
@workflow_node('opening_statement')
class OpeningStatementNode(WorkflowNode):
"""Opening statement node - provide conversation opener and suggested questions"""
type_name = 'opening_statement'
category = 'action'
icon = 'MessageSquare'
name = 'opening_statement'
description = 'opening_statement'
name_zh = '对话开场白'
name_en = 'Opening Statement'
description_zh = '提供对话开场白和建议问题'
description_en = 'Provide conversation opener and suggested questions'
inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = []
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
statement = self.get_config('statement', '')

View File

@@ -2,51 +2,16 @@
from __future__ import annotations
from typing import Any, ClassVar
from typing import Any
from ..entities import ExecutionContext
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
from ..node import WorkflowNode, workflow_node
@workflow_node('parallel')
class ParallelNode(WorkflowNode):
"""Parallel node - execute multiple branches simultaneously"""
type_name = 'parallel'
category = 'control'
icon = 'Layers'
name = 'parallel'
name_zh = '并行执行'
name_en = 'Parallel'
description = 'parallel'
description_zh = '并行执行多个分支'
description_en = 'Execute multiple branches in parallel'
inputs: ClassVar[list[NodePort]] = [
NodePort(name='input', type='any', description='Input data for all branches', required=False),
]
outputs: ClassVar[list[NodePort]] = [
NodePort(name='results', type='object', description='Combined results from all branches'),
NodePort(name='errors', type='array', description='Errors from branches (if any)'),
]
config_schema: ClassVar[list[NodeConfig]] = [
NodeConfig(
name='wait_all',
type='boolean',
required=False,
default=True,
description='Wait for all branches to complete',
label={'en_US': 'Wait for All', 'zh_Hans': '等待全部完成'},
),
NodeConfig(
name='fail_fast',
type='boolean',
required=False,
default=False,
description='Stop all branches if any fails',
label={'en_US': 'Fail Fast', 'zh_Hans': '快速失败'},
),
]
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
return {

View File

@@ -5,29 +5,17 @@ Node metadata is loaded from: ../../templates/metadata/nodes/parameter_extractor
from __future__ import annotations
from typing import Any, ClassVar
from typing import Any
from ..entities import ExecutionContext
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
from ..node import WorkflowNode, workflow_node
@workflow_node('parameter_extractor')
class ParameterExtractorNode(WorkflowNode):
"""Parameter extractor node - extract structured parameters from text"""
type_name = 'parameter_extractor'
category = 'process'
icon: str = 'Variable'
name = 'parameter_extractor'
description = 'parameter_extractor'
name_zh = '参数提取器'
name_en = 'Parameter Extractor'
description_zh = '使用 AI 从文本中提取结构化参数'
description_en = 'Extract structured parameters from text using AI'
inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = []
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
param_defs = self.get_config('parameters', [])

View File

@@ -5,11 +5,10 @@
# from __future__ import annotations
# from typing import Any, ClassVar
# from typing import Any
# from ..entities import ExecutionContext
# from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
# from ..node import WorkflowNode, workflow_node
# @workflow_node('plugin_call')
# class PluginCallNode(WorkflowNode):

View File

@@ -5,29 +5,16 @@ Node metadata is loaded from: ../../templates/metadata/nodes/question_classifier
from __future__ import annotations
from typing import Any, ClassVar
from typing import Any
from ..entities import ExecutionContext
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
from ..node import WorkflowNode, workflow_node
@workflow_node('question_classifier')
class QuestionClassifierNode(WorkflowNode):
"""Question classifier node - classify user questions into categories"""
type_name = 'question_classifier'
category = 'process'
icon = 'ListFilter'
name = 'question_classifier'
description = 'question_classifier'
name_zh = '问题分类器'
name_en = 'Question Classifier'
description_zh = '使用 AI 将问题分类到预定义类别'
description_en = 'Classify questions into predefined categories using AI'
inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = []
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
categories = self.get_config('categories', [])

View File

@@ -5,29 +5,16 @@ Node metadata is loaded from: ../../templates/metadata/nodes/redis_operation.yam
from __future__ import annotations
from typing import Any, ClassVar
from typing import Any
from ..entities import ExecutionContext
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
from ..node import WorkflowNode, workflow_node
@workflow_node('redis_operation')
class RedisOperationNode(WorkflowNode):
"""Redis operation node - perform Redis cache operations"""
type_name = 'redis_operation'
category = 'integration'
icon = 'Server'
name = 'redis_operation'
description = 'redis_operation'
name_zh = 'Redis 操作'
name_en = 'Redis Operation'
description_zh = '执行 Redis 缓存操作'
description_en = 'Perform Redis cache operations'
inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = []
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
connection_url = self.get_config('connection_url', 'redis://localhost:6379')

View File

@@ -6,31 +6,18 @@ Node metadata is loaded from: ../../templates/metadata/nodes/reply_message.yaml
from __future__ import annotations
import logging
from typing import Any, ClassVar
from typing import Any
from ..entities import ExecutionContext
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
from ..node import WorkflowNode, workflow_node
logger = logging.getLogger(__name__)
@workflow_node('reply_message')
class ReplyMessageNode(WorkflowNode):
"""Reply message node - reply to the triggering message"""
type_name = 'reply_message'
category = 'action'
icon = 'Send'
name = 'reply_message'
description = 'reply_message'
name_zh = '回复消息'
name_en = 'Reply Message'
description_zh = '回复触发工作流的消息'
description_en = 'Reply to the message that triggered the workflow'
inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = []
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
message = inputs.get('message')

View File

@@ -5,29 +5,16 @@ Node metadata is loaded from: ../../templates/metadata/nodes/send_message.yaml
from __future__ import annotations
from typing import Any, ClassVar
from typing import Any
from ..entities import ExecutionContext
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
from ..node import WorkflowNode, workflow_node
@workflow_node('send_message')
class SendMessageNode(WorkflowNode):
"""Send message node - send message to a target"""
type_name = 'send_message'
category = 'action'
icon = 'MessageCircle'
name = 'send_message'
description = 'send_message'
name_zh = '发送消息'
name_en = 'Send Message'
description_zh = '向聊天或用户发送消息'
description_en = 'Send a message to a chat or user'
inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = []
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
return {'status': 'sent', 'message_id': f'msg_{context.execution_id}'}

View File

@@ -5,29 +5,16 @@ Node metadata is loaded from: ../../templates/metadata/nodes/set_variable.yaml
from __future__ import annotations
from typing import Any, ClassVar
from typing import Any
from ..entities import ExecutionContext
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
from ..node import WorkflowNode, workflow_node
@workflow_node('set_variable')
class SetVariableNode(WorkflowNode):
"""Set variable node - set workflow or conversation variable"""
type_name = 'set_variable'
category = 'action'
icon = 'Variable'
name = 'set_variable'
description = 'set_variable'
name_zh = '设置变量'
name_en = 'Set Variable'
description_zh = '设置上下文变量值'
description_en = 'Set a context variable value'
inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = []
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
value = inputs.get('value')

View File

@@ -5,29 +5,16 @@ Node metadata is loaded from: ../../templates/metadata/nodes/store_data.yaml
from __future__ import annotations
from typing import Any, ClassVar
from typing import Any
from ..entities import ExecutionContext
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
from ..node import WorkflowNode, workflow_node
@workflow_node('store_data')
class StoreDataNode(WorkflowNode):
"""Store data node - save data to storage"""
type_name = 'store_data'
category = 'action'
icon = 'Database'
name = 'store_data'
description = 'store_data'
name_zh = '存储数据'
name_en = 'Store Data'
description_zh = '将数据存储到持久化存储'
description_en = 'Store data to persistent storage'
inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = []
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
key = inputs.get('key', '')

View File

@@ -5,29 +5,16 @@ Node metadata is loaded from: ../../templates/metadata/nodes/switch.yaml
from __future__ import annotations
from typing import Any, ClassVar
from typing import Any
from ..entities import ExecutionContext
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
from ..node import WorkflowNode, workflow_node
@workflow_node('switch')
class SwitchNode(WorkflowNode):
"""Switch node - multi-way branch based on value"""
type_name = 'switch'
category = 'control'
icon = 'Split'
name = 'switch'
description = 'switch'
name_zh = '多路分支'
name_en = 'Switch'
description_zh = '根据多个条件分支工作流'
description_en = 'Branch workflow based on multiple cases'
inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = []
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
expression = self.get_config('expression', '')

View File

@@ -5,29 +5,16 @@ Node metadata is loaded from: ../../templates/metadata/nodes/variable_aggregator
from __future__ import annotations
from typing import Any, ClassVar
from typing import Any
from ..entities import ExecutionContext
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
from ..node import WorkflowNode, workflow_node
@workflow_node('variable_aggregator')
class VariableAggregatorNode(WorkflowNode):
"""Variable aggregator node - aggregate variables from multiple branches"""
type_name = 'variable_aggregator'
category = 'control'
icon = 'GitMerge'
name = 'variable_aggregator'
description = 'variable_aggregator'
name_zh = '变量聚合器'
name_en = 'Variable Aggregator'
description_zh = '聚合多个分支的变量输出'
description_en = 'Aggregate variable outputs from multiple branches'
inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = []
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
variables = inputs.get('variables', {})

View File

@@ -5,29 +5,16 @@ Node metadata is loaded from: ../../templates/metadata/nodes/wait.yaml
from __future__ import annotations
from typing import Any, ClassVar
from typing import Any
from ..entities import ExecutionContext
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
from ..node import WorkflowNode, workflow_node
@workflow_node('wait')
class WaitNode(WorkflowNode):
"""Wait node - pause execution for a duration"""
type_name = 'wait'
category = 'control'
icon = 'Clock'
name = 'wait'
description = 'wait'
name_zh = '等待'
name_en = 'Wait'
description_zh = '暂停工作流执行指定时间'
description_en = 'Pause workflow execution for a specified duration'
inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = []
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
import asyncio

View File

@@ -5,29 +5,16 @@ Node metadata is loaded from: ../../templates/metadata/nodes/webhook_trigger.yam
from __future__ import annotations
from typing import Any, ClassVar
from typing import Any
from ..entities import ExecutionContext
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
from ..node import WorkflowNode, workflow_node
@workflow_node('webhook_trigger')
class WebhookTriggerNode(WorkflowNode):
"""Webhook trigger node - triggers workflow via HTTP request"""
type_name = 'webhook_trigger'
category = 'trigger'
icon = 'Webhook'
name = 'webhook_trigger'
description = 'webhook_trigger'
name_zh = 'Webhook 触发'
name_en = 'Webhook Trigger'
description_zh = '通过 HTTP 请求触发工作流'
description_en = 'Trigger workflow via HTTP webhook'
inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = []
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
trigger_data = context.trigger_data

View File

@@ -1,22 +1,35 @@
"""Node type registry"""
"""Workflow node type registry."""
from __future__ import annotations
import copy
import logging
from typing import Any, Optional
from .node import WorkflowNode, get_pending_registrations, clear_pending_registrations
from .metadata import build_node_type
from .node import WorkflowNode, clear_pending_registrations, get_pending_registrations
logger = logging.getLogger(__name__)
class NodeConflictError(Exception):
"""Raised when two workflow node metadata definitions conflict."""
class NodeTypeRegistry:
"""
Central registry for all workflow node types.
Supports both built-in and plugin-provided nodes.
Central registry for workflow node types.
YAML metadata is the UI-facing source of truth. Python node classes are
registered separately and provide execution logic only.
"""
_instance: Optional['NodeTypeRegistry'] = None
def __init__(self):
self._nodes: dict[str, type[WorkflowNode]] = {}
self._metadata: dict[str, dict[str, Any]] = {}
self._metadata_sources: dict[str, str] = {}
self._categories: dict[str, list[str]] = {
'trigger': [],
'process': [],
@@ -25,144 +38,396 @@ class NodeTypeRegistry:
'integration': [],
'misc': [],
}
self._conflicts: list[dict[str, str]] = []
@classmethod
def instance(cls) -> 'NodeTypeRegistry':
"""Get singleton instance"""
"""Get singleton instance."""
if cls._instance is None:
cls._instance = cls()
return cls._instance
def register_metadata(self, metadata: dict[str, Any], source: str = 'core') -> bool:
"""Register YAML metadata for a workflow node type.
Core metadata cannot be overridden by plugin metadata. Plugin-plugin
conflicts are allowed with a warning so hot-reload/development flows can
replace plugin definitions.
"""
node_type = build_node_type(metadata)
existing_source = self._metadata_sources.get(node_type)
if existing_source:
conflict = {'type': node_type, 'existing_source': existing_source, 'new_source': source}
if existing_source == 'core' and source != 'core':
self._conflicts.append(conflict)
logger.error('Plugin source %s attempted to override core workflow node %s', source, node_type)
return False
logger.warning(
'Workflow node metadata %s from %s overrides previous source %s', node_type, source, existing_source
)
cached_metadata = copy.deepcopy(metadata)
cached_metadata['_source'] = source
self._metadata[node_type] = cached_metadata
self._metadata_sources[node_type] = source
self._add_to_category(metadata.get('category', 'misc'), node_type)
return True
def register(self, node_type: str, node_class: type[WorkflowNode]):
"""
Register a node type.
"""Register a Python workflow node implementation class."""
canonical_type = self._canonical_type_for_class(node_type, node_class)
self._nodes[canonical_type] = node_class
Args:
node_type: Unique type identifier
node_class: WorkflowNode subclass
"""
self._nodes[node_type] = node_class
metadata = self.get_metadata(canonical_type)
if metadata:
category = metadata.get('category', getattr(node_class, 'category', 'misc'))
else:
category = getattr(node_class, 'category', 'misc')
logger.warning('Workflow node implementation %s has no YAML metadata', canonical_type)
# Add to category
category = getattr(node_class, 'category', 'misc')
if category not in self._categories:
self._categories[category] = []
if node_type not in self._categories[category]:
self._categories[category].append(node_type)
self._add_to_category(category, canonical_type)
def unregister(self, node_type: str):
"""Unregister a node type"""
if node_type in self._nodes:
node_class = self._nodes[node_type]
category = getattr(node_class, 'category', 'misc')
if category in self._categories and node_type in self._categories[category]:
self._categories[category].remove(node_type)
del self._nodes[node_type]
"""Unregister a Python workflow node implementation."""
canonical_type = self._resolve_registered_node_key(node_type)
if canonical_type is None:
return
node_class = self._nodes[canonical_type]
metadata = self.get_metadata(canonical_type)
category = metadata.get('category') if metadata else getattr(node_class, 'category', 'misc')
self._remove_from_category(category or 'misc', canonical_type)
del self._nodes[canonical_type]
def unregister_metadata(self, node_type: str):
"""Unregister YAML metadata for a node type, primarily for plugin unload."""
canonical_type = self._resolve_metadata_key(node_type)
if canonical_type is None:
return
metadata = self._metadata[canonical_type]
self._remove_from_category(metadata.get('category', 'misc'), canonical_type)
del self._metadata[canonical_type]
self._metadata_sources.pop(canonical_type, None)
def get(self, node_type: str) -> Optional[type[WorkflowNode]]:
"""Get node class by type. Supports both 'category.type_name' and short 'type_name' formats."""
# First try exact match (category.type_name format)
if node_type in self._nodes:
return self._nodes[node_type]
"""Get node class by type. Supports both ``category.name`` and short names."""
canonical_type = self._resolve_registered_node_key(node_type)
if canonical_type:
return self._nodes[canonical_type]
# Try short name format (e.g., 'dify_workflow' -> 'integration.dify_workflow')
# Search through all registered nodes for a matching type_name
for registered_type, node_class in self._nodes.items():
if node_class.type_name == node_type:
return node_class
# Lazy-process pending registrations so execution paths that didn't
# explicitly warm the registry can still resolve newly imported nodes.
if get_pending_registrations():
self.process_pending_registrations()
canonical_type = self._resolve_registered_node_key(node_type)
if canonical_type:
return self._nodes[canonical_type]
if node_type in self._nodes:
return self._nodes[node_type]
for registered_type, node_class in self._nodes.items():
if node_class.type_name == node_type:
return node_class
return None
def get_metadata(self, node_type: str) -> Optional[dict[str, Any]]:
"""Get YAML metadata by full type or short node name."""
canonical_type = self._resolve_metadata_key(node_type)
if canonical_type:
return copy.deepcopy(self._metadata[canonical_type])
return None
def create_instance(
self, node_type: str, node_id: str, config: dict[str, Any], ap: Optional['app.Application'] = None
) -> Optional[WorkflowNode]:
"""Create a node instance. Supports both 'category.type_name' and short 'type_name' formats."""
"""Create a node instance. Supports both ``category.name`` and short names."""
node_class = self.get(node_type)
if node_class:
return node_class(node_id, config, ap=ap)
logger.warning('No workflow node implementation registered for type: %s', node_type)
return None
def get_merged_schema(self, node_type: str) -> Optional[dict[str, Any]]:
"""Get frontend schema from YAML metadata.
Python node classes no longer carry UI metadata. If a node class is
registered but has no YAML metadata, a minimal schema is generated
from the class attributes (category, type_name) so it still appears
in the editor.
"""
metadata = self.get_metadata(node_type)
node_class = self.get(node_type)
if metadata:
schema = self._metadata_to_schema(metadata)
if node_class:
# Supplement pipeline config reuse fields from Python class
for key in ('config_schema_source', 'config_stages'):
if not schema.get(key) and getattr(node_class, key, None):
schema[key] = getattr(node_class, key)
return schema
if node_class:
# Fallback: node has Python class but no YAML metadata
short_name = getattr(node_class, 'type_name', '') or node_type.split('.')[-1]
category = getattr(node_class, 'category', 'misc')
return {
'type': f'{category}.{short_name}',
'name': short_name,
'label': self._normalize_i18n(None, self._prettify_name(short_name)),
'description': self._normalize_i18n(None, ''),
'category': category,
'icon': '',
'color': '',
'inputs': [],
'outputs': [],
'config_schema': [],
'config_schema_source': getattr(node_class, 'config_schema_source', None),
'config_stages': getattr(node_class, 'config_stages', []),
'source': 'python-only',
}
return None
def list_all(self) -> list[dict[str, Any]]:
"""
Get all registered node types as schema list.
Returns:
List of node schemas
"""
return [node_class.to_schema() for node_class in self._nodes.values()]
"""Get all registered node type schemas, including metadata-only nodes."""
node_types = self._ordered_node_types(set(self._metadata.keys()) | set(self._nodes.keys()))
return [schema for node_type in node_types if (schema := self.get_merged_schema(node_type)) is not None]
def list_by_category(self, category: str) -> list[dict[str, Any]]:
"""
Get node types by category.
Args:
category: Category name (trigger, process, control, action, integration, misc)
Returns:
List of node schemas in the category
"""
"""Get node type schemas by category."""
if category not in self._categories:
return []
return [
self._nodes[node_type].to_schema() for node_type in self._categories[category] if node_type in self._nodes
]
return [schema for node_type in self._categories[category] if (schema := self.get_merged_schema(node_type)) is not None]
def get_categories(self) -> dict[str, list[dict[str, Any]]]:
"""
Get all nodes organized by category.
Returns:
Dictionary mapping category names to lists of node schemas
"""
"""Get all nodes organized by category."""
return {category: self.list_by_category(category) for category in self._categories.keys()}
def has_type(self, node_type: str) -> bool:
"""Check if a node type is registered. Supports both formats."""
return self.get(node_type) is not None
"""Check whether a node has metadata or an implementation registered."""
return self.get_metadata(node_type) is not None or self.get(node_type) is not None
def process_pending_registrations(self):
"""Process all pending node registrations from decorators"""
"""Process all pending node registrations from decorators."""
for node_type, node_class in get_pending_registrations():
# Use category.type_name format for consistency with frontend
category = getattr(node_class, 'category', 'misc')
full_type = f'{category}.{node_type}'
self.register(full_type, node_class)
self.register(node_type, node_class)
clear_pending_registrations()
def count(self) -> int:
"""Get total number of registered node types"""
return len(self._nodes)
"""Get total number of node types exposed by metadata or implementation."""
return len(set(self._metadata.keys()) | set(self._nodes.keys()))
def metadata_count(self) -> int:
"""Get number of registered YAML metadata definitions."""
return len(self._metadata)
def get_conflicts(self) -> list[dict[str, str]]:
"""Return metadata registration conflicts."""
return copy.deepcopy(self._conflicts)
def clear(self):
"""Clear all registrations (for testing)"""
"""Clear all registrations (for testing)."""
self._nodes.clear()
self._metadata.clear()
self._metadata_sources.clear()
self._conflicts.clear()
for category in self._categories:
self._categories[category] = []
def _canonical_type_for_class(self, node_type: str, node_class: type[WorkflowNode]) -> str:
short_name = node_type.split('.')[-1]
metadata_key = self._resolve_metadata_key(node_type) or self._resolve_metadata_key(short_name)
if metadata_key:
return metadata_key
category = getattr(node_class, 'category', 'misc')
return node_type if '.' in node_type else f'{category}.{short_name}'
def _resolve_registered_node_key(self, node_type: str) -> Optional[str]:
if node_type in self._nodes:
return node_type
short_name = node_type.split('.')[-1]
for registered_type, node_class in self._nodes.items():
if registered_type.split('.')[-1] == short_name or getattr(node_class, 'type_name', None) == short_name:
return registered_type
return None
def _resolve_metadata_key(self, node_type: str) -> Optional[str]:
if node_type in self._metadata:
return node_type
short_name = node_type.split('.')[-1]
for registered_type, metadata in self._metadata.items():
if registered_type.split('.')[-1] == short_name or metadata.get('name') == short_name:
return registered_type
return None
def _ordered_node_types(self, node_types: set[str]) -> list[str]:
ordered: list[str] = []
for category in self._categories:
for node_type in self._categories[category]:
if node_type in node_types and node_type not in ordered:
ordered.append(node_type)
for node_type in sorted(node_types):
if node_type not in ordered:
ordered.append(node_type)
return ordered
def _add_to_category(self, category: str, node_type: str) -> None:
if category not in self._categories:
self._categories[category] = []
if node_type not in self._categories[category]:
self._categories[category].append(node_type)
def _remove_from_category(self, category: str, node_type: str) -> None:
if category in self._categories and node_type in self._categories[category]:
self._categories[category].remove(node_type)
def _metadata_to_schema(self, metadata: dict[str, Any]) -> dict[str, Any]:
node_type = build_node_type(metadata)
node_name = metadata.get('name', node_type.split('.')[-1])
return {
'type': node_type,
'name': node_name,
'label': self._normalize_i18n(metadata.get('label'), self._prettify_name(node_name)),
'description': self._normalize_i18n(metadata.get('description'), ''),
'category': metadata.get('category', 'misc'),
'icon': metadata.get('icon', ''),
'color': metadata.get('color', ''),
'inputs': [self._normalize_port_item(item) for item in metadata.get('inputs', [])],
'outputs': [self._normalize_port_item(item) for item in metadata.get('outputs', [])],
'config_schema': [self._normalize_config_item(item) for item in metadata.get('config', [])],
'config_schema_source': metadata.get('config_schema_source'),
'config_stages': metadata.get('config_stages', []),
'source': metadata.get('_source', 'core'),
}
def _merge_missing_schema_fields(self, yaml_schema: dict[str, Any], python_schema: dict[str, Any]) -> dict[str, Any]:
result = copy.deepcopy(yaml_schema)
for key in ('config_schema_source', 'config_stages'):
if not result.get(key) and python_schema.get(key):
result[key] = python_schema[key]
return result
def _normalize_port_item(self, port: dict[str, Any]) -> dict[str, Any]:
item = copy.deepcopy(port)
name = item.get('name', '')
item['label'] = self._normalize_i18n(item.get('label'), self._prettify_name(name))
item['description'] = self._normalize_i18n(item.get('description'), '')
item.setdefault('type', 'any')
item.setdefault('required', True)
return item
def _normalize_config_item(self, config: dict[str, Any]) -> dict[str, Any]:
item = copy.deepcopy(config)
name = item.get('name', '')
frontend_type = self._normalize_config_type(item.get('type', 'string'))
item['id'] = item.get('id') or name
item['type'] = frontend_type
item['label'] = self._normalize_i18n(item.get('label'), self._prettify_name(name))
item['description'] = self._normalize_i18n(item.get('description'), '')
item['required'] = bool(item.get('required', False))
item['default'] = item.get('default', self._default_value_for_type(frontend_type))
if 'options' in item:
item['options'] = self._normalize_options(item.get('options'), name)
return item
def _normalize_options(self, options: Any, field_name: str) -> list[dict[str, Any]]:
if not isinstance(options, list):
return []
normalized: list[dict[str, Any]] = []
for option in options:
if isinstance(option, dict):
option_item = copy.deepcopy(option)
option_name = option_item.get('name', option_item.get('value', ''))
option_item['name'] = str(option_name)
option_item['label'] = self._normalize_i18n(option_item.get('label'), str(option_name))
normalized.append(option_item)
else:
option_name = str(option)
normalized.append({'name': option_name, 'label': self._normalize_i18n(None, option_name)})
return normalized
def _normalize_i18n(self, value: Any, fallback: str) -> dict[str, str]:
if isinstance(value, dict):
en_value = (
value.get('en_US')
or value.get('en-US')
or value.get('en')
or value.get('en_US'.replace('_', '-'))
or fallback
)
zh_value = value.get('zh_Hans') or value.get('zh-Hans') or value.get('zh-CN') or value.get('zh') or en_value
return {
'en_US': str(en_value),
'en': str(en_value),
'en-US': str(en_value),
'zh_Hans': str(zh_value),
'zh-Hans': str(zh_value),
'zh-CN': str(zh_value),
}
if isinstance(value, str) and value:
return {
'en_US': value,
'en': value,
'en-US': value,
'zh_Hans': value,
'zh-Hans': value,
'zh-CN': value,
}
return {
'en_US': fallback,
'en': fallback,
'en-US': fallback,
'zh_Hans': fallback,
'zh-Hans': fallback,
'zh-CN': fallback,
}
def _normalize_config_type(self, field_type: str) -> str:
type_map = {
'number': 'float',
'json': 'text',
'textarea': 'text',
}
return type_map.get(field_type, field_type)
def _default_value_for_type(self, field_type: str) -> Any:
if field_type == 'boolean':
return False
if field_type in {'integer', 'float'}:
return 0
if field_type in {'array[string]', 'knowledge-base-multi-selector', 'tools-selector'}:
return []
if field_type == 'model-fallback-selector':
return {'primary': '', 'fallbacks': []}
if field_type == 'prompt-editor':
return [{'role': 'system', 'content': ''}]
return ''
def _prettify_name(self, name: str) -> str:
return ' '.join(part.capitalize() for part in str(name).replace('-', '_').split('_') if part)
# Convenience functions for module-level access
def register_node(node_type: str, node_class: type[WorkflowNode]):
"""Register a node type to the global registry"""
"""Register a node type to the global registry."""
NodeTypeRegistry.instance().register(node_type, node_class)
def get_node_class(node_type: str) -> Optional[type[WorkflowNode]]:
"""Get a node class from the global registry"""
"""Get a node class from the global registry."""
return NodeTypeRegistry.instance().get(node_type)
def list_node_types() -> list[dict[str, Any]]:
"""List all registered node types"""
"""List all registered node types."""
return NodeTypeRegistry.instance().list_all()

View File

@@ -1,9 +1,14 @@
# Call Pipeline Node Configuration
name: call_pipeline
label:
en_US: Call Pipeline
zh_Hans: 调用 Pipeline
category: action
icon: "⚙️"
icon: Workflow
color: '#ef4444'
description: 'workflows.nodes.callPipelineDescription'
description:
en_US: Invoke an existing Pipeline for processing
zh_Hans: 调用现有的 Pipeline 进行处理
inputs:
- name: query

View File

@@ -3,10 +3,15 @@
# The corresponding Python implementation is in: pkg/workflow/nodes/code_executor.py
name: code_executor
label:
en_US: Code Executor
zh_Hans: 代码执行
category: process
icon: "💻"
icon: Code
color: '#3b82f6'
description: 'workflows.nodes.codeExecutorDescription'
description:
en_US: Execute custom code to process data
zh_Hans: 执行自定义代码处理数据
inputs:
- name: input

View File

@@ -3,10 +3,15 @@
# The corresponding Python implementation is in: pkg/workflow/nodes/condition.py
name: condition
label:
en_US: Condition
zh_Hans: 条件分支
category: control
icon: "🔀"
icon: GitBranch
color: '#8b5cf6'
description: 'workflows.nodes.conditionDescription'
description:
en_US: Branch workflow based on a condition
zh_Hans: 根据条件分支工作流
inputs:
- name: input

View File

@@ -3,10 +3,15 @@
# The corresponding Python implementation is in: pkg/workflow/nodes/cron_trigger.py
name: cron_trigger
label:
en_US: Scheduled Trigger
zh_Hans: 定时触发
category: trigger
icon: "⏰"
icon: Timer
color: '#22c55e'
description: 'workflows.nodes.cronTriggerDescription'
description:
en_US: Trigger workflow on a scheduled time
zh_Hans: 按定时计划触发工作流
inputs: []

View File

@@ -3,10 +3,15 @@
# The corresponding Python implementation is in: pkg/workflow/nodes/data_transform.py
name: data_transform
label:
en_US: Data Transform
zh_Hans: 数据转换
category: process
icon: "🔄"
icon: ArrowRightLeft
color: '#3b82f6'
description: 'workflows.nodes.dataTransformDescription'
description:
en_US: Transform data using templates or JSONPath
zh_Hans: 使用模板或 JSONPath 转换数据
inputs:
- name: data

View File

@@ -3,10 +3,15 @@
# The corresponding Python implementation is in: pkg/workflow/nodes/database_query.py
name: database_query
label:
en_US: Database Query
zh_Hans: 数据库查询
category: integration
icon: "🗄️"
icon: Database
color: '#ec4899'
description: 'workflows.nodes.databaseQueryDescription'
description:
en_US: Execute database queries
zh_Hans: 执行数据库查询
inputs:
- name: parameters

View File

@@ -3,10 +3,15 @@
# The corresponding Python implementation is in: pkg/workflow/nodes/end.py
name: end
label:
en_US: End
zh_Hans: 结束
category: control
icon: "🛑"
icon: PauseCircle
color: '#8b5cf6'
description: 'workflows.nodes.endDescription'
description:
en_US: End the workflow execution
zh_Hans: 结束工作流执行
inputs:
- name: input

View File

@@ -3,10 +3,15 @@
# The corresponding Python implementation is in: pkg/workflow/nodes/event_trigger.py
name: event_trigger
label:
en_US: Event Trigger
zh_Hans: 事件触发
category: trigger
icon: "📡"
icon: Bell
color: '#22c55e'
description: 'workflows.nodes.eventTriggerDescription'
description:
en_US: Trigger workflow when a system event occurs
zh_Hans: 当系统事件发生时触发工作流
inputs: []

View File

@@ -3,10 +3,15 @@
# The corresponding Python implementation is in: pkg/workflow/nodes/http_request.py
name: http_request
label:
en_US: HTTP Request
zh_Hans: HTTP 请求
category: action
icon: "🌍"
icon: Globe
color: '#10b981'
description: 'workflows.nodes.httpRequestDescription'
description:
en_US: Make HTTP requests to external APIs
zh_Hans: 向外部 API 发送 HTTP 请求
inputs:
- name: body

View File

@@ -1,9 +1,14 @@
# Iterator Node Configuration
name: iterator
label:
en_US: Iterator
zh_Hans: 迭代器
category: control
icon: "🔄"
icon: Repeat
color: '#f59e0b'
description: 'workflows.nodes.iteratorDescription'
description:
en_US: Iterate over array elements one by one
zh_Hans: 逐个遍历数组元素
inputs:
- name: array

View File

@@ -3,10 +3,15 @@
# The corresponding Python implementation is in: pkg/workflow/nodes/knowledge_retrieval.py
name: knowledge_retrieval
label:
en_US: Knowledge Retrieval
zh_Hans: 知识库检索
category: process
icon: "📚"
icon: Search
color: '#8b5cf6'
description: 'workflows.nodes.knowledgeRetrievalDescription'
description:
en_US: Retrieve relevant information from knowledge bases
zh_Hans: 从知识库中检索相关信息
inputs:
- name: query
@@ -46,7 +51,7 @@ outputs:
config:
- name: knowledge_bases
type: json
type: knowledge-base-multi-selector
required: true
default: []
label:
@@ -101,7 +106,7 @@ config:
zh_Hans: 使用重排序模型提高结果相关性
- name: rerank_model
type: string
type: rerank-model-selector
default: ""
label:
en_US: Rerank Model
@@ -109,3 +114,5 @@ config:
description:
en_US: Model to use for reranking results
zh_Hans: 用于结果重排序的模型
show_if:
rerank_enabled: true

View File

@@ -3,10 +3,15 @@
# The corresponding Python implementation is in: pkg/workflow/nodes/llm_call.py
name: llm_call
label:
en_US: LLM Call
zh_Hans: LLM 调用
category: process
icon: "🤖"
icon: Brain
color: '#8b5cf6'
description: 'workflows.nodes.llmCallDescription'
description:
en_US: Call a large language model to generate responses
zh_Hans: 调用大语言模型生成响应
inputs:
- name: input

View File

@@ -3,10 +3,15 @@
# The corresponding Python implementation is in: pkg/workflow/nodes/loop.py
name: loop
label:
en_US: Loop
zh_Hans: 循环
category: control
icon: "🔁"
icon: Repeat
color: '#8b5cf6'
description: 'workflows.nodes.loopDescription'
description:
en_US: Iterate over items or repeat until condition
zh_Hans: 遍历项目或重复直到满足条件
inputs:
- name: items

View File

@@ -3,10 +3,15 @@
# The corresponding Python implementation is in: pkg/workflow/nodes/mcp_tool.py
name: mcp_tool
label:
en_US: MCP Tool
zh_Hans: MCP 工具
category: integration
icon: 🔧
icon: Wrench
color: '#ec4899'
description: workflows.nodes.mcpToolDescription
description:
en_US: Invoke an MCP (Model Context Protocol) tool
zh_Hans: 调用 MCP 工具
inputs:
- name: arguments
type: object

View File

@@ -3,10 +3,15 @@
# The corresponding Python implementation is in: pkg/workflow/nodes/memory_store.py
name: memory_store
label:
en_US: Memory Store
zh_Hans: 记忆存储
category: integration
icon: "💾"
icon: HardDrive
color: '#ec4899'
description: 'workflows.nodes.memoryStoreDescription'
description:
en_US: Store and retrieve data from workflow memory
zh_Hans: 从工作流记忆中存储和检索数据
inputs:
- name: value

View File

@@ -1,9 +1,14 @@
# Merge Node Configuration
name: merge
label:
en_US: Merge
zh_Hans: 合并
category: control
icon: "🔗"
icon: GitMerge
color: '#f59e0b'
description: 'workflows.nodes.mergeDescription'
description:
en_US: Merge multiple branches back together
zh_Hans: 将多个分支合并在一起
inputs:
- name: input_1

View File

@@ -3,10 +3,15 @@
# The corresponding Python implementation is in: pkg/workflow/nodes/message_trigger.py
name: message_trigger
label:
en_US: Message Trigger
zh_Hans: 消息触发
category: trigger
icon: "💬"
icon: MessageSquare
color: '#22c55e'
description: 'workflows.nodes.messageTriggerDescription'
description:
en_US: Trigger workflow when a message is received
zh_Hans: 当收到消息时触发工作流
inputs: []

View File

@@ -1,9 +1,14 @@
# Opening Statement Node Configuration
name: opening_statement
label:
en_US: Opening Statement
zh_Hans: 对话开场白
category: action
icon: "👋"
icon: MessageSquare
color: '#ef4444'
description: 'workflows.nodes.openingStatementDescription'
description:
en_US: Provide conversation opener and suggested questions
zh_Hans: 提供对话开场白和建议问题
inputs: []

View File

@@ -3,10 +3,15 @@
# The corresponding Python implementation is in: pkg/workflow/nodes/parallel.py
name: parallel
label:
en_US: Parallel
zh_Hans: 并行执行
category: control
icon: "⚡"
icon: Layers
color: '#8b5cf6'
description: 'workflows.nodes.parallelDescription'
description:
en_US: Execute multiple branches in parallel
zh_Hans: 并行执行多个分支
inputs:
- name: input

View File

@@ -3,10 +3,15 @@
# The corresponding Python implementation is in: pkg/workflow/nodes/parameter_extractor.py
name: parameter_extractor
label:
en_US: Parameter Extractor
zh_Hans: 参数提取器
category: process
icon: "🔍"
icon: Variable
color: '#8b5cf6'
description: 'workflows.nodes.parameterExtractorDescription'
description:
en_US: Extract structured parameters from text using AI
zh_Hans: 使用 AI 从文本中提取结构化参数
inputs:
- name: text

View File

@@ -3,10 +3,15 @@
# The corresponding Python implementation is in: pkg/workflow/nodes/question_classifier.py
name: question_classifier
label:
en_US: Question Classifier
zh_Hans: 问题分类器
category: process
icon: "🏷️"
icon: ListFilter
color: '#8b5cf6'
description: 'workflows.nodes.questionClassifierDescription'
description:
en_US: Classify questions into predefined categories using AI
zh_Hans: 使用 AI 将问题分类到预定义类别
inputs:
- name: question

View File

@@ -3,10 +3,15 @@
# The corresponding Python implementation is in: pkg/workflow/nodes/redis_operation.py
name: redis_operation
label:
en_US: Redis Operation
zh_Hans: Redis 操作
category: integration
icon: "🔴"
icon: Server
color: '#ec4899'
description: 'workflows.nodes.redisOperationDescription'
description:
en_US: Perform Redis cache operations
zh_Hans: 执行 Redis 缓存操作
inputs:
- name: key

View File

@@ -1,9 +1,14 @@
# Reply Message Node Configuration
name: reply_message
label:
en_US: Reply Message
zh_Hans: 回复消息
category: action
icon: "↩️"
icon: Send
color: '#ef4444'
description: 'workflows.nodes.replyMessageDescription'
description:
en_US: Reply to the message that triggered the workflow
zh_Hans: 回复触发工作流的消息
inputs:
- name: message

View File

@@ -3,10 +3,15 @@
# The corresponding Python implementation is in: pkg/workflow/nodes/send_message.py
name: send_message
label:
en_US: Send Message
zh_Hans: 发送消息
category: action
icon: "📤"
icon: MessageCircle
color: '#10b981'
description: 'workflows.nodes.sendMessageDescription'
description:
en_US: Send a message to a chat or user
zh_Hans: 向聊天或用户发送消息
inputs:
- name: content

View File

@@ -1,9 +1,14 @@
# Set Variable Node Configuration
name: set_variable
label:
en_US: Set Variable
zh_Hans: 设置变量
category: action
icon: "📝"
icon: Variable
color: '#ef4444'
description: 'workflows.nodes.setVariableDescription'
description:
en_US: Set a context variable value
zh_Hans: 设置上下文变量值
inputs:
- name: value

View File

@@ -1,9 +1,14 @@
# Store Data Node Configuration
name: store_data
label:
en_US: Store Data
zh_Hans: 存储数据
category: action
icon: "💾"
icon: Database
color: '#ef4444'
description: 'workflows.nodes.storeDataDescription'
description:
en_US: Store data to persistent storage
zh_Hans: 将数据存储到持久化存储
inputs:
- name: key

View File

@@ -3,10 +3,15 @@
# The corresponding Python implementation is in: pkg/workflow/nodes/switch.py
name: switch
label:
en_US: Switch
zh_Hans: 多路分支
category: control
icon: "🔀"
icon: Split
color: '#8b5cf6'
description: 'workflows.nodes.switchDescription'
description:
en_US: Branch workflow based on multiple cases
zh_Hans: 根据多个条件分支工作流
inputs:
- name: input

View File

@@ -1,9 +1,14 @@
# Variable Aggregator Node Configuration
name: variable_aggregator
label:
en_US: Variable Aggregator
zh_Hans: 变量聚合器
category: control
icon: "📊"
icon: GitMerge
color: '#f59e0b'
description: 'workflows.nodes.variableAggregatorDescription'
description:
en_US: Aggregate variable outputs from multiple branches
zh_Hans: 聚合多个分支的变量输出
inputs:
- name: variables

View File

@@ -3,10 +3,15 @@
# The corresponding Python implementation is in: pkg/workflow/nodes/wait.py
name: wait
label:
en_US: Wait
zh_Hans: 等待
category: control
icon: "⏳"
icon: Clock
color: '#8b5cf6'
description: 'workflows.nodes.waitDescription'
description:
en_US: Pause workflow execution for a specified duration
zh_Hans: 暂停工作流执行指定时间
inputs:
- name: input

View File

@@ -3,10 +3,15 @@
# The corresponding Python implementation is in: pkg/workflow/nodes/webhook_trigger.py
name: webhook_trigger
label:
en_US: Webhook Trigger
zh_Hans: Webhook 触发
category: trigger
icon: "🌐"
icon: Webhook
color: '#22c55e'
description: 'workflows.nodes.webhookTriggerDescription'
description:
en_US: Trigger workflow via HTTP webhook
zh_Hans: 通过 HTTP 请求触发工作流
inputs: []