mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-09 07:16:04 +00:00
backend
This commit is contained in:
@@ -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',
|
||||
|
||||
283
src/langbot/pkg/workflow/metadata.py
Normal file
283
src/langbot/pkg/workflow/metadata.py
Normal 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}'
|
||||
@@ -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')
|
||||
|
||||
@@ -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]] = []
|
||||
|
||||
|
||||
@@ -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', '')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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', '')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', [])
|
||||
|
||||
@@ -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', '')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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', [])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', '')
|
||||
|
||||
@@ -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', '')
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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', [])
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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', [])
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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}'}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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', '')
|
||||
|
||||
@@ -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', '')
|
||||
|
||||
@@ -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', {})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user