mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-04 21:06:03 +00:00
backend
This commit is contained in:
@@ -567,58 +567,30 @@ class WorkflowService:
|
||||
return result
|
||||
|
||||
def _enrich_node_type_configs(self, node_types: list[dict]) -> list[dict]:
|
||||
"""Enrich node schemas with node YAML config metadata if available"""
|
||||
# Get workflow node configs loaded from YAML files
|
||||
workflow_node_configs = getattr(self.ap, 'workflow_node_configs', {})
|
||||
"""Enrich node schemas with pipeline config metadata if available.
|
||||
|
||||
Dedicated workflow node YAML metadata is already applied by
|
||||
NodeTypeRegistry before this method runs. This method only keeps the
|
||||
older pipeline metadata reuse path for nodes that explicitly request it.
|
||||
"""
|
||||
for node_schema in node_types:
|
||||
node_type = node_schema.get('type', '')
|
||||
node_schema.setdefault('config_schema', [])
|
||||
|
||||
# First, try to load config from dedicated node YAML file
|
||||
if node_type in workflow_node_configs:
|
||||
node_config = workflow_node_configs[node_type]
|
||||
|
||||
# Enrich inputs from YAML if Python class has empty inputs
|
||||
yaml_inputs = node_config.get('inputs', [])
|
||||
if yaml_inputs and not node_schema.get('inputs'):
|
||||
node_schema['inputs'] = [self._normalize_port_item(inp) for inp in yaml_inputs]
|
||||
|
||||
# Enrich outputs from YAML if Python class has empty outputs
|
||||
yaml_outputs = node_config.get('outputs', [])
|
||||
if yaml_outputs and not node_schema.get('outputs'):
|
||||
node_schema['outputs'] = [self._normalize_port_item(out) for out in yaml_outputs]
|
||||
|
||||
# Enrich config_schema from YAML
|
||||
yaml_configs = node_config.get('config', [])
|
||||
|
||||
# Normalize and add YAML configs to config_schema
|
||||
existing_names = {cfg.get('name') for cfg in node_schema.get('config_schema', [])}
|
||||
for cfg in yaml_configs:
|
||||
if cfg.get('name') not in existing_names:
|
||||
normalized_cfg = self._normalize_config_item(cfg)
|
||||
node_schema['config_schema'].append(normalized_cfg)
|
||||
existing_names.add(cfg.get('name'))
|
||||
|
||||
# Second, try to load from pipeline config metadata (for backward compatibility)
|
||||
config_schema_source = node_schema.get('config_schema_source')
|
||||
config_stages = node_schema.get('config_stages', [])
|
||||
|
||||
if config_schema_source and config_stages:
|
||||
# Get pipeline config metadata
|
||||
pipeline_meta = self._get_pipeline_config_meta(config_schema_source)
|
||||
if pipeline_meta:
|
||||
# Extract config items from specified stages
|
||||
enriched_configs = []
|
||||
for stage_name in config_stages:
|
||||
stage_data = self._find_stage_in_pipeline(pipeline_meta, stage_name)
|
||||
if stage_data and 'config' in stage_data:
|
||||
enriched_configs.extend(stage_data['config'])
|
||||
|
||||
# Merge with existing config_schema (avoid duplicates by name)
|
||||
existing_names = {cfg.get('name') for cfg in node_schema.get('config_schema', [])}
|
||||
for cfg in enriched_configs:
|
||||
if cfg.get('name') not in existing_names:
|
||||
# Normalize config item format for frontend compatibility
|
||||
normalized_cfg = self._normalize_config_item(cfg)
|
||||
node_schema['config_schema'].append(normalized_cfg)
|
||||
existing_names.add(cfg.get('name'))
|
||||
|
||||
@@ -222,51 +222,35 @@ class LoadConfigStage(stage.BootingStage):
|
||||
ap.pipeline_config_meta_ai = await load_resource_yaml_template_data('metadata/pipeline/ai.yaml')
|
||||
ap.pipeline_config_meta_output = await load_resource_yaml_template_data('metadata/pipeline/output.yaml')
|
||||
|
||||
# Load workflow node configurations from YAML files
|
||||
ap.workflow_node_configs = {}
|
||||
node_config_files = [
|
||||
# Trigger nodes
|
||||
'metadata/nodes/message_trigger.yaml',
|
||||
'metadata/nodes/cron_trigger.yaml',
|
||||
'metadata/nodes/webhook_trigger.yaml',
|
||||
'metadata/nodes/event_trigger.yaml',
|
||||
# AI/Process nodes
|
||||
'metadata/nodes/llm_call.yaml',
|
||||
'metadata/nodes/question_classifier.yaml',
|
||||
'metadata/nodes/parameter_extractor.yaml',
|
||||
'metadata/nodes/knowledge_retrieval.yaml',
|
||||
'metadata/nodes/code_executor.yaml',
|
||||
'metadata/nodes/data_transform.yaml',
|
||||
# Control nodes
|
||||
'metadata/nodes/condition.yaml',
|
||||
'metadata/nodes/switch.yaml',
|
||||
'metadata/nodes/loop.yaml',
|
||||
'metadata/nodes/parallel.yaml',
|
||||
'metadata/nodes/wait.yaml',
|
||||
'metadata/nodes/end.yaml',
|
||||
# Action nodes
|
||||
'metadata/nodes/send_message.yaml',
|
||||
'metadata/nodes/http_request.yaml',
|
||||
# Integration nodes - Data & Tools
|
||||
'metadata/nodes/database_query.yaml',
|
||||
'metadata/nodes/redis_operation.yaml',
|
||||
'metadata/nodes/mcp_tool.yaml',
|
||||
'metadata/nodes/memory_store.yaml',
|
||||
# Integration nodes - External services
|
||||
'metadata/nodes/dify_workflow.yaml',
|
||||
'metadata/nodes/dify_knowledge_query.yaml',
|
||||
'metadata/nodes/n8n_workflow.yaml',
|
||||
'metadata/nodes/langflow_flow.yaml',
|
||||
'metadata/nodes/coze_bot.yaml',
|
||||
]
|
||||
for config_file in node_config_files:
|
||||
try:
|
||||
node_config = await load_resource_yaml_template_data(config_file)
|
||||
node_name = node_config.get('name')
|
||||
node_category = node_config.get('category', 'misc')
|
||||
if node_name:
|
||||
# Use category.name format to match node type format (e.g., integration.coze_bot)
|
||||
full_type = f'{node_category}.{node_name}'
|
||||
ap.workflow_node_configs[full_type] = node_config
|
||||
except Exception as e:
|
||||
print(f'Failed to load node config {config_file}: {e}')
|
||||
# Load workflow node metadata from YAML files. YAML is the source of
|
||||
# truth for workflow editor metadata; Python classes provide execution
|
||||
# logic and are bound through the registry.
|
||||
from langbot.pkg.workflow.metadata import NodeMetadataLoader
|
||||
from langbot.pkg.workflow.registry import NodeTypeRegistry
|
||||
|
||||
workflow_metadata_loader = NodeMetadataLoader()
|
||||
workflow_node_count = await workflow_metadata_loader.load_core_metadata()
|
||||
ap.workflow_node_configs = workflow_metadata_loader.get_all_metadata()
|
||||
ap.workflow_node_metadata_loader = workflow_metadata_loader
|
||||
|
||||
workflow_registry = NodeTypeRegistry.instance()
|
||||
for node_config in ap.workflow_node_configs.values():
|
||||
workflow_registry.register_metadata(node_config, source=node_config.get('_source', 'core'))
|
||||
|
||||
# Import node modules after metadata registration so decorators can bind
|
||||
# implementations to YAML-defined canonical node types.
|
||||
from langbot.pkg.workflow import nodes as workflow_nodes # noqa: F401
|
||||
|
||||
workflow_registry.process_pending_registrations()
|
||||
|
||||
workflow_load_errors = workflow_metadata_loader.get_load_errors()
|
||||
if workflow_load_errors:
|
||||
print(f'Workflow node metadata load errors: {len(workflow_load_errors)}')
|
||||
for error in workflow_load_errors:
|
||||
print(f" - {error.get('file')}: {error.get('error')}")
|
||||
|
||||
print(
|
||||
f'Loaded {workflow_node_count} workflow node metadata files; '
|
||||
f'registered {workflow_registry.metadata_count()} metadata definitions, '
|
||||
f'{workflow_registry.count()} node types'
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
# Call Pipeline Node Configuration
|
||||
name: call_pipeline
|
||||
label:
|
||||
en_US: Call Pipeline
|
||||
zh_Hans: 调用 Pipeline
|
||||
category: action
|
||||
icon: "⚙️"
|
||||
icon: Workflow
|
||||
color: '#ef4444'
|
||||
description: 'workflows.nodes.callPipelineDescription'
|
||||
description:
|
||||
en_US: Invoke an existing Pipeline for processing
|
||||
zh_Hans: 调用现有的 Pipeline 进行处理
|
||||
|
||||
inputs:
|
||||
- name: query
|
||||
|
||||
@@ -3,10 +3,15 @@
|
||||
# The corresponding Python implementation is in: pkg/workflow/nodes/code_executor.py
|
||||
|
||||
name: code_executor
|
||||
label:
|
||||
en_US: Code Executor
|
||||
zh_Hans: 代码执行
|
||||
category: process
|
||||
icon: "💻"
|
||||
icon: Code
|
||||
color: '#3b82f6'
|
||||
description: 'workflows.nodes.codeExecutorDescription'
|
||||
description:
|
||||
en_US: Execute custom code to process data
|
||||
zh_Hans: 执行自定义代码处理数据
|
||||
|
||||
inputs:
|
||||
- name: input
|
||||
|
||||
@@ -3,10 +3,15 @@
|
||||
# The corresponding Python implementation is in: pkg/workflow/nodes/condition.py
|
||||
|
||||
name: condition
|
||||
label:
|
||||
en_US: Condition
|
||||
zh_Hans: 条件分支
|
||||
category: control
|
||||
icon: "🔀"
|
||||
icon: GitBranch
|
||||
color: '#8b5cf6'
|
||||
description: 'workflows.nodes.conditionDescription'
|
||||
description:
|
||||
en_US: Branch workflow based on a condition
|
||||
zh_Hans: 根据条件分支工作流
|
||||
|
||||
inputs:
|
||||
- name: input
|
||||
|
||||
@@ -3,10 +3,15 @@
|
||||
# The corresponding Python implementation is in: pkg/workflow/nodes/cron_trigger.py
|
||||
|
||||
name: cron_trigger
|
||||
label:
|
||||
en_US: Scheduled Trigger
|
||||
zh_Hans: 定时触发
|
||||
category: trigger
|
||||
icon: "⏰"
|
||||
icon: Timer
|
||||
color: '#22c55e'
|
||||
description: 'workflows.nodes.cronTriggerDescription'
|
||||
description:
|
||||
en_US: Trigger workflow on a scheduled time
|
||||
zh_Hans: 按定时计划触发工作流
|
||||
|
||||
inputs: []
|
||||
|
||||
|
||||
@@ -3,10 +3,15 @@
|
||||
# The corresponding Python implementation is in: pkg/workflow/nodes/data_transform.py
|
||||
|
||||
name: data_transform
|
||||
label:
|
||||
en_US: Data Transform
|
||||
zh_Hans: 数据转换
|
||||
category: process
|
||||
icon: "🔄"
|
||||
icon: ArrowRightLeft
|
||||
color: '#3b82f6'
|
||||
description: 'workflows.nodes.dataTransformDescription'
|
||||
description:
|
||||
en_US: Transform data using templates or JSONPath
|
||||
zh_Hans: 使用模板或 JSONPath 转换数据
|
||||
|
||||
inputs:
|
||||
- name: data
|
||||
|
||||
@@ -3,10 +3,15 @@
|
||||
# The corresponding Python implementation is in: pkg/workflow/nodes/database_query.py
|
||||
|
||||
name: database_query
|
||||
label:
|
||||
en_US: Database Query
|
||||
zh_Hans: 数据库查询
|
||||
category: integration
|
||||
icon: "🗄️"
|
||||
icon: Database
|
||||
color: '#ec4899'
|
||||
description: 'workflows.nodes.databaseQueryDescription'
|
||||
description:
|
||||
en_US: Execute database queries
|
||||
zh_Hans: 执行数据库查询
|
||||
|
||||
inputs:
|
||||
- name: parameters
|
||||
|
||||
@@ -3,10 +3,15 @@
|
||||
# The corresponding Python implementation is in: pkg/workflow/nodes/end.py
|
||||
|
||||
name: end
|
||||
label:
|
||||
en_US: End
|
||||
zh_Hans: 结束
|
||||
category: control
|
||||
icon: "🛑"
|
||||
icon: PauseCircle
|
||||
color: '#8b5cf6'
|
||||
description: 'workflows.nodes.endDescription'
|
||||
description:
|
||||
en_US: End the workflow execution
|
||||
zh_Hans: 结束工作流执行
|
||||
|
||||
inputs:
|
||||
- name: input
|
||||
|
||||
@@ -3,10 +3,15 @@
|
||||
# The corresponding Python implementation is in: pkg/workflow/nodes/event_trigger.py
|
||||
|
||||
name: event_trigger
|
||||
label:
|
||||
en_US: Event Trigger
|
||||
zh_Hans: 事件触发
|
||||
category: trigger
|
||||
icon: "📡"
|
||||
icon: Bell
|
||||
color: '#22c55e'
|
||||
description: 'workflows.nodes.eventTriggerDescription'
|
||||
description:
|
||||
en_US: Trigger workflow when a system event occurs
|
||||
zh_Hans: 当系统事件发生时触发工作流
|
||||
|
||||
inputs: []
|
||||
|
||||
|
||||
@@ -3,10 +3,15 @@
|
||||
# The corresponding Python implementation is in: pkg/workflow/nodes/http_request.py
|
||||
|
||||
name: http_request
|
||||
label:
|
||||
en_US: HTTP Request
|
||||
zh_Hans: HTTP 请求
|
||||
category: action
|
||||
icon: "🌍"
|
||||
icon: Globe
|
||||
color: '#10b981'
|
||||
description: 'workflows.nodes.httpRequestDescription'
|
||||
description:
|
||||
en_US: Make HTTP requests to external APIs
|
||||
zh_Hans: 向外部 API 发送 HTTP 请求
|
||||
|
||||
inputs:
|
||||
- name: body
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
# Iterator Node Configuration
|
||||
name: iterator
|
||||
label:
|
||||
en_US: Iterator
|
||||
zh_Hans: 迭代器
|
||||
category: control
|
||||
icon: "🔄"
|
||||
icon: Repeat
|
||||
color: '#f59e0b'
|
||||
description: 'workflows.nodes.iteratorDescription'
|
||||
description:
|
||||
en_US: Iterate over array elements one by one
|
||||
zh_Hans: 逐个遍历数组元素
|
||||
|
||||
inputs:
|
||||
- name: array
|
||||
|
||||
@@ -3,10 +3,15 @@
|
||||
# The corresponding Python implementation is in: pkg/workflow/nodes/knowledge_retrieval.py
|
||||
|
||||
name: knowledge_retrieval
|
||||
label:
|
||||
en_US: Knowledge Retrieval
|
||||
zh_Hans: 知识库检索
|
||||
category: process
|
||||
icon: "📚"
|
||||
icon: Search
|
||||
color: '#8b5cf6'
|
||||
description: 'workflows.nodes.knowledgeRetrievalDescription'
|
||||
description:
|
||||
en_US: Retrieve relevant information from knowledge bases
|
||||
zh_Hans: 从知识库中检索相关信息
|
||||
|
||||
inputs:
|
||||
- name: query
|
||||
@@ -46,7 +51,7 @@ outputs:
|
||||
|
||||
config:
|
||||
- name: knowledge_bases
|
||||
type: json
|
||||
type: knowledge-base-multi-selector
|
||||
required: true
|
||||
default: []
|
||||
label:
|
||||
@@ -101,7 +106,7 @@ config:
|
||||
zh_Hans: 使用重排序模型提高结果相关性
|
||||
|
||||
- name: rerank_model
|
||||
type: string
|
||||
type: rerank-model-selector
|
||||
default: ""
|
||||
label:
|
||||
en_US: Rerank Model
|
||||
@@ -109,3 +114,5 @@ config:
|
||||
description:
|
||||
en_US: Model to use for reranking results
|
||||
zh_Hans: 用于结果重排序的模型
|
||||
show_if:
|
||||
rerank_enabled: true
|
||||
|
||||
@@ -3,10 +3,15 @@
|
||||
# The corresponding Python implementation is in: pkg/workflow/nodes/llm_call.py
|
||||
|
||||
name: llm_call
|
||||
label:
|
||||
en_US: LLM Call
|
||||
zh_Hans: LLM 调用
|
||||
category: process
|
||||
icon: "🤖"
|
||||
icon: Brain
|
||||
color: '#8b5cf6'
|
||||
description: 'workflows.nodes.llmCallDescription'
|
||||
description:
|
||||
en_US: Call a large language model to generate responses
|
||||
zh_Hans: 调用大语言模型生成响应
|
||||
|
||||
inputs:
|
||||
- name: input
|
||||
|
||||
@@ -3,10 +3,15 @@
|
||||
# The corresponding Python implementation is in: pkg/workflow/nodes/loop.py
|
||||
|
||||
name: loop
|
||||
label:
|
||||
en_US: Loop
|
||||
zh_Hans: 循环
|
||||
category: control
|
||||
icon: "🔁"
|
||||
icon: Repeat
|
||||
color: '#8b5cf6'
|
||||
description: 'workflows.nodes.loopDescription'
|
||||
description:
|
||||
en_US: Iterate over items or repeat until condition
|
||||
zh_Hans: 遍历项目或重复直到满足条件
|
||||
|
||||
inputs:
|
||||
- name: items
|
||||
|
||||
@@ -3,10 +3,15 @@
|
||||
# The corresponding Python implementation is in: pkg/workflow/nodes/mcp_tool.py
|
||||
|
||||
name: mcp_tool
|
||||
label:
|
||||
en_US: MCP Tool
|
||||
zh_Hans: MCP 工具
|
||||
category: integration
|
||||
icon: 🔧
|
||||
icon: Wrench
|
||||
color: '#ec4899'
|
||||
description: workflows.nodes.mcpToolDescription
|
||||
description:
|
||||
en_US: Invoke an MCP (Model Context Protocol) tool
|
||||
zh_Hans: 调用 MCP 工具
|
||||
inputs:
|
||||
- name: arguments
|
||||
type: object
|
||||
|
||||
@@ -3,10 +3,15 @@
|
||||
# The corresponding Python implementation is in: pkg/workflow/nodes/memory_store.py
|
||||
|
||||
name: memory_store
|
||||
label:
|
||||
en_US: Memory Store
|
||||
zh_Hans: 记忆存储
|
||||
category: integration
|
||||
icon: "💾"
|
||||
icon: HardDrive
|
||||
color: '#ec4899'
|
||||
description: 'workflows.nodes.memoryStoreDescription'
|
||||
description:
|
||||
en_US: Store and retrieve data from workflow memory
|
||||
zh_Hans: 从工作流记忆中存储和检索数据
|
||||
|
||||
inputs:
|
||||
- name: value
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
# Merge Node Configuration
|
||||
name: merge
|
||||
label:
|
||||
en_US: Merge
|
||||
zh_Hans: 合并
|
||||
category: control
|
||||
icon: "🔗"
|
||||
icon: GitMerge
|
||||
color: '#f59e0b'
|
||||
description: 'workflows.nodes.mergeDescription'
|
||||
description:
|
||||
en_US: Merge multiple branches back together
|
||||
zh_Hans: 将多个分支合并在一起
|
||||
|
||||
inputs:
|
||||
- name: input_1
|
||||
|
||||
@@ -3,10 +3,15 @@
|
||||
# The corresponding Python implementation is in: pkg/workflow/nodes/message_trigger.py
|
||||
|
||||
name: message_trigger
|
||||
label:
|
||||
en_US: Message Trigger
|
||||
zh_Hans: 消息触发
|
||||
category: trigger
|
||||
icon: "💬"
|
||||
icon: MessageSquare
|
||||
color: '#22c55e'
|
||||
description: 'workflows.nodes.messageTriggerDescription'
|
||||
description:
|
||||
en_US: Trigger workflow when a message is received
|
||||
zh_Hans: 当收到消息时触发工作流
|
||||
|
||||
inputs: []
|
||||
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
# Opening Statement Node Configuration
|
||||
name: opening_statement
|
||||
label:
|
||||
en_US: Opening Statement
|
||||
zh_Hans: 对话开场白
|
||||
category: action
|
||||
icon: "👋"
|
||||
icon: MessageSquare
|
||||
color: '#ef4444'
|
||||
description: 'workflows.nodes.openingStatementDescription'
|
||||
description:
|
||||
en_US: Provide conversation opener and suggested questions
|
||||
zh_Hans: 提供对话开场白和建议问题
|
||||
|
||||
inputs: []
|
||||
|
||||
|
||||
@@ -3,10 +3,15 @@
|
||||
# The corresponding Python implementation is in: pkg/workflow/nodes/parallel.py
|
||||
|
||||
name: parallel
|
||||
label:
|
||||
en_US: Parallel
|
||||
zh_Hans: 并行执行
|
||||
category: control
|
||||
icon: "⚡"
|
||||
icon: Layers
|
||||
color: '#8b5cf6'
|
||||
description: 'workflows.nodes.parallelDescription'
|
||||
description:
|
||||
en_US: Execute multiple branches in parallel
|
||||
zh_Hans: 并行执行多个分支
|
||||
|
||||
inputs:
|
||||
- name: input
|
||||
|
||||
@@ -3,10 +3,15 @@
|
||||
# The corresponding Python implementation is in: pkg/workflow/nodes/parameter_extractor.py
|
||||
|
||||
name: parameter_extractor
|
||||
label:
|
||||
en_US: Parameter Extractor
|
||||
zh_Hans: 参数提取器
|
||||
category: process
|
||||
icon: "🔍"
|
||||
icon: Variable
|
||||
color: '#8b5cf6'
|
||||
description: 'workflows.nodes.parameterExtractorDescription'
|
||||
description:
|
||||
en_US: Extract structured parameters from text using AI
|
||||
zh_Hans: 使用 AI 从文本中提取结构化参数
|
||||
|
||||
inputs:
|
||||
- name: text
|
||||
|
||||
@@ -3,10 +3,15 @@
|
||||
# The corresponding Python implementation is in: pkg/workflow/nodes/question_classifier.py
|
||||
|
||||
name: question_classifier
|
||||
label:
|
||||
en_US: Question Classifier
|
||||
zh_Hans: 问题分类器
|
||||
category: process
|
||||
icon: "🏷️"
|
||||
icon: ListFilter
|
||||
color: '#8b5cf6'
|
||||
description: 'workflows.nodes.questionClassifierDescription'
|
||||
description:
|
||||
en_US: Classify questions into predefined categories using AI
|
||||
zh_Hans: 使用 AI 将问题分类到预定义类别
|
||||
|
||||
inputs:
|
||||
- name: question
|
||||
|
||||
@@ -3,10 +3,15 @@
|
||||
# The corresponding Python implementation is in: pkg/workflow/nodes/redis_operation.py
|
||||
|
||||
name: redis_operation
|
||||
label:
|
||||
en_US: Redis Operation
|
||||
zh_Hans: Redis 操作
|
||||
category: integration
|
||||
icon: "🔴"
|
||||
icon: Server
|
||||
color: '#ec4899'
|
||||
description: 'workflows.nodes.redisOperationDescription'
|
||||
description:
|
||||
en_US: Perform Redis cache operations
|
||||
zh_Hans: 执行 Redis 缓存操作
|
||||
|
||||
inputs:
|
||||
- name: key
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
# Reply Message Node Configuration
|
||||
name: reply_message
|
||||
label:
|
||||
en_US: Reply Message
|
||||
zh_Hans: 回复消息
|
||||
category: action
|
||||
icon: "↩️"
|
||||
icon: Send
|
||||
color: '#ef4444'
|
||||
description: 'workflows.nodes.replyMessageDescription'
|
||||
description:
|
||||
en_US: Reply to the message that triggered the workflow
|
||||
zh_Hans: 回复触发工作流的消息
|
||||
|
||||
inputs:
|
||||
- name: message
|
||||
|
||||
@@ -3,10 +3,15 @@
|
||||
# The corresponding Python implementation is in: pkg/workflow/nodes/send_message.py
|
||||
|
||||
name: send_message
|
||||
label:
|
||||
en_US: Send Message
|
||||
zh_Hans: 发送消息
|
||||
category: action
|
||||
icon: "📤"
|
||||
icon: MessageCircle
|
||||
color: '#10b981'
|
||||
description: 'workflows.nodes.sendMessageDescription'
|
||||
description:
|
||||
en_US: Send a message to a chat or user
|
||||
zh_Hans: 向聊天或用户发送消息
|
||||
|
||||
inputs:
|
||||
- name: content
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
# Set Variable Node Configuration
|
||||
name: set_variable
|
||||
label:
|
||||
en_US: Set Variable
|
||||
zh_Hans: 设置变量
|
||||
category: action
|
||||
icon: "📝"
|
||||
icon: Variable
|
||||
color: '#ef4444'
|
||||
description: 'workflows.nodes.setVariableDescription'
|
||||
description:
|
||||
en_US: Set a context variable value
|
||||
zh_Hans: 设置上下文变量值
|
||||
|
||||
inputs:
|
||||
- name: value
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
# Store Data Node Configuration
|
||||
name: store_data
|
||||
label:
|
||||
en_US: Store Data
|
||||
zh_Hans: 存储数据
|
||||
category: action
|
||||
icon: "💾"
|
||||
icon: Database
|
||||
color: '#ef4444'
|
||||
description: 'workflows.nodes.storeDataDescription'
|
||||
description:
|
||||
en_US: Store data to persistent storage
|
||||
zh_Hans: 将数据存储到持久化存储
|
||||
|
||||
inputs:
|
||||
- name: key
|
||||
|
||||
@@ -3,10 +3,15 @@
|
||||
# The corresponding Python implementation is in: pkg/workflow/nodes/switch.py
|
||||
|
||||
name: switch
|
||||
label:
|
||||
en_US: Switch
|
||||
zh_Hans: 多路分支
|
||||
category: control
|
||||
icon: "🔀"
|
||||
icon: Split
|
||||
color: '#8b5cf6'
|
||||
description: 'workflows.nodes.switchDescription'
|
||||
description:
|
||||
en_US: Branch workflow based on multiple cases
|
||||
zh_Hans: 根据多个条件分支工作流
|
||||
|
||||
inputs:
|
||||
- name: input
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
# Variable Aggregator Node Configuration
|
||||
name: variable_aggregator
|
||||
label:
|
||||
en_US: Variable Aggregator
|
||||
zh_Hans: 变量聚合器
|
||||
category: control
|
||||
icon: "📊"
|
||||
icon: GitMerge
|
||||
color: '#f59e0b'
|
||||
description: 'workflows.nodes.variableAggregatorDescription'
|
||||
description:
|
||||
en_US: Aggregate variable outputs from multiple branches
|
||||
zh_Hans: 聚合多个分支的变量输出
|
||||
|
||||
inputs:
|
||||
- name: variables
|
||||
|
||||
@@ -3,10 +3,15 @@
|
||||
# The corresponding Python implementation is in: pkg/workflow/nodes/wait.py
|
||||
|
||||
name: wait
|
||||
label:
|
||||
en_US: Wait
|
||||
zh_Hans: 等待
|
||||
category: control
|
||||
icon: "⏳"
|
||||
icon: Clock
|
||||
color: '#8b5cf6'
|
||||
description: 'workflows.nodes.waitDescription'
|
||||
description:
|
||||
en_US: Pause workflow execution for a specified duration
|
||||
zh_Hans: 暂停工作流执行指定时间
|
||||
|
||||
inputs:
|
||||
- name: input
|
||||
|
||||
@@ -3,10 +3,15 @@
|
||||
# The corresponding Python implementation is in: pkg/workflow/nodes/webhook_trigger.py
|
||||
|
||||
name: webhook_trigger
|
||||
label:
|
||||
en_US: Webhook Trigger
|
||||
zh_Hans: Webhook 触发
|
||||
category: trigger
|
||||
icon: "🌐"
|
||||
icon: Webhook
|
||||
color: '#22c55e'
|
||||
description: 'workflows.nodes.webhookTriggerDescription'
|
||||
description:
|
||||
en_US: Trigger workflow via HTTP webhook
|
||||
zh_Hans: 通过 HTTP 请求触发工作流
|
||||
|
||||
inputs: []
|
||||
|
||||
|
||||
Reference in New Issue
Block a user