mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-07 14:26:03 +00:00
后端没修完版
This commit is contained in:
53
src/langbot/pkg/workflow/__init__.py
Normal file
53
src/langbot/pkg/workflow/__init__.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""Workflow package for LangBot
|
||||
|
||||
This package provides a visual workflow system for LangBot, including:
|
||||
- Workflow definition models
|
||||
- Execution engine
|
||||
- Node types (trigger, process, control, action, integration)
|
||||
- Trigger system for automation
|
||||
"""
|
||||
|
||||
from .entities import (
|
||||
WorkflowDefinition,
|
||||
NodeDefinition,
|
||||
EdgeDefinition,
|
||||
Position,
|
||||
PortDefinition,
|
||||
TriggerDefinition,
|
||||
WorkflowSettings,
|
||||
ExecutionContext,
|
||||
NodeState,
|
||||
ExecutionStatus,
|
||||
NodeStatus,
|
||||
)
|
||||
|
||||
from .node import WorkflowNode, NodePort, NodeConfig, workflow_node
|
||||
from .registry import NodeTypeRegistry
|
||||
from .executor import WorkflowExecutor
|
||||
|
||||
# Import nodes module to trigger node registration
|
||||
from . import nodes as nodes
|
||||
|
||||
__all__ = [
|
||||
# Entities
|
||||
'WorkflowDefinition',
|
||||
'NodeDefinition',
|
||||
'EdgeDefinition',
|
||||
'Position',
|
||||
'PortDefinition',
|
||||
'TriggerDefinition',
|
||||
'WorkflowSettings',
|
||||
'ExecutionContext',
|
||||
'NodeState',
|
||||
'ExecutionStatus',
|
||||
'NodeStatus',
|
||||
# Node
|
||||
'WorkflowNode',
|
||||
'NodePort',
|
||||
'NodeConfig',
|
||||
'workflow_node',
|
||||
# Registry
|
||||
'NodeTypeRegistry',
|
||||
# Executor
|
||||
'WorkflowExecutor',
|
||||
]
|
||||
278
src/langbot/pkg/workflow/entities.py
Normal file
278
src/langbot/pkg/workflow/entities.py
Normal file
@@ -0,0 +1,278 @@
|
||||
"""Workflow entities and data models"""
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
import pydantic
|
||||
|
||||
|
||||
class Position(pydantic.BaseModel):
|
||||
"""Node position on canvas"""
|
||||
x: float = 0
|
||||
y: float = 0
|
||||
|
||||
|
||||
class PortDefinition(pydantic.BaseModel):
|
||||
"""Node port definition"""
|
||||
name: str
|
||||
type: str = "any" # any, string, number, boolean, object, array
|
||||
description: str = ""
|
||||
required: bool = True
|
||||
|
||||
|
||||
class NodeDefinition(pydantic.BaseModel):
|
||||
"""Workflow node definition"""
|
||||
id: str
|
||||
type: str
|
||||
name: str = ""
|
||||
position: Position = Position()
|
||||
config: dict[str, Any] = {}
|
||||
inputs: list[PortDefinition] = []
|
||||
outputs: list[PortDefinition] = []
|
||||
|
||||
# UI metadata
|
||||
description: str = ""
|
||||
comment: str = "" # User comment/annotation
|
||||
|
||||
|
||||
class EdgeDefinition(pydantic.BaseModel):
|
||||
"""Workflow edge definition (connection between nodes)"""
|
||||
id: str
|
||||
source_node: str
|
||||
source_port: str = "output"
|
||||
target_node: str
|
||||
target_port: str = "input"
|
||||
condition: Optional[str] = None # Optional condition expression
|
||||
|
||||
|
||||
class TriggerDefinition(pydantic.BaseModel):
|
||||
"""Workflow trigger definition"""
|
||||
id: str
|
||||
type: str # message, cron, event, webhook
|
||||
config: dict[str, Any] = {}
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class WorkflowSettings(pydantic.BaseModel):
|
||||
"""Workflow settings"""
|
||||
# Execution settings
|
||||
max_execution_time: int = 300 # seconds
|
||||
max_retries: int = 3
|
||||
retry_delay: int = 5 # seconds
|
||||
|
||||
# Error handling
|
||||
error_handling: str = "stop" # stop, continue, retry
|
||||
|
||||
# Logging
|
||||
log_level: str = "info"
|
||||
save_execution_history: bool = True
|
||||
|
||||
# Concurrency
|
||||
max_concurrent_executions: int = 10
|
||||
|
||||
|
||||
class SafetyConfig(pydantic.BaseModel):
|
||||
"""Safety configuration (inherited from Pipeline)"""
|
||||
content_filter: dict[str, Any] = {
|
||||
"enable": False,
|
||||
"sensitive_words": [],
|
||||
"replace_with": "***"
|
||||
}
|
||||
rate_limit: dict[str, Any] = {
|
||||
"enable": False,
|
||||
"requests_per_minute": 60,
|
||||
"burst_limit": 10
|
||||
}
|
||||
|
||||
|
||||
class OutputConfig(pydantic.BaseModel):
|
||||
"""Output configuration (inherited from Pipeline)"""
|
||||
long_text_processing: dict[str, Any] = {
|
||||
"strategy": "split", # split, truncate, file
|
||||
"max_length": 4000,
|
||||
"split_separator": "\n\n"
|
||||
}
|
||||
force_delay: dict[str, Any] = {
|
||||
"enable": False,
|
||||
"min_delay_ms": 0,
|
||||
"max_delay_ms": 0
|
||||
}
|
||||
misc: dict[str, Any] = {}
|
||||
|
||||
|
||||
class WorkflowGlobalConfig(pydantic.BaseModel):
|
||||
"""Workflow global configuration (inherited from Pipeline capabilities)"""
|
||||
safety: SafetyConfig = SafetyConfig()
|
||||
output: OutputConfig = OutputConfig()
|
||||
|
||||
|
||||
class ExtensionsPreferences(pydantic.BaseModel):
|
||||
"""Extensions preferences (same as Pipeline)"""
|
||||
enable_all_plugins: bool = True
|
||||
enable_all_mcp_servers: bool = True
|
||||
plugins: list[str] = []
|
||||
mcp_servers: list[str] = []
|
||||
|
||||
|
||||
class ConversationVariable(pydantic.BaseModel):
|
||||
"""Conversation-level variable definition"""
|
||||
name: str
|
||||
type: str = "string" # string, number, boolean, object, array
|
||||
description: str = ""
|
||||
default_value: Any = None
|
||||
max_length: Optional[int] = None # For strings
|
||||
|
||||
|
||||
class WorkflowDefinition(pydantic.BaseModel):
|
||||
"""Complete workflow definition"""
|
||||
uuid: str
|
||||
name: str
|
||||
description: str = ""
|
||||
emoji: str = "🔄"
|
||||
version: int = 1
|
||||
|
||||
# Workflow graph
|
||||
nodes: list[NodeDefinition] = []
|
||||
edges: list[EdgeDefinition] = []
|
||||
|
||||
# Variables
|
||||
variables: dict[str, Any] = {} # Global variables
|
||||
conversation_variables: list[ConversationVariable] = [] # Session-level variables
|
||||
|
||||
# Settings
|
||||
settings: WorkflowSettings = WorkflowSettings()
|
||||
|
||||
# Triggers (for automation)
|
||||
triggers: list[TriggerDefinition] = []
|
||||
|
||||
# Global configuration (inherited from Pipeline)
|
||||
global_config: WorkflowGlobalConfig = WorkflowGlobalConfig()
|
||||
|
||||
# Extensions
|
||||
extensions_preferences: ExtensionsPreferences = ExtensionsPreferences()
|
||||
|
||||
# Metadata
|
||||
is_enabled: bool = True
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
# Source tracking (for imported workflows)
|
||||
source: Optional[str] = None # dify, n8n, langflow, etc.
|
||||
source_id: Optional[str] = None
|
||||
|
||||
|
||||
class ExecutionStatus(enum.Enum):
|
||||
"""Workflow execution status"""
|
||||
PENDING = "pending"
|
||||
RUNNING = "running"
|
||||
WAITING = "waiting"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class NodeStatus(enum.Enum):
|
||||
"""Node execution status"""
|
||||
PENDING = "pending"
|
||||
RUNNING = "running"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
SKIPPED = "skipped"
|
||||
|
||||
|
||||
class NodeState(pydantic.BaseModel):
|
||||
"""Runtime state of a node during execution"""
|
||||
node_id: str
|
||||
status: NodeStatus = NodeStatus.PENDING
|
||||
inputs: dict[str, Any] = {}
|
||||
outputs: dict[str, Any] = {}
|
||||
start_time: Optional[datetime] = None
|
||||
end_time: Optional[datetime] = None
|
||||
error: Optional[str] = None
|
||||
retry_count: int = 0
|
||||
|
||||
|
||||
class MessageContext(pydantic.BaseModel):
|
||||
"""Message context for message-triggered workflows"""
|
||||
message_id: str
|
||||
message_content: str
|
||||
sender_id: str
|
||||
sender_name: str = ""
|
||||
platform: str = ""
|
||||
conversation_id: str = ""
|
||||
is_group: bool = False
|
||||
group_id: Optional[str] = None
|
||||
mentions: list[str] = []
|
||||
reply_to: Optional[str] = None
|
||||
raw_message: dict[str, Any] = {}
|
||||
|
||||
|
||||
class ExecutionStep(pydantic.BaseModel):
|
||||
"""Execution history step"""
|
||||
timestamp: datetime
|
||||
node_id: str
|
||||
node_type: str
|
||||
status: str
|
||||
inputs: dict[str, Any] = {}
|
||||
outputs: dict[str, Any] = {}
|
||||
duration_ms: int = 0
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class ExecutionContext(pydantic.BaseModel):
|
||||
"""Workflow execution context"""
|
||||
execution_id: str
|
||||
workflow_id: str
|
||||
workflow_version: int = 1
|
||||
status: ExecutionStatus = ExecutionStatus.PENDING
|
||||
|
||||
# Runtime data
|
||||
variables: dict[str, Any] = {}
|
||||
conversation_variables: dict[str, Any] = {} # Session-level persistent variables
|
||||
node_states: dict[str, NodeState] = {}
|
||||
memory: dict[str, Any] = {} # Workflow memory for storing/retrieving data
|
||||
|
||||
# Timing
|
||||
start_time: Optional[datetime] = None
|
||||
end_time: Optional[datetime] = None
|
||||
|
||||
# Error
|
||||
error: Optional[str] = None
|
||||
|
||||
# Message context (if triggered by message)
|
||||
message_context: Optional[MessageContext] = None
|
||||
|
||||
# Trigger info
|
||||
trigger_type: Optional[str] = None
|
||||
trigger_data: dict[str, Any] = {}
|
||||
|
||||
# Execution history
|
||||
history: list[ExecutionStep] = []
|
||||
|
||||
# Session info
|
||||
session_id: Optional[str] = None
|
||||
user_id: Optional[str] = None
|
||||
bot_id: Optional[str] = None
|
||||
|
||||
def get_node_output(self, node_id: str, output_name: str = "output") -> Any:
|
||||
"""Get output from a specific node"""
|
||||
if node_id in self.node_states:
|
||||
return self.node_states[node_id].outputs.get(output_name)
|
||||
return None
|
||||
|
||||
def set_variable(self, name: str, value: Any):
|
||||
"""Set a workflow variable"""
|
||||
self.variables[name] = value
|
||||
|
||||
def get_variable(self, name: str, default: Any = None) -> Any:
|
||||
"""Get a workflow variable"""
|
||||
return self.variables.get(name, default)
|
||||
|
||||
def set_conversation_variable(self, name: str, value: Any):
|
||||
"""Set a conversation-level variable (persisted across executions)"""
|
||||
self.conversation_variables[name] = value
|
||||
|
||||
def get_conversation_variable(self, name: str, default: Any = None) -> Any:
|
||||
"""Get a conversation-level variable"""
|
||||
return self.conversation_variables.get(name, default)
|
||||
1283
src/langbot/pkg/workflow/executor.py
Normal file
1283
src/langbot/pkg/workflow/executor.py
Normal file
File diff suppressed because it is too large
Load Diff
280
src/langbot/pkg/workflow/node.py
Normal file
280
src/langbot/pkg/workflow/node.py
Normal file
@@ -0,0 +1,280 @@
|
||||
"""Workflow node base class and decorators"""
|
||||
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"""
|
||||
|
||||
# Node metadata
|
||||
type_name: str = ""
|
||||
name: str = ""
|
||||
description: str = ""
|
||||
category: str = "misc" # trigger, process, control, action, integration
|
||||
icon: str = ""
|
||||
|
||||
# Port definitions
|
||||
inputs: list[NodePort] = []
|
||||
outputs: list[NodePort] = []
|
||||
|
||||
# Configuration schema
|
||||
config_schema: list[NodeConfig] = []
|
||||
|
||||
# Pipeline config reuse
|
||||
config_schema_source: Optional[str] = None # e.g., "pipeline:ai"
|
||||
config_stages: list[str] = [] # Specific stages to reuse
|
||||
|
||||
def __init__(self, node_id: str, config: dict[str, Any], ap: Optional['app.Application'] = None):
|
||||
"""Initialize node with ID and configuration"""
|
||||
self.node_id = node_id
|
||||
self.config = config
|
||||
self.ap = ap # Reference to the application instance for accessing services
|
||||
|
||||
@abc.abstractmethod
|
||||
async def execute(
|
||||
self,
|
||||
inputs: dict[str, Any],
|
||||
context: ExecutionContext
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Execute the node logic.
|
||||
|
||||
Args:
|
||||
inputs: Input data from connected nodes
|
||||
context: Execution context with workflow state
|
||||
|
||||
Returns:
|
||||
Dictionary of output values
|
||||
"""
|
||||
pass
|
||||
|
||||
async def validate_inputs(self, inputs: dict[str, Any]) -> list[str]:
|
||||
"""
|
||||
Validate input data against port definitions.
|
||||
|
||||
Returns:
|
||||
List of validation error messages (empty if valid)
|
||||
"""
|
||||
errors = []
|
||||
for port in self.inputs:
|
||||
if port.required and port.name not in inputs:
|
||||
errors.append(f"Missing required input: {port.name}")
|
||||
return errors
|
||||
|
||||
async def validate_config(self) -> list[str]:
|
||||
"""
|
||||
Validate node configuration.
|
||||
|
||||
Returns:
|
||||
List of validation error messages (empty if valid)
|
||||
"""
|
||||
errors = []
|
||||
for cfg in self.config_schema:
|
||||
if cfg.required and cfg.name not in self.config:
|
||||
errors.append(f"Missing required config: {cfg.name}")
|
||||
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
|
||||
|
||||
# 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',
|
||||
'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',
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
# Registry for node type decorator
|
||||
_pending_registrations: list[tuple[str, type[WorkflowNode]]] = []
|
||||
|
||||
|
||||
def workflow_node(type_name: str) -> Callable[[type[WorkflowNode]], type[WorkflowNode]]:
|
||||
"""
|
||||
Decorator to register a workflow node type.
|
||||
|
||||
Usage:
|
||||
@workflow_node('llm_call')
|
||||
class LLMCallNode(WorkflowNode):
|
||||
...
|
||||
"""
|
||||
def decorator(cls: type[WorkflowNode]) -> type[WorkflowNode]:
|
||||
cls.type_name = type_name
|
||||
_pending_registrations.append((type_name, cls))
|
||||
return cls
|
||||
return decorator
|
||||
|
||||
|
||||
def get_pending_registrations() -> list[tuple[str, type[WorkflowNode]]]:
|
||||
"""Get pending node registrations"""
|
||||
return _pending_registrations.copy()
|
||||
|
||||
|
||||
def clear_pending_registrations():
|
||||
"""Clear pending registrations after they're processed"""
|
||||
_pending_registrations.clear()
|
||||
91
src/langbot/pkg/workflow/nodes/__init__.py
Normal file
91
src/langbot/pkg/workflow/nodes/__init__.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""Core workflow nodes package"""
|
||||
|
||||
# Import all node modules to trigger registration
|
||||
# Trigger nodes
|
||||
from . import message_trigger
|
||||
from . import cron_trigger
|
||||
from . import webhook_trigger
|
||||
from . import event_trigger
|
||||
|
||||
# Process nodes
|
||||
from . import llm_call
|
||||
from . import code_executor
|
||||
from . import http_request
|
||||
from . import data_transform
|
||||
from . import question_classifier
|
||||
from . import parameter_extractor
|
||||
from . import knowledge_retrieval
|
||||
|
||||
# Control nodes
|
||||
from . import condition
|
||||
from . import switch
|
||||
from . import loop
|
||||
from . import iterator
|
||||
from . import parallel
|
||||
from . import wait
|
||||
from . import merge
|
||||
from . import variable_aggregator
|
||||
|
||||
# Action nodes
|
||||
from . import send_message
|
||||
from . import reply_message
|
||||
from . import call_pipeline
|
||||
from . import store_data
|
||||
from . import set_variable
|
||||
from . import opening_statement
|
||||
from . import end
|
||||
|
||||
# Integration nodes
|
||||
from . import database_query
|
||||
from . import redis_operation
|
||||
from . import mcp_tool
|
||||
from . import memory_store
|
||||
from . import dify_workflow
|
||||
from . import dify_knowledge_query
|
||||
from . import n8n_workflow
|
||||
from . import langflow_flow
|
||||
from . import coze_bot
|
||||
# from . import plugin_call
|
||||
|
||||
__all__ = [
|
||||
# Trigger nodes
|
||||
'message_trigger',
|
||||
'cron_trigger',
|
||||
'webhook_trigger',
|
||||
'event_trigger',
|
||||
# Process nodes
|
||||
'llm_call',
|
||||
'code_executor',
|
||||
'http_request',
|
||||
'data_transform',
|
||||
'question_classifier',
|
||||
'parameter_extractor',
|
||||
'knowledge_retrieval',
|
||||
# Control nodes
|
||||
'condition',
|
||||
'switch',
|
||||
'loop',
|
||||
'iterator',
|
||||
'parallel',
|
||||
'wait',
|
||||
'merge',
|
||||
'variable_aggregator',
|
||||
# Action nodes
|
||||
'send_message',
|
||||
'reply_message',
|
||||
'call_pipeline',
|
||||
'store_data',
|
||||
'set_variable',
|
||||
'opening_statement',
|
||||
'end',
|
||||
# Integration nodes
|
||||
'database_query',
|
||||
'redis_operation',
|
||||
'mcp_tool',
|
||||
'memory_store',
|
||||
'dify_workflow',
|
||||
'dify_knowledge_query',
|
||||
'n8n_workflow',
|
||||
'langflow_flow',
|
||||
'coze_bot',
|
||||
]
|
||||
36
src/langbot/pkg/workflow/nodes/call_pipeline.py
Normal file
36
src/langbot/pkg/workflow/nodes/call_pipeline.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""Call Pipeline Node - invoke an existing pipeline
|
||||
|
||||
Node metadata is loaded from: ../../templates/metadata/nodes/call_pipeline.yaml
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from ..entities import ExecutionContext
|
||||
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
|
||||
|
||||
|
||||
@workflow_node('call_pipeline')
|
||||
class CallPipelineNode(WorkflowNode):
|
||||
"""Call pipeline node - invoke an existing pipeline"""
|
||||
|
||||
type_name = "call_pipeline"
|
||||
category = "action"
|
||||
icon = "⚙️"
|
||||
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]:
|
||||
query = inputs.get("query", "")
|
||||
pipeline_uuid = self.get_config("pipeline_uuid", "")
|
||||
|
||||
return {"response": f"[Pipeline {pipeline_uuid} response for: {query[:50]}...]", "result": {}}
|
||||
73
src/langbot/pkg/workflow/nodes/code_executor.py
Normal file
73
src/langbot/pkg/workflow/nodes/code_executor.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""Code Executor Node - run Python or JavaScript code
|
||||
|
||||
Node metadata is loaded from: ../../templates/metadata/nodes/code_executor.yaml
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from ..entities import ExecutionContext
|
||||
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
|
||||
|
||||
|
||||
@workflow_node('code_executor')
|
||||
class CodeExecutorNode(WorkflowNode):
|
||||
"""Code executor node - run Python or JavaScript code"""
|
||||
|
||||
type_name = "code_executor"
|
||||
category = "process"
|
||||
icon = "💻"
|
||||
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", "")
|
||||
language = self.get_config("language", "python")
|
||||
|
||||
if language == "python":
|
||||
return await self._execute_python(code, inputs, context)
|
||||
else:
|
||||
return await self._execute_javascript(code, inputs, context)
|
||||
|
||||
async def _execute_python(self, code: str, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
|
||||
import io
|
||||
import sys
|
||||
|
||||
stdout_capture = io.StringIO()
|
||||
old_stdout = sys.stdout
|
||||
|
||||
try:
|
||||
sys.stdout = stdout_capture
|
||||
|
||||
restricted_globals = {
|
||||
'__builtins__': {
|
||||
'len': len, 'str': str, 'int': int, 'float': float, 'bool': bool,
|
||||
'list': list, 'dict': dict, 'set': set, 'tuple': tuple,
|
||||
'range': range, 'enumerate': enumerate, 'zip': zip,
|
||||
'map': map, 'filter': filter, 'sorted': sorted, 'reversed': reversed,
|
||||
'sum': sum, 'min': min, 'max': max, 'abs': abs, 'round': round,
|
||||
'print': print, 'isinstance': isinstance, 'type': type,
|
||||
'hasattr': hasattr, 'getattr': getattr, 'json': json, 're': re,
|
||||
}
|
||||
}
|
||||
|
||||
local_vars = {'inputs': inputs, 'output': None}
|
||||
exec(code, restricted_globals, local_vars)
|
||||
|
||||
return {"output": local_vars.get('output'), "console": stdout_capture.getvalue()}
|
||||
finally:
|
||||
sys.stdout = old_stdout
|
||||
|
||||
async def _execute_javascript(self, code: str, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
|
||||
return {"output": f"[JS execution not implemented: {code[:50]}...]", "console": ""}
|
||||
88
src/langbot/pkg/workflow/nodes/condition.py
Normal file
88
src/langbot/pkg/workflow/nodes/condition.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""Condition Node - branch based on condition
|
||||
|
||||
Node metadata is loaded from: ../../templates/metadata/nodes/condition.yaml
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from ..entities import ExecutionContext
|
||||
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
|
||||
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 = "🔀"
|
||||
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")
|
||||
input_data = inputs.get("input")
|
||||
|
||||
result = False
|
||||
|
||||
if condition_type == "expression":
|
||||
expression = self.get_config("expression", "false")
|
||||
result = await self._evaluate_expression(expression, input_data, context)
|
||||
elif condition_type == "comparison":
|
||||
result = await self._evaluate_comparison(input_data, context)
|
||||
elif condition_type == "contains":
|
||||
left = self.get_config("left_value", "")
|
||||
right = self.get_config("right_value", "")
|
||||
result = right in left
|
||||
elif condition_type == "empty":
|
||||
result = not bool(input_data)
|
||||
elif condition_type == "regex":
|
||||
import re
|
||||
left = self.get_config("left_value", "")
|
||||
pattern = self.get_config("right_value", "")
|
||||
result = bool(re.match(pattern, str(left)))
|
||||
|
||||
if result:
|
||||
return {"true": input_data, "false": None}
|
||||
else:
|
||||
return {"true": None, "false": input_data}
|
||||
|
||||
async def _evaluate_expression(self, expression: str, data: Any, context: ExecutionContext) -> bool:
|
||||
try:
|
||||
local_vars = {"input": data, "data": data, "variables": context.variables}
|
||||
return bool(safe_eval_with_vars(expression, local_vars))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def _evaluate_comparison(self, data: Any, context: ExecutionContext) -> bool:
|
||||
left = self.get_config("left_value", "")
|
||||
right = self.get_config("right_value", "")
|
||||
operator = self.get_config("operator", "==")
|
||||
|
||||
try:
|
||||
left_num = float(left)
|
||||
right_num = float(right)
|
||||
|
||||
if operator == "==": return left_num == right_num
|
||||
elif operator == "!=": return left_num != right_num
|
||||
elif operator == ">": return left_num > right_num
|
||||
elif operator == "<": return left_num < right_num
|
||||
elif operator == ">=": return left_num >= right_num
|
||||
elif operator == "<=": return left_num <= right_num
|
||||
except ValueError:
|
||||
if operator == "==": return left == right
|
||||
elif operator == "!=": return left != right
|
||||
elif operator in (">", "<", ">=", "<="): return False
|
||||
|
||||
return False
|
||||
49
src/langbot/pkg/workflow/nodes/coze_bot.py
Normal file
49
src/langbot/pkg/workflow/nodes/coze_bot.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""Coze Bot Node - call Coze API bot
|
||||
|
||||
Node metadata is loaded from: ../../templates/metadata/nodes/coze_bot.yaml
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from ..entities import ExecutionContext
|
||||
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
|
||||
|
||||
|
||||
@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", "")
|
||||
bot_id = self.get_config("bot_id", "")
|
||||
api_base = self.get_config("api_base", "https://api.coze.cn")
|
||||
query = inputs.get("query", "")
|
||||
conversation_id = inputs.get("conversation_id")
|
||||
|
||||
return {
|
||||
"answer": "",
|
||||
"conversation_id": conversation_id,
|
||||
"success": False,
|
||||
"_debug": {
|
||||
"api_key": api_key[:8] + "..." if api_key else "",
|
||||
"bot_id": bot_id,
|
||||
"api_base": api_base,
|
||||
"query": query,
|
||||
},
|
||||
}
|
||||
39
src/langbot/pkg/workflow/nodes/cron_trigger.py
Normal file
39
src/langbot/pkg/workflow/nodes/cron_trigger.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""Cron Trigger Node - triggers workflow on schedule
|
||||
|
||||
Node metadata is loaded from: ../../templates/metadata/nodes/cron_trigger.yaml
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from ..entities import ExecutionContext
|
||||
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
|
||||
|
||||
|
||||
@workflow_node('cron_trigger')
|
||||
class CronTriggerNode(WorkflowNode):
|
||||
"""Cron trigger node - triggers workflow on schedule"""
|
||||
|
||||
type_name = "cron_trigger"
|
||||
category = "trigger"
|
||||
icon = "⏰"
|
||||
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
|
||||
|
||||
return {
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"schedule": self.get_config("cron", ""),
|
||||
"context": context.trigger_data,
|
||||
}
|
||||
81
src/langbot/pkg/workflow/nodes/data_transform.py
Normal file
81
src/langbot/pkg/workflow/nodes/data_transform.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""Data Transform Node - transform data using templates or JSONPath
|
||||
|
||||
Node metadata is loaded from: ../../templates/metadata/nodes/data_transform.yaml
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from ..entities import ExecutionContext
|
||||
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
|
||||
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 = "🔄"
|
||||
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")
|
||||
transform_type = self.get_config("transform_type", "template")
|
||||
|
||||
if transform_type == "template":
|
||||
template = self.get_config("template", "")
|
||||
result = self._apply_template(template, data, context)
|
||||
elif transform_type == "jsonpath":
|
||||
expression = self.get_config("expression", "$")
|
||||
result = self._apply_jsonpath(expression, data)
|
||||
elif transform_type == "expression":
|
||||
expression = self.get_config("expression", "")
|
||||
result = self._evaluate_expression(expression, data, context)
|
||||
else:
|
||||
result = data
|
||||
|
||||
return {"result": result}
|
||||
|
||||
def _apply_template(self, template: str, data: Any, context: ExecutionContext) -> str:
|
||||
result = template
|
||||
if isinstance(data, dict):
|
||||
for key, value in data.items():
|
||||
result = result.replace(f"{{{{data.{key}}}}}", str(value))
|
||||
for key, value in context.variables.items():
|
||||
result = result.replace(f"{{{{variables.{key}}}}}", str(value))
|
||||
return result
|
||||
|
||||
def _apply_jsonpath(self, expression: str, data: Any) -> Any:
|
||||
if expression == "$":
|
||||
return data
|
||||
if expression.startswith("$."):
|
||||
parts = expression[2:].split(".")
|
||||
result = data
|
||||
for part in parts:
|
||||
if isinstance(result, dict):
|
||||
result = result.get(part)
|
||||
elif isinstance(result, list) and part.isdigit():
|
||||
result = result[int(part)]
|
||||
else:
|
||||
return None
|
||||
return result
|
||||
return data
|
||||
|
||||
def _evaluate_expression(self, expression: str, data: Any, context: ExecutionContext) -> Any:
|
||||
local_vars = {"data": data, "variables": context.variables}
|
||||
try:
|
||||
return safe_eval_with_vars(expression, local_vars)
|
||||
except Exception:
|
||||
return None
|
||||
52
src/langbot/pkg/workflow/nodes/database_query.py
Normal file
52
src/langbot/pkg/workflow/nodes/database_query.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""Database Query Node - execute database queries
|
||||
|
||||
Node metadata is loaded from: ../../templates/metadata/nodes/database_query.yaml
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from ..entities import ExecutionContext
|
||||
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
|
||||
|
||||
|
||||
@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")
|
||||
connection_string = self.get_config("connection_string", "")
|
||||
query = self.get_config("query", "")
|
||||
query_type = self.get_config("query_type", "select")
|
||||
timeout = self.get_config("timeout", 30)
|
||||
|
||||
parameters = inputs.get("parameters", {})
|
||||
|
||||
return {
|
||||
"results": [],
|
||||
"row_count": 0,
|
||||
"success": False,
|
||||
"_debug": {
|
||||
"connection_type": connection_type,
|
||||
"query": query,
|
||||
"query_type": query_type,
|
||||
"timeout": timeout,
|
||||
"parameters": parameters,
|
||||
},
|
||||
}
|
||||
47
src/langbot/pkg/workflow/nodes/dify_knowledge_query.py
Normal file
47
src/langbot/pkg/workflow/nodes/dify_knowledge_query.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""Dify Knowledge Query Node - query Dify knowledge base
|
||||
|
||||
Node metadata is loaded from: ../../templates/metadata/nodes/dify_knowledge_query.yaml
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from ..entities import ExecutionContext
|
||||
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
|
||||
|
||||
|
||||
@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")
|
||||
api_key = self.get_config("api_key", "")
|
||||
dataset_id = self.get_config("dataset_id", "")
|
||||
query = inputs.get("query", "")
|
||||
|
||||
return {
|
||||
"results": [],
|
||||
"success": False,
|
||||
"_debug": {
|
||||
"base_url": base_url,
|
||||
"api_key": api_key[:8] + "..." if api_key else "",
|
||||
"dataset_id": dataset_id,
|
||||
"query": query,
|
||||
},
|
||||
}
|
||||
49
src/langbot/pkg/workflow/nodes/dify_workflow.py
Normal file
49
src/langbot/pkg/workflow/nodes/dify_workflow.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""Dify Workflow Node - call Dify service API
|
||||
|
||||
Node metadata is loaded from: ../../templates/metadata/nodes/dify_workflow.yaml
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from ..entities import ExecutionContext
|
||||
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
|
||||
|
||||
|
||||
@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")
|
||||
api_key = self.get_config("api_key", "")
|
||||
app_type = self.get_config("app_type", "chat")
|
||||
query = inputs.get("query", "")
|
||||
conversation_id = inputs.get("conversation_id")
|
||||
|
||||
return {
|
||||
"answer": "",
|
||||
"conversation_id": conversation_id,
|
||||
"success": False,
|
||||
"_debug": {
|
||||
"base_url": base_url,
|
||||
"api_key": api_key[:8] + "..." if api_key else "",
|
||||
"app_type": app_type,
|
||||
"query": query,
|
||||
},
|
||||
}
|
||||
45
src/langbot/pkg/workflow/nodes/end.py
Normal file
45
src/langbot/pkg/workflow/nodes/end.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""End Node - marks the end of workflow execution
|
||||
|
||||
Node metadata is loaded from: ../../templates/metadata/nodes/end.yaml
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from ..entities import ExecutionContext
|
||||
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
|
||||
|
||||
|
||||
@workflow_node('end')
|
||||
class EndNode(WorkflowNode):
|
||||
"""End node - marks the end of workflow execution"""
|
||||
|
||||
type_name = "end"
|
||||
category = "action"
|
||||
icon = "🏁"
|
||||
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]] = []
|
||||
|
||||
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
|
||||
result = inputs.get("result")
|
||||
output_format = self.get_config("output_format", "passthrough")
|
||||
|
||||
if output_format == "text":
|
||||
return {"output": str(result)}
|
||||
elif output_format == "json":
|
||||
import json
|
||||
try:
|
||||
return {"output": json.dumps(result, ensure_ascii=False)}
|
||||
except Exception:
|
||||
return {"output": str(result)}
|
||||
else:
|
||||
return {"output": result}
|
||||
41
src/langbot/pkg/workflow/nodes/event_trigger.py
Normal file
41
src/langbot/pkg/workflow/nodes/event_trigger.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""Event Trigger Node - triggers workflow on system events
|
||||
|
||||
Node metadata is loaded from: ../../templates/metadata/nodes/event_trigger.yaml
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from ..entities import ExecutionContext
|
||||
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
|
||||
|
||||
|
||||
@workflow_node('event_trigger')
|
||||
class EventTriggerNode(WorkflowNode):
|
||||
"""Event trigger node - triggers workflow on system events"""
|
||||
|
||||
type_name = "event_trigger"
|
||||
category = "trigger"
|
||||
icon = "📡"
|
||||
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
|
||||
|
||||
trigger_data = context.trigger_data
|
||||
|
||||
return {
|
||||
"event_type": trigger_data.get("event_type", ""),
|
||||
"event_data": trigger_data.get("event_data", {}),
|
||||
"timestamp": trigger_data.get("timestamp", datetime.now().isoformat()),
|
||||
}
|
||||
70
src/langbot/pkg/workflow/nodes/http_request.py
Normal file
70
src/langbot/pkg/workflow/nodes/http_request.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""HTTP Request Node - make HTTP API calls
|
||||
|
||||
Node metadata is loaded from: ../../templates/metadata/nodes/http_request.yaml
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from ..entities import ExecutionContext
|
||||
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
|
||||
|
||||
|
||||
@workflow_node('http_request')
|
||||
class HTTPRequestNode(WorkflowNode):
|
||||
"""HTTP request node - make HTTP API calls"""
|
||||
|
||||
type_name = "http_request"
|
||||
category = "process"
|
||||
icon = "🌐"
|
||||
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]] = []
|
||||
|
||||
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
|
||||
import aiohttp
|
||||
|
||||
url = self.get_config("url", "")
|
||||
method = self.get_config("method", "GET")
|
||||
timeout = self.get_config("timeout", 30)
|
||||
content_type = self.get_config("content_type", "application/json")
|
||||
|
||||
headers = inputs.get("headers", {})
|
||||
headers["Content-Type"] = content_type
|
||||
|
||||
auth_type = self.get_config("auth_type", "none")
|
||||
auth_config = self.get_config("auth_config", {})
|
||||
|
||||
if auth_type == "bearer":
|
||||
headers["Authorization"] = f"Bearer {auth_config.get('token', '')}"
|
||||
elif auth_type == "api_key":
|
||||
header_name = auth_config.get("header", "X-API-Key")
|
||||
headers[header_name] = auth_config.get("key", "")
|
||||
|
||||
body = inputs.get("body")
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.request(
|
||||
method=method, url=url,
|
||||
json=body if content_type == "application/json" else None,
|
||||
data=body if content_type != "application/json" else None,
|
||||
headers=headers,
|
||||
timeout=aiohttp.ClientTimeout(total=timeout)
|
||||
) as response:
|
||||
try:
|
||||
response_data = await response.json()
|
||||
except Exception:
|
||||
response_data = await response.text()
|
||||
|
||||
return {"response": response_data, "status_code": response.status, "headers": dict(response.headers)}
|
||||
except Exception as e:
|
||||
return {"response": None, "status_code": 0, "headers": {}, "error": str(e)}
|
||||
60
src/langbot/pkg/workflow/nodes/iterator.py
Normal file
60
src/langbot/pkg/workflow/nodes/iterator.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""Iterator Node - Dify-style iterator for processing array items"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from ..entities import ExecutionContext
|
||||
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
|
||||
|
||||
|
||||
@workflow_node('iterator')
|
||||
class IteratorNode(WorkflowNode):
|
||||
"""Iterator node - iterate over array items one by one"""
|
||||
|
||||
type_name = "iterator"
|
||||
category = "control"
|
||||
icon = "🔄"
|
||||
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", [])
|
||||
if not isinstance(items, list):
|
||||
items = [items] if items else []
|
||||
|
||||
max_iterations = self.get_config("max_iterations", 1000)
|
||||
items = items[:max_iterations]
|
||||
|
||||
return {
|
||||
"item": items[0] if items else None,
|
||||
"index": 0,
|
||||
"is_first": True,
|
||||
"is_last": len(items) <= 1,
|
||||
"results": [],
|
||||
"completed": len(items) == 0,
|
||||
"_items": items,
|
||||
}
|
||||
34
src/langbot/pkg/workflow/nodes/knowledge_retrieval.py
Normal file
34
src/langbot/pkg/workflow/nodes/knowledge_retrieval.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""Knowledge Retrieval Node - search in knowledge base
|
||||
|
||||
Node metadata is loaded from: ../../templates/metadata/nodes/knowledge_retrieval.yaml
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from ..entities import ExecutionContext
|
||||
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
|
||||
|
||||
|
||||
@workflow_node('knowledge_retrieval')
|
||||
class KnowledgeRetrievalNode(WorkflowNode):
|
||||
"""Knowledge retrieval node - search in knowledge base"""
|
||||
|
||||
type_name = "knowledge_retrieval"
|
||||
category = "process"
|
||||
icon = "📚"
|
||||
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", "")
|
||||
return {"documents": [], "citations": [], "context": f"[Knowledge base search for: {query}]"}
|
||||
47
src/langbot/pkg/workflow/nodes/langflow_flow.py
Normal file
47
src/langbot/pkg/workflow/nodes/langflow_flow.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""Langflow Flow Node - call Langflow API
|
||||
|
||||
Node metadata is loaded from: ../../templates/metadata/nodes/langflow_flow.yaml
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from ..entities import ExecutionContext
|
||||
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
|
||||
|
||||
|
||||
@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")
|
||||
api_key = self.get_config("api_key", "")
|
||||
flow_id = self.get_config("flow_id", "")
|
||||
input_value = inputs.get("input_value", "")
|
||||
|
||||
return {
|
||||
"result": None,
|
||||
"success": False,
|
||||
"_debug": {
|
||||
"base_url": base_url,
|
||||
"api_key": api_key[:8] + "..." if api_key else "",
|
||||
"flow_id": flow_id,
|
||||
"input_value": input_value,
|
||||
},
|
||||
}
|
||||
163
src/langbot/pkg/workflow/nodes/llm_call.py
Normal file
163
src/langbot/pkg/workflow/nodes/llm_call.py
Normal file
@@ -0,0 +1,163 @@
|
||||
"""LLM Call Node - invoke large language model."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, ClassVar
|
||||
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
|
||||
from ..entities import ExecutionContext
|
||||
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
|
||||
|
||||
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 = "🤖"
|
||||
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."""
|
||||
def replacer(match: re.Match) -> str:
|
||||
expr = match.group(1).strip()
|
||||
# Try inputs first
|
||||
if expr in inputs:
|
||||
return str(inputs[expr])
|
||||
# Try context variables
|
||||
if expr.startswith("variables."):
|
||||
var_name = expr[len("variables."):]
|
||||
return str(context.variables.get(var_name, ""))
|
||||
# Try message context
|
||||
if expr.startswith("message.") and context.message_context:
|
||||
attr = expr[len("message."):]
|
||||
return str(getattr(context.message_context, attr, ""))
|
||||
return match.group(0) # leave unresolved
|
||||
|
||||
return re.sub(r"\{\{([^}]+)\}\}", replacer, template)
|
||||
|
||||
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
|
||||
model_uuid = self.get_config("model", "")
|
||||
if not model_uuid:
|
||||
raise ValueError("No model configured for LLM call node")
|
||||
|
||||
if not self.ap:
|
||||
raise RuntimeError("Application instance not available — cannot call LLM")
|
||||
|
||||
# Resolve prompts
|
||||
system_prompt = self._resolve_template(
|
||||
self.get_config("system_prompt", ""), inputs, context
|
||||
)
|
||||
user_prompt = self._resolve_template(
|
||||
self.get_config("user_prompt_template", "{{input}}"), inputs, context
|
||||
)
|
||||
|
||||
# Build messages
|
||||
messages: list[provider_message.Message] = []
|
||||
if system_prompt:
|
||||
messages.append(provider_message.Message(role="system", content=system_prompt))
|
||||
messages.append(provider_message.Message(role="user", content=user_prompt))
|
||||
|
||||
# Get model
|
||||
runtime_model = await self.ap.model_mgr.get_model_by_uuid(model_uuid)
|
||||
|
||||
# Build extra args from config
|
||||
extra_args: dict[str, Any] = {}
|
||||
temperature = self.get_config("temperature")
|
||||
if temperature is not None:
|
||||
extra_args["temperature"] = float(temperature)
|
||||
max_tokens = self.get_config("max_tokens", 0)
|
||||
if max_tokens and int(max_tokens) > 0:
|
||||
extra_args["max_tokens"] = int(max_tokens)
|
||||
|
||||
# Invoke LLM
|
||||
logger.info(f"LLM call node {self.node_id}: invoking model {model_uuid}")
|
||||
result_message = await runtime_model.provider.invoke_llm(
|
||||
query=None,
|
||||
model=runtime_model,
|
||||
messages=messages,
|
||||
funcs=None,
|
||||
extra_args=extra_args,
|
||||
)
|
||||
|
||||
# Extract response text
|
||||
response_text = ""
|
||||
if isinstance(result_message.content, str):
|
||||
response_text = result_message.content
|
||||
elif isinstance(result_message.content, list):
|
||||
# ContentElement list — concatenate text elements
|
||||
for elem in result_message.content:
|
||||
if hasattr(elem, 'text') and elem.text:
|
||||
response_text += elem.text
|
||||
elif isinstance(elem, str):
|
||||
response_text += elem
|
||||
|
||||
# Extract usage info if available
|
||||
usage = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
|
||||
if hasattr(result_message, 'usage') and result_message.usage:
|
||||
u = result_message.usage
|
||||
usage = {
|
||||
"prompt_tokens": getattr(u, 'prompt_tokens', 0) or 0,
|
||||
"completion_tokens": getattr(u, 'completion_tokens', 0) or 0,
|
||||
"total_tokens": getattr(u, 'total_tokens', 0) or 0,
|
||||
}
|
||||
elif hasattr(result_message, 'token_usage') and result_message.token_usage:
|
||||
u = result_message.token_usage
|
||||
usage = {
|
||||
"prompt_tokens": getattr(u, 'prompt_tokens', 0) or 0,
|
||||
"completion_tokens": getattr(u, 'completion_tokens', 0) or 0,
|
||||
"total_tokens": getattr(u, 'total_tokens', 0) or 0,
|
||||
}
|
||||
|
||||
return {
|
||||
"response": response_text,
|
||||
"usage": usage,
|
||||
}
|
||||
62
src/langbot/pkg/workflow/nodes/loop.py
Normal file
62
src/langbot/pkg/workflow/nodes/loop.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""Loop Node - iterate over items"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from ..entities import ExecutionContext
|
||||
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
|
||||
|
||||
|
||||
@workflow_node('loop')
|
||||
class LoopNode(WorkflowNode):
|
||||
"""Loop node - iterate over items"""
|
||||
|
||||
type_name = "loop"
|
||||
category = "control"
|
||||
icon = "🔁"
|
||||
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", [])
|
||||
if not isinstance(items, list):
|
||||
items = [items] if items else []
|
||||
|
||||
max_iterations = self.get_config("max_iterations", 100)
|
||||
items = items[:max_iterations]
|
||||
|
||||
return {
|
||||
"item": items[0] if items else None,
|
||||
"index": 0,
|
||||
"results": [],
|
||||
"completed": len(items) == 0,
|
||||
"_items": items,
|
||||
}
|
||||
70
src/langbot/pkg/workflow/nodes/mcp_tool.py
Normal file
70
src/langbot/pkg/workflow/nodes/mcp_tool.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""MCP Tool Node - Invoke MCP (Model Context Protocol) tools
|
||||
|
||||
This module contains the implementation for the MCP Tool workflow node.
|
||||
Node metadata (label, description, inputs, outputs, config) is loaded from:
|
||||
../../templates/metadata/nodes/mcp_tool.yaml
|
||||
|
||||
The i18n for label and description is handled on the frontend side.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from ..entities import ExecutionContext
|
||||
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
|
||||
|
||||
|
||||
@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
|
||||
|
||||
Args:
|
||||
inputs: Input data from connected nodes
|
||||
context: Execution context with workflow state
|
||||
|
||||
Returns:
|
||||
Dictionary of output values
|
||||
"""
|
||||
server_name = self.get_config("server_name", "")
|
||||
tool_name = self.get_config("tool_name", "")
|
||||
arguments_template = self.get_config("arguments_template", "")
|
||||
timeout = self.get_config("timeout", 30)
|
||||
|
||||
arguments = inputs.get("arguments", arguments_template)
|
||||
|
||||
return {
|
||||
"result": None,
|
||||
"success": False,
|
||||
"error": f"MCP tool '{server_name}/{tool_name}' not implemented yet",
|
||||
"_debug": {
|
||||
"server_name": server_name,
|
||||
"tool_name": tool_name,
|
||||
"arguments": arguments,
|
||||
"timeout": timeout,
|
||||
},
|
||||
}
|
||||
103
src/langbot/pkg/workflow/nodes/memory_store.py
Normal file
103
src/langbot/pkg/workflow/nodes/memory_store.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""Memory Store Node - store and retrieve from workflow memory
|
||||
|
||||
Node metadata is loaded from: ../../templates/metadata/nodes/memory_store.yaml
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from ..entities import ExecutionContext
|
||||
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
|
||||
|
||||
|
||||
class MemoryHelper:
|
||||
"""Helper class wrapping context.memory dict with get/set/delete/list_all/append operations"""
|
||||
|
||||
def __init__(self, memory_dict: dict[str, Any]):
|
||||
self._data = memory_dict
|
||||
|
||||
def get(self, key: str, scope: str = "execution", default: Any = None) -> Any:
|
||||
"""Get a value from memory by key"""
|
||||
scoped_key = f"{scope}:{key}" if scope else key
|
||||
return self._data.get(scoped_key, default)
|
||||
|
||||
def set(self, key: str, value: Any, scope: str = "execution", ttl: int = 0) -> None:
|
||||
"""Set a value in memory"""
|
||||
scoped_key = f"{scope}:{key}" if scope else key
|
||||
self._data[scoped_key] = value
|
||||
|
||||
def delete(self, key: str, scope: str = "execution") -> None:
|
||||
"""Delete a value from memory"""
|
||||
scoped_key = f"{scope}:{key}" if scope else key
|
||||
self._data.pop(scoped_key, None)
|
||||
|
||||
def list_all(self, scope: str = "execution") -> dict[str, Any]:
|
||||
"""List all values in the given scope"""
|
||||
prefix = f"{scope}:"
|
||||
return {
|
||||
k[len(prefix):]: v
|
||||
for k, v in self._data.items()
|
||||
if k.startswith(prefix)
|
||||
}
|
||||
|
||||
def append(self, key: str, value: Any, scope: str = "execution", ttl: int = 0) -> list:
|
||||
"""Append a value to a list in memory"""
|
||||
current = self.get(key, scope=scope, default=[])
|
||||
if isinstance(current, list):
|
||||
current.append(value)
|
||||
else:
|
||||
current = [current, value]
|
||||
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")
|
||||
key = self.get_config("key", "")
|
||||
scope = self.get_config("scope", "execution")
|
||||
ttl = self.get_config("ttl", 0)
|
||||
|
||||
value = inputs.get("value")
|
||||
|
||||
# Wrap context.memory dict with MemoryHelper for structured operations
|
||||
memory = MemoryHelper(context.memory)
|
||||
|
||||
try:
|
||||
if operation == "get":
|
||||
result = memory.get(key, scope=scope)
|
||||
return {"result": result, "success": True}
|
||||
elif operation == "set":
|
||||
memory.set(key, value, scope=scope, ttl=ttl)
|
||||
return {"result": value, "success": True}
|
||||
elif operation == "delete":
|
||||
memory.delete(key, scope=scope)
|
||||
return {"result": None, "success": True}
|
||||
elif operation == "append":
|
||||
result = memory.append(key, value, scope=scope, ttl=ttl)
|
||||
return {"result": result, "success": True}
|
||||
elif operation == "list":
|
||||
result = memory.list_all(scope=scope)
|
||||
return {"result": result, "success": True}
|
||||
else:
|
||||
return {"result": None, "success": False, "error": f"Unknown operation: {operation}"}
|
||||
except Exception as e:
|
||||
return {"result": None, "success": False, "error": str(e)}
|
||||
65
src/langbot/pkg/workflow/nodes/merge.py
Normal file
65
src/langbot/pkg/workflow/nodes/merge.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""Merge Node - combine multiple inputs
|
||||
|
||||
Node metadata is loaded from: ../../templates/metadata/nodes/merge.yaml
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from ..entities import ExecutionContext
|
||||
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
|
||||
|
||||
|
||||
@workflow_node('merge')
|
||||
class MergeNode(WorkflowNode):
|
||||
"""Merge node - combine multiple inputs"""
|
||||
|
||||
type_name = "merge"
|
||||
category = "control"
|
||||
icon = "🔗"
|
||||
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")
|
||||
|
||||
values = [inputs.get("input_1"), inputs.get("input_2"), inputs.get("input_3"), inputs.get("input_4")]
|
||||
non_null_values = [v for v in values if v is not None]
|
||||
|
||||
if strategy == "object":
|
||||
merged = {}
|
||||
for i, v in enumerate(non_null_values):
|
||||
if isinstance(v, dict):
|
||||
merged.update(v)
|
||||
else:
|
||||
merged[f"value_{i}"] = v
|
||||
return {"merged": merged, "array": non_null_values}
|
||||
|
||||
elif strategy == "array":
|
||||
return {"merged": non_null_values, "array": non_null_values}
|
||||
|
||||
elif strategy == "first_non_null":
|
||||
first = non_null_values[0] if non_null_values else None
|
||||
return {"merged": first, "array": non_null_values}
|
||||
|
||||
elif strategy == "concat":
|
||||
if all(isinstance(v, str) for v in non_null_values):
|
||||
return {"merged": "".join(non_null_values), "array": non_null_values}
|
||||
elif all(isinstance(v, list) for v in non_null_values):
|
||||
merged_list = []
|
||||
for v in non_null_values:
|
||||
merged_list.extend(v)
|
||||
return {"merged": merged_list, "array": merged_list}
|
||||
else:
|
||||
return {"merged": non_null_values, "array": non_null_values}
|
||||
|
||||
return {"merged": non_null_values, "array": non_null_values}
|
||||
56
src/langbot/pkg/workflow/nodes/message_trigger.py
Normal file
56
src/langbot/pkg/workflow/nodes/message_trigger.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""Message Trigger Node - triggers workflow on message arrival
|
||||
|
||||
This module contains the implementation for the Message Trigger workflow node.
|
||||
Node metadata (label, description, inputs, outputs, config) is loaded from:
|
||||
../../templates/metadata/nodes/message_trigger.yaml
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from ..entities import ExecutionContext
|
||||
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
|
||||
|
||||
|
||||
@workflow_node('message_trigger')
|
||||
class MessageTriggerNode(WorkflowNode):
|
||||
"""Message trigger node - triggers workflow on message arrival"""
|
||||
|
||||
type_name = "message_trigger"
|
||||
category = "trigger"
|
||||
icon = "💬"
|
||||
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
|
||||
|
||||
if msg_ctx:
|
||||
return {
|
||||
"message": msg_ctx.message_content,
|
||||
"sender_id": msg_ctx.sender_id,
|
||||
"sender_name": msg_ctx.sender_name,
|
||||
"platform": msg_ctx.platform,
|
||||
"conversation_id": msg_ctx.conversation_id,
|
||||
"is_group": msg_ctx.is_group,
|
||||
"context": msg_ctx.model_dump(),
|
||||
}
|
||||
|
||||
return {
|
||||
"message": context.get_variable("message", ""),
|
||||
"sender_id": context.get_variable("sender_id", ""),
|
||||
"sender_name": context.get_variable("sender_name", ""),
|
||||
"platform": context.get_variable("platform", ""),
|
||||
"conversation_id": context.get_variable("conversation_id", ""),
|
||||
"is_group": context.get_variable("is_group", False),
|
||||
"context": context.trigger_data,
|
||||
}
|
||||
47
src/langbot/pkg/workflow/nodes/n8n_workflow.py
Normal file
47
src/langbot/pkg/workflow/nodes/n8n_workflow.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""N8n Workflow Node - call n8n workflow API
|
||||
|
||||
Node metadata is loaded from: ../../templates/metadata/nodes/n8n_workflow.yaml
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from ..entities import ExecutionContext
|
||||
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
|
||||
|
||||
|
||||
@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", "")
|
||||
auth_type = self.get_config("auth_type", "none")
|
||||
timeout = self.get_config("timeout", 120)
|
||||
payload = inputs.get("payload", {})
|
||||
|
||||
return {
|
||||
"result": None,
|
||||
"success": False,
|
||||
"_debug": {
|
||||
"webhook_url": webhook_url,
|
||||
"auth_type": auth_type,
|
||||
"timeout": timeout,
|
||||
"payload": payload,
|
||||
},
|
||||
}
|
||||
37
src/langbot/pkg/workflow/nodes/opening_statement.py
Normal file
37
src/langbot/pkg/workflow/nodes/opening_statement.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""Opening Statement Node - provide conversation opener and suggested questions
|
||||
|
||||
Node metadata is loaded from: ../../templates/metadata/nodes/opening_statement.yaml
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from ..entities import ExecutionContext
|
||||
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
|
||||
|
||||
|
||||
@workflow_node('opening_statement')
|
||||
class OpeningStatementNode(WorkflowNode):
|
||||
"""Opening statement node - provide conversation opener and suggested questions"""
|
||||
|
||||
type_name = "opening_statement"
|
||||
category = "action"
|
||||
icon = "👋"
|
||||
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", "")
|
||||
suggestions = self.get_config("suggested_questions", [])
|
||||
show = self.get_config("show_suggestions", True)
|
||||
|
||||
return {"statement": statement, "suggested_questions": suggestions if show else []}
|
||||
49
src/langbot/pkg/workflow/nodes/parallel.py
Normal file
49
src/langbot/pkg/workflow/nodes/parallel.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""Parallel Node - execute multiple branches simultaneously"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from ..entities import ExecutionContext
|
||||
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
|
||||
|
||||
|
||||
@workflow_node('parallel')
|
||||
class ParallelNode(WorkflowNode):
|
||||
"""Parallel node - execute multiple branches simultaneously"""
|
||||
|
||||
type_name = "parallel"
|
||||
category = "control"
|
||||
icon = "⚡"
|
||||
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 {
|
||||
"results": {},
|
||||
"errors": [],
|
||||
}
|
||||
40
src/langbot/pkg/workflow/nodes/parameter_extractor.py
Normal file
40
src/langbot/pkg/workflow/nodes/parameter_extractor.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""Parameter Extractor Node - extract structured parameters from text
|
||||
|
||||
Node metadata is loaded from: ../../templates/metadata/nodes/parameter_extractor.yaml
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from ..entities import ExecutionContext
|
||||
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
|
||||
|
||||
|
||||
@workflow_node('parameter_extractor')
|
||||
class ParameterExtractorNode(WorkflowNode):
|
||||
"""Parameter extractor node - extract structured parameters from text"""
|
||||
|
||||
type_name = "parameter_extractor"
|
||||
category = "process"
|
||||
icon: str = "📤"
|
||||
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]:
|
||||
text = inputs.get("text", "")
|
||||
param_defs = self.get_config("parameters", [])
|
||||
|
||||
extracted = {}
|
||||
for param in param_defs:
|
||||
extracted[param.get("name", "")] = None
|
||||
|
||||
return {"parameters": extracted, "extraction_success": False}
|
||||
42
src/langbot/pkg/workflow/nodes/plugin_call.py
Normal file
42
src/langbot/pkg/workflow/nodes/plugin_call.py
Normal file
@@ -0,0 +1,42 @@
|
||||
# """Plugin Call Node - invoke a plugin
|
||||
|
||||
# Node metadata is loaded from: ../../templates/metadata/nodes/plugin_call.yaml
|
||||
# """
|
||||
|
||||
# from __future__ import annotations
|
||||
|
||||
# from typing import Any, ClassVar
|
||||
|
||||
# from ..entities import ExecutionContext
|
||||
# from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
|
||||
|
||||
|
||||
# @workflow_node('plugin_call')
|
||||
# class PluginCallNode(WorkflowNode):
|
||||
# """Plugin call node - invoke a plugin"""
|
||||
|
||||
# type_name = "plugin_call"
|
||||
# category = "action"
|
||||
# icon = "🔌"
|
||||
# name = "plugin_call"
|
||||
# description = "plugin_call"
|
||||
|
||||
# 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]:
|
||||
# plugin_name = self.get_config("plugin_name", "")
|
||||
# method_name = self.get_config("method_name", "")
|
||||
# arguments = inputs.get("arguments", {})
|
||||
|
||||
# return {
|
||||
# "result": None,
|
||||
# "success": False,
|
||||
# "error": f"Plugin call '{plugin_name}/{method_name}' not implemented yet",
|
||||
# "_debug": {
|
||||
# "plugin_name": plugin_name,
|
||||
# "method_name": method_name,
|
||||
# "arguments": arguments,
|
||||
# },
|
||||
# }
|
||||
43
src/langbot/pkg/workflow/nodes/question_classifier.py
Normal file
43
src/langbot/pkg/workflow/nodes/question_classifier.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""Question Classifier Node - classify user questions into categories
|
||||
|
||||
Node metadata is loaded from: ../../templates/metadata/nodes/question_classifier.yaml
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from ..entities import ExecutionContext
|
||||
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
|
||||
|
||||
|
||||
@workflow_node('question_classifier')
|
||||
class QuestionClassifierNode(WorkflowNode):
|
||||
"""Question classifier node - classify user questions into categories"""
|
||||
|
||||
type_name = "question_classifier"
|
||||
category = "process"
|
||||
icon = "🏷️"
|
||||
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]:
|
||||
question = inputs.get("question", "")
|
||||
categories = self.get_config("categories", [])
|
||||
|
||||
if categories:
|
||||
return {
|
||||
"category": categories[0].get("name", "unknown"),
|
||||
"confidence": 0.8,
|
||||
"all_scores": {cat.get("name"): 0.1 for cat in categories},
|
||||
}
|
||||
|
||||
return {"category": "unknown", "confidence": 0.0, "all_scores": {}}
|
||||
53
src/langbot/pkg/workflow/nodes/redis_operation.py
Normal file
53
src/langbot/pkg/workflow/nodes/redis_operation.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""Redis Operation Node - perform Redis cache operations
|
||||
|
||||
Node metadata is loaded from: ../../templates/metadata/nodes/redis_operation.yaml
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from ..entities import ExecutionContext
|
||||
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
|
||||
|
||||
|
||||
@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")
|
||||
operation = self.get_config("operation", "get")
|
||||
key_template = self.get_config("key_template", "")
|
||||
hash_field = self.get_config("hash_field", "")
|
||||
ttl = self.get_config("ttl", 0)
|
||||
|
||||
key = inputs.get("key", key_template)
|
||||
value = inputs.get("value")
|
||||
|
||||
return {
|
||||
"result": None,
|
||||
"success": False,
|
||||
"_debug": {
|
||||
"connection_url": connection_url,
|
||||
"operation": operation,
|
||||
"key": key,
|
||||
"hash_field": hash_field,
|
||||
"ttl": ttl,
|
||||
"value": value,
|
||||
},
|
||||
}
|
||||
95
src/langbot/pkg/workflow/nodes/reply_message.py
Normal file
95
src/langbot/pkg/workflow/nodes/reply_message.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""Reply Message Node - reply to the triggering message
|
||||
|
||||
Node metadata is loaded from: ../../templates/metadata/nodes/reply_message.yaml
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from ..entities import ExecutionContext
|
||||
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
|
||||
|
||||
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 = "↩️"
|
||||
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")
|
||||
if message in (None, ""):
|
||||
message = inputs.get("input")
|
||||
if message in (None, ""):
|
||||
message = inputs.get("response")
|
||||
if message in (None, "") and context.message_context:
|
||||
message = context.message_context.message_content
|
||||
if message is None:
|
||||
message = ""
|
||||
|
||||
template = self.get_config("message_template")
|
||||
|
||||
if template:
|
||||
message = template
|
||||
for key, value in inputs.items():
|
||||
message = message.replace(f"{{{{{key}}}}}", str(value))
|
||||
for key, value in context.variables.items():
|
||||
message = message.replace(f"{{{{variables.{key}}}}}", str(value))
|
||||
|
||||
logger.info(
|
||||
"ReplyMessageNode resolved message",
|
||||
extra={
|
||||
'node_id': self.node_id,
|
||||
'execution_id': context.execution_id,
|
||||
'input_keys': list(inputs.keys()),
|
||||
'message_preview': str(message)[:200],
|
||||
'has_template': bool(template),
|
||||
'session_id': context.session_id,
|
||||
},
|
||||
)
|
||||
|
||||
if not str(message).strip():
|
||||
logger.warning(
|
||||
"ReplyMessageNode has empty message after resolution",
|
||||
extra={
|
||||
'node_id': self.node_id,
|
||||
'execution_id': context.execution_id,
|
||||
'input_keys': list(inputs.keys()),
|
||||
},
|
||||
)
|
||||
|
||||
# 实际发送消息
|
||||
if self.ap:
|
||||
from langbot_plugin.api.entities.builtin.platform.message import MessageChain, Plain
|
||||
message_chain = MessageChain([Plain(text=str(message))])
|
||||
await self.ap.platform_mgr.websocket_proxy_bot.adapter.send_message(
|
||||
target_type='person',
|
||||
target_id=f'websocket_{context.session_id}',
|
||||
message=message_chain,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"ReplyMessageNode missing application instance",
|
||||
extra={
|
||||
'node_id': self.node_id,
|
||||
'execution_id': context.execution_id,
|
||||
},
|
||||
)
|
||||
|
||||
return {"status": "sent", "message_id": f"reply_{context.execution_id}"}
|
||||
36
src/langbot/pkg/workflow/nodes/send_message.py
Normal file
36
src/langbot/pkg/workflow/nodes/send_message.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""Send Message Node - send message to a target
|
||||
|
||||
Node metadata is loaded from: ../../templates/metadata/nodes/send_message.yaml
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from ..entities import ExecutionContext
|
||||
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
|
||||
|
||||
|
||||
@workflow_node('send_message')
|
||||
class SendMessageNode(WorkflowNode):
|
||||
"""Send message node - send message to a target"""
|
||||
|
||||
type_name = "send_message"
|
||||
category = "action"
|
||||
icon = "📤"
|
||||
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]:
|
||||
message = inputs.get("message", "")
|
||||
target = inputs.get("target") or self.get_config("target_id", "")
|
||||
|
||||
return {"status": "sent", "message_id": f"msg_{context.execution_id}"}
|
||||
64
src/langbot/pkg/workflow/nodes/set_variable.py
Normal file
64
src/langbot/pkg/workflow/nodes/set_variable.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""Set Variable Node - set workflow or conversation variable
|
||||
|
||||
Node metadata is loaded from: ../../templates/metadata/nodes/set_variable.yaml
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from ..entities import ExecutionContext
|
||||
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
|
||||
|
||||
|
||||
@workflow_node('set_variable')
|
||||
class SetVariableNode(WorkflowNode):
|
||||
"""Set variable node - set workflow or conversation variable"""
|
||||
|
||||
type_name = "set_variable"
|
||||
category = "action"
|
||||
icon = "📝"
|
||||
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")
|
||||
name = self.get_config("variable_name", "")
|
||||
scope = self.get_config("variable_scope", "workflow")
|
||||
operation = self.get_config("operation", "set")
|
||||
|
||||
if scope == "conversation":
|
||||
current = context.get_conversation_variable(name)
|
||||
else:
|
||||
current = context.get_variable(name)
|
||||
|
||||
if operation == "set":
|
||||
final_value = value
|
||||
elif operation == "append":
|
||||
if isinstance(current, list):
|
||||
final_value = current + [value]
|
||||
elif isinstance(current, str):
|
||||
final_value = current + str(value)
|
||||
else:
|
||||
final_value = [current, value] if current else [value]
|
||||
elif operation == "increment":
|
||||
final_value = (current or 0) + (value if isinstance(value, (int, float)) else 1)
|
||||
elif operation == "decrement":
|
||||
final_value = (current or 0) - (value if isinstance(value, (int, float)) else 1)
|
||||
else:
|
||||
final_value = value
|
||||
|
||||
if scope == "conversation":
|
||||
context.set_conversation_variable(name, final_value)
|
||||
else:
|
||||
context.set_variable(name, final_value)
|
||||
|
||||
return {"value": final_value}
|
||||
45
src/langbot/pkg/workflow/nodes/store_data.py
Normal file
45
src/langbot/pkg/workflow/nodes/store_data.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""Store Data Node - save data to storage
|
||||
|
||||
Node metadata is loaded from: ../../templates/metadata/nodes/store_data.yaml
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from ..entities import ExecutionContext
|
||||
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
|
||||
|
||||
|
||||
@workflow_node('store_data')
|
||||
class StoreDataNode(WorkflowNode):
|
||||
"""Store data node - save data to storage"""
|
||||
|
||||
type_name = "store_data"
|
||||
category = "action"
|
||||
icon = "💾"
|
||||
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", "")
|
||||
value = inputs.get("value")
|
||||
storage_type = self.get_config("storage_type", "session")
|
||||
prefix = self.get_config("key_prefix", "")
|
||||
|
||||
full_key = f"{prefix}{key}" if prefix else key
|
||||
|
||||
if storage_type == "session":
|
||||
context.set_conversation_variable(full_key, value)
|
||||
else:
|
||||
context.set_variable(full_key, value)
|
||||
|
||||
return {"status": "stored"}
|
||||
64
src/langbot/pkg/workflow/nodes/switch.py
Normal file
64
src/langbot/pkg/workflow/nodes/switch.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""Switch Node - multi-way branch based on value
|
||||
|
||||
Node metadata is loaded from: ../../templates/metadata/nodes/switch.yaml
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from ..entities import ExecutionContext
|
||||
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
|
||||
|
||||
|
||||
@workflow_node('switch')
|
||||
class SwitchNode(WorkflowNode):
|
||||
"""Switch node - multi-way branch based on value"""
|
||||
|
||||
type_name = "switch"
|
||||
category = "control"
|
||||
icon = "🔃"
|
||||
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", "")
|
||||
cases = self.get_config("cases", [])
|
||||
input_data = inputs.get("input")
|
||||
|
||||
value = await self._evaluate_expression(expression, input_data, context)
|
||||
|
||||
for case in cases:
|
||||
if str(case.get("value")) == str(value):
|
||||
return {"matched_case": input_data, "default": None, "_matched_output": case.get("output")}
|
||||
|
||||
return {"matched_case": None, "default": input_data}
|
||||
|
||||
async def _evaluate_expression(self, expression: str, data: Any, context: ExecutionContext) -> Any:
|
||||
if not expression:
|
||||
return data
|
||||
|
||||
if expression.startswith("{{") and expression.endswith("}}"):
|
||||
var_path = expression[2:-2].strip()
|
||||
parts = var_path.split(".")
|
||||
|
||||
if parts[0] == "input":
|
||||
result = data
|
||||
for part in parts[1:]:
|
||||
if isinstance(result, dict):
|
||||
result = result.get(part)
|
||||
else:
|
||||
return None
|
||||
return result
|
||||
elif parts[0] == "variables":
|
||||
return context.variables.get(".".join(parts[1:]))
|
||||
|
||||
return expression
|
||||
51
src/langbot/pkg/workflow/nodes/variable_aggregator.py
Normal file
51
src/langbot/pkg/workflow/nodes/variable_aggregator.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""Variable Aggregator Node - aggregate variables from multiple branches
|
||||
|
||||
Node metadata is loaded from: ../../templates/metadata/nodes/variable_aggregator.yaml
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from ..entities import ExecutionContext
|
||||
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
|
||||
|
||||
|
||||
@workflow_node('variable_aggregator')
|
||||
class VariableAggregatorNode(WorkflowNode):
|
||||
"""Variable aggregator node - aggregate variables from multiple branches"""
|
||||
|
||||
type_name = "variable_aggregator"
|
||||
category = "control"
|
||||
icon = "📊"
|
||||
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", {})
|
||||
mode = self.get_config("aggregation_mode", "merge")
|
||||
|
||||
aggregated = {}
|
||||
|
||||
if mode == "merge":
|
||||
if isinstance(variables, dict):
|
||||
aggregated.update(variables)
|
||||
elif mode == "override":
|
||||
if isinstance(variables, dict):
|
||||
aggregated = variables.copy()
|
||||
elif mode == "append":
|
||||
for key, value in (variables if isinstance(variables, dict) else {}).items():
|
||||
if key in aggregated and isinstance(aggregated[key], list):
|
||||
aggregated[key].append(value)
|
||||
else:
|
||||
aggregated[key] = [value]
|
||||
|
||||
return {"aggregated": aggregated}
|
||||
45
src/langbot/pkg/workflow/nodes/wait.py
Normal file
45
src/langbot/pkg/workflow/nodes/wait.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""Wait Node - pause execution for a duration
|
||||
|
||||
Node metadata is loaded from: ../../templates/metadata/nodes/wait.yaml
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from ..entities import ExecutionContext
|
||||
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
|
||||
|
||||
|
||||
@workflow_node('wait')
|
||||
class WaitNode(WorkflowNode):
|
||||
"""Wait node - pause execution for a duration"""
|
||||
|
||||
type_name = "wait"
|
||||
category = "control"
|
||||
icon = "⏳"
|
||||
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
|
||||
|
||||
duration = self.get_config("duration", 1)
|
||||
duration_type = self.get_config("duration_type", "seconds")
|
||||
|
||||
if duration_type == "minutes":
|
||||
duration *= 60
|
||||
elif duration_type == "hours":
|
||||
duration *= 3600
|
||||
|
||||
await asyncio.sleep(duration)
|
||||
|
||||
return {"output": inputs.get("input")}
|
||||
40
src/langbot/pkg/workflow/nodes/webhook_trigger.py
Normal file
40
src/langbot/pkg/workflow/nodes/webhook_trigger.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""Webhook Trigger Node - triggers workflow via HTTP request
|
||||
|
||||
Node metadata is loaded from: ../../templates/metadata/nodes/webhook_trigger.yaml
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from ..entities import ExecutionContext
|
||||
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
|
||||
|
||||
|
||||
@workflow_node('webhook_trigger')
|
||||
class WebhookTriggerNode(WorkflowNode):
|
||||
"""Webhook trigger node - triggers workflow via HTTP request"""
|
||||
|
||||
type_name = "webhook_trigger"
|
||||
category = "trigger"
|
||||
icon = "🌐"
|
||||
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
|
||||
|
||||
return {
|
||||
"body": trigger_data.get("body", {}),
|
||||
"headers": trigger_data.get("headers", {}),
|
||||
"query": trigger_data.get("query", {}),
|
||||
"method": trigger_data.get("method", "POST"),
|
||||
}
|
||||
161
src/langbot/pkg/workflow/registry.py
Normal file
161
src/langbot/pkg/workflow/registry.py
Normal file
@@ -0,0 +1,161 @@
|
||||
"""Node type registry"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from .node import WorkflowNode, get_pending_registrations, clear_pending_registrations
|
||||
|
||||
|
||||
class NodeTypeRegistry:
|
||||
"""
|
||||
Central registry for all workflow node types.
|
||||
Supports both built-in and plugin-provided nodes.
|
||||
"""
|
||||
|
||||
_instance: Optional['NodeTypeRegistry'] = None
|
||||
|
||||
def __init__(self):
|
||||
self._nodes: dict[str, type[WorkflowNode]] = {}
|
||||
self._categories: dict[str, list[str]] = {
|
||||
'trigger': [],
|
||||
'process': [],
|
||||
'control': [],
|
||||
'action': [],
|
||||
'integration': [],
|
||||
'misc': [],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def instance(cls) -> 'NodeTypeRegistry':
|
||||
"""Get singleton instance"""
|
||||
if cls._instance is None:
|
||||
cls._instance = cls()
|
||||
return cls._instance
|
||||
|
||||
def register(self, node_type: str, node_class: type[WorkflowNode]):
|
||||
"""
|
||||
Register a node type.
|
||||
|
||||
Args:
|
||||
node_type: Unique type identifier
|
||||
node_class: WorkflowNode subclass
|
||||
"""
|
||||
self._nodes[node_type] = node_class
|
||||
|
||||
# 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)
|
||||
|
||||
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]
|
||||
|
||||
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]
|
||||
|
||||
# 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
|
||||
|
||||
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."""
|
||||
node_class = self.get(node_type)
|
||||
if node_class:
|
||||
return node_class(node_id, config, ap=ap)
|
||||
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()
|
||||
]
|
||||
|
||||
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
|
||||
"""
|
||||
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
|
||||
]
|
||||
|
||||
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
|
||||
"""
|
||||
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
|
||||
|
||||
def process_pending_registrations(self):
|
||||
"""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)
|
||||
clear_pending_registrations()
|
||||
|
||||
def count(self) -> int:
|
||||
"""Get total number of registered node types"""
|
||||
return len(self._nodes)
|
||||
|
||||
def clear(self):
|
||||
"""Clear all registrations (for testing)"""
|
||||
self._nodes.clear()
|
||||
for category in self._categories:
|
||||
self._categories[category] = []
|
||||
|
||||
|
||||
# Convenience functions for module-level access
|
||||
def register_node(node_type: str, node_class: type[WorkflowNode]):
|
||||
"""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"""
|
||||
return NodeTypeRegistry.instance().get(node_type)
|
||||
|
||||
|
||||
def list_node_types() -> list[dict[str, Any]]:
|
||||
"""List all registered node types"""
|
||||
return NodeTypeRegistry.instance().list_all()
|
||||
151
src/langbot/pkg/workflow/safe_eval.py
Normal file
151
src/langbot/pkg/workflow/safe_eval.py
Normal file
@@ -0,0 +1,151 @@
|
||||
"""Safe expression evaluator for workflow nodes.
|
||||
|
||||
Uses Python's ``ast`` module to whitelist only comparison, boolean, arithmetic,
|
||||
and simple attribute / subscript access. No function calls, imports, or
|
||||
arbitrary code execution.
|
||||
|
||||
The public API is :func:`safe_eval_with_vars` which accepts a mapping of
|
||||
allowed variable names so that expressions like ``input == "hello"`` or
|
||||
``data.x > 3`` work without resorting to :func:`eval`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import operator
|
||||
from typing import Any
|
||||
|
||||
|
||||
_SAFE_OPS = {
|
||||
# Arithmetic
|
||||
ast.Add: operator.add,
|
||||
ast.Sub: operator.sub,
|
||||
ast.Mult: operator.mul,
|
||||
ast.Div: operator.truediv,
|
||||
ast.FloorDiv: operator.floordiv,
|
||||
ast.Mod: operator.mod,
|
||||
ast.Pow: operator.pow,
|
||||
# Unary
|
||||
ast.USub: operator.neg,
|
||||
ast.UAdd: operator.pos,
|
||||
ast.Not: operator.not_,
|
||||
# Comparison
|
||||
ast.Eq: operator.eq,
|
||||
ast.NotEq: operator.ne,
|
||||
ast.Lt: operator.lt,
|
||||
ast.LtE: operator.le,
|
||||
ast.Gt: operator.gt,
|
||||
ast.GtE: operator.ge,
|
||||
ast.Is: operator.is_,
|
||||
ast.IsNot: operator.is_not,
|
||||
ast.In: lambda a, b: a in b,
|
||||
ast.NotIn: lambda a, b: a not in b,
|
||||
}
|
||||
|
||||
|
||||
def safe_eval_with_vars(expr: str, variables: dict[str, Any] | None = None) -> Any:
|
||||
"""Evaluate an expression safely with an optional variable mapping.
|
||||
|
||||
Supports:
|
||||
- Literals (numbers, strings, booleans, None)
|
||||
- Comparisons (==, !=, <, >, <=, >=, in, not in, is, is not)
|
||||
- Boolean logic (and, or, not)
|
||||
- Arithmetic (+, -, *, /, //, %, **)
|
||||
- Ternary (x if cond else y)
|
||||
- Variable references from *variables* dict (e.g. ``input``, ``data``)
|
||||
- Attribute access on known variables (e.g. ``data.name``)
|
||||
- Subscript access on known variables (e.g. ``data["key"]``, ``items[0]``)
|
||||
|
||||
Raises :class:`ValueError` on any disallowed construct (function calls,
|
||||
starred expressions, walrus operator, etc.).
|
||||
"""
|
||||
variables = variables or {}
|
||||
tree = ast.parse(expr.strip(), mode='eval')
|
||||
return _eval_node(tree.body, variables)
|
||||
|
||||
|
||||
def _eval_node(node: ast.AST, variables: dict[str, Any]) -> Any:
|
||||
# Literals
|
||||
if isinstance(node, ast.Constant):
|
||||
return node.value
|
||||
|
||||
# Variable references
|
||||
if isinstance(node, ast.Name):
|
||||
if node.id in ('None', 'True', 'False'):
|
||||
return {'None': None, 'True': True, 'False': False}[node.id]
|
||||
if node.id in variables:
|
||||
return variables[node.id]
|
||||
raise ValueError(f"Unsupported variable reference: {node.id}")
|
||||
|
||||
# Attribute access: obj.attr (only on allowed variables)
|
||||
if isinstance(node, ast.Attribute):
|
||||
obj = _eval_node(node.value, variables)
|
||||
attr = node.attr
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(attr)
|
||||
if hasattr(obj, attr):
|
||||
return getattr(obj, attr)
|
||||
return None
|
||||
|
||||
# Subscript access: obj[key] (only on allowed variables)
|
||||
if isinstance(node, ast.Subscript):
|
||||
obj = _eval_node(node.value, variables)
|
||||
key = _eval_node(node.slice, variables)
|
||||
try:
|
||||
return obj[key]
|
||||
except (KeyError, IndexError, TypeError):
|
||||
return None
|
||||
|
||||
# Unary operators
|
||||
if isinstance(node, ast.UnaryOp):
|
||||
op_fn = _SAFE_OPS.get(type(node.op))
|
||||
if op_fn is None:
|
||||
raise ValueError(f"Unsupported unary op: {type(node.op).__name__}")
|
||||
return op_fn(_eval_node(node.operand, variables))
|
||||
|
||||
# Binary operators
|
||||
if isinstance(node, ast.BinOp):
|
||||
op_fn = _SAFE_OPS.get(type(node.op))
|
||||
if op_fn is None:
|
||||
raise ValueError(f"Unsupported binary op: {type(node.op).__name__}")
|
||||
return op_fn(_eval_node(node.left, variables), _eval_node(node.right, variables))
|
||||
|
||||
# Comparisons (chained)
|
||||
if isinstance(node, ast.Compare):
|
||||
left = _eval_node(node.left, variables)
|
||||
for op, comparator in zip(node.ops, node.comparators):
|
||||
op_fn = _SAFE_OPS.get(type(op))
|
||||
if op_fn is None:
|
||||
raise ValueError(f"Unsupported comparison: {type(op).__name__}")
|
||||
right = _eval_node(comparator, variables)
|
||||
if not op_fn(left, right):
|
||||
return False
|
||||
left = right
|
||||
return True
|
||||
|
||||
# Boolean operators
|
||||
if isinstance(node, ast.BoolOp):
|
||||
if isinstance(node.op, ast.And):
|
||||
return all(_eval_node(v, variables) for v in node.values)
|
||||
if isinstance(node.op, ast.Or):
|
||||
return any(_eval_node(v, variables) for v in node.values)
|
||||
|
||||
# Ternary
|
||||
if isinstance(node, ast.IfExp):
|
||||
return (
|
||||
_eval_node(node.body, variables)
|
||||
if _eval_node(node.test, variables)
|
||||
else _eval_node(node.orelse, variables)
|
||||
)
|
||||
|
||||
# Tuples / Lists (e.g. ``x in [1, 2, 3]``)
|
||||
if isinstance(node, (ast.Tuple, ast.List)):
|
||||
return [_eval_node(e, variables) for e in node.elts]
|
||||
|
||||
# Dict literals (e.g. ``{"a": 1}``)
|
||||
if isinstance(node, ast.Dict):
|
||||
return {
|
||||
_eval_node(k, variables): _eval_node(v, variables)
|
||||
for k, v in zip(node.keys, node.values)
|
||||
}
|
||||
|
||||
raise ValueError(f"Unsupported expression node: {type(node).__name__}")
|
||||
Reference in New Issue
Block a user