diff --git a/src/langbot/pkg/api/http/controller/groups/workflows/__init__.py b/src/langbot/pkg/api/http/controller/groups/workflows/__init__.py index 75ad22b9..619aebb8 100644 --- a/src/langbot/pkg/api/http/controller/groups/workflows/__init__.py +++ b/src/langbot/pkg/api/http/controller/groups/workflows/__init__.py @@ -3,4 +3,3 @@ from .workflows import WorkflowsRouterGroup, ExecutionsRouterGroup from .websocket_chat import WorkflowWebSocketChatRouterGroup __all__ = ['WorkflowsRouterGroup', 'ExecutionsRouterGroup', 'WorkflowWebSocketChatRouterGroup'] - diff --git a/src/langbot/pkg/api/http/controller/groups/workflows/websocket_chat.py b/src/langbot/pkg/api/http/controller/groups/workflows/websocket_chat.py index 685ee715..607380ff 100644 --- a/src/langbot/pkg/api/http/controller/groups/workflows/websocket_chat.py +++ b/src/langbot/pkg/api/http/controller/groups/workflows/websocket_chat.py @@ -40,7 +40,7 @@ class WorkflowWebSocketChatRouterGroup(group.RouterGroup): 'origin': quart.websocket.headers.get('Origin', ''), }, ) - + if session_type not in ['person', 'group']: await quart.websocket.send( json.dumps({'type': 'error', 'message': 'session_type must be person or group'}) @@ -48,7 +48,7 @@ class WorkflowWebSocketChatRouterGroup(group.RouterGroup): return websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter - + if not websocket_adapter: logger.warning( 'Workflow WebSocket adapter missing', diff --git a/src/langbot/pkg/api/http/controller/groups/workflows/workflows.py b/src/langbot/pkg/api/http/controller/groups/workflows/workflows.py index 7496f6ee..d989d45e 100644 --- a/src/langbot/pkg/api/http/controller/groups/workflows/workflows.py +++ b/src/langbot/pkg/api/http/controller/groups/workflows/workflows.py @@ -9,7 +9,7 @@ from ....service.workflow import WorkflowExecutionFailedError @group.group_class('workflows', '/api/v1/workflows') class WorkflowsRouterGroup(group.RouterGroup): """Workflow API router group""" - + async def initialize(self) -> None: # Workflow CRUD @self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) @@ -19,9 +19,7 @@ class WorkflowsRouterGroup(group.RouterGroup): sort_order = quart.request.args.get('sort_order', 'DESC') enabled_only = quart.request.args.get('enabled_only', 'false').lower() == 'true' return self.success( - data={'workflows': await self.ap.workflow_service.get_workflows( - sort_by, sort_order, enabled_only - )} + data={'workflows': await self.ap.workflow_service.get_workflows(sort_by, sort_order, enabled_only)} ) elif quart.request.method == 'POST': json_data = await quart.request.json @@ -31,10 +29,12 @@ class WorkflowsRouterGroup(group.RouterGroup): # Get node types (available nodes for the editor) @self.route('/_/node-types', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) async def _() -> str: - return self.success(data={ - 'node_types': await self.ap.workflow_service.get_node_types(), - 'categories': await self.ap.workflow_service.get_node_types_by_category_meta(), - }) + return self.success( + data={ + 'node_types': await self.ap.workflow_service.get_node_types(), + 'categories': await self.ap.workflow_service.get_node_types_by_category_meta(), + } + ) # Get node types by category @self.route('/_/node-types/categories', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) @@ -97,7 +97,7 @@ class WorkflowsRouterGroup(group.RouterGroup): session_id = json_data.get('session_id') user_id = json_data.get('user_id') bot_id = json_data.get('bot_id') - + try: execution_id = await self.ap.workflow_service.execute_workflow( workflow_uuid, @@ -105,7 +105,7 @@ class WorkflowsRouterGroup(group.RouterGroup): trigger_data=trigger_data, session_id=session_id, user_id=user_id, - bot_id=bot_id + bot_id=bot_id, ) return self.success(data={'execution_id': execution_id}) except ValueError as e: @@ -119,9 +119,7 @@ class WorkflowsRouterGroup(group.RouterGroup): limit = int(quart.request.args.get('limit', 50)) offset = int(quart.request.args.get('offset', 0)) executions = await self.ap.workflow_service.get_executions( - workflow_uuid=workflow_uuid, - limit=limit, - offset=offset + workflow_uuid=workflow_uuid, limit=limit, offset=offset ) return self.success(data=executions) @@ -146,9 +144,7 @@ class WorkflowsRouterGroup(group.RouterGroup): # Rollback to a specific version @self.route( - '//rollback/', - methods=['POST'], - auth_type=group.AuthType.USER_TOKEN_OR_API_KEY + '//rollback/', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY ) async def _(workflow_uuid: str, version: int) -> str: try: @@ -192,14 +188,12 @@ class WorkflowsRouterGroup(group.RouterGroup): try: await self.ap.workflow_service.update_workflow_extensions( - workflow_uuid, bound_plugins, bound_mcp_servers, - enable_all_plugins, enable_all_mcp_servers + workflow_uuid, bound_plugins, bound_mcp_servers, enable_all_plugins, enable_all_mcp_servers ) return self.success() except ValueError as e: return self.http_status(404, -1, str(e)) - # Debug API - Start debug execution @self.route('//debug/start', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) async def _(workflow_uuid: str) -> str: @@ -207,13 +201,10 @@ class WorkflowsRouterGroup(group.RouterGroup): context = json_data.get('context', {}) variables = json_data.get('variables', {}) breakpoints = json_data.get('breakpoints', []) - + try: execution_id = await self.ap.workflow_service.start_debug_execution( - workflow_uuid, - context=context, - variables=variables, - breakpoints=breakpoints + workflow_uuid, context=context, variables=variables, breakpoints=breakpoints ) return self.success(data={'execution_id': execution_id}) except ValueError as e: @@ -223,7 +214,7 @@ class WorkflowsRouterGroup(group.RouterGroup): @self.route( '//debug//pause', methods=['POST'], - auth_type=group.AuthType.USER_TOKEN_OR_API_KEY + auth_type=group.AuthType.USER_TOKEN_OR_API_KEY, ) async def _(workflow_uuid: str, execution_uuid: str) -> str: try: @@ -236,7 +227,7 @@ class WorkflowsRouterGroup(group.RouterGroup): @self.route( '//debug//resume', methods=['POST'], - auth_type=group.AuthType.USER_TOKEN_OR_API_KEY + auth_type=group.AuthType.USER_TOKEN_OR_API_KEY, ) async def _(workflow_uuid: str, execution_uuid: str) -> str: try: @@ -249,7 +240,7 @@ class WorkflowsRouterGroup(group.RouterGroup): @self.route( '//debug//step', methods=['POST'], - auth_type=group.AuthType.USER_TOKEN_OR_API_KEY + auth_type=group.AuthType.USER_TOKEN_OR_API_KEY, ) async def _(workflow_uuid: str, execution_uuid: str) -> str: try: @@ -262,7 +253,7 @@ class WorkflowsRouterGroup(group.RouterGroup): @self.route( '//debug//stop', methods=['POST'], - auth_type=group.AuthType.USER_TOKEN_OR_API_KEY + auth_type=group.AuthType.USER_TOKEN_OR_API_KEY, ) async def _(workflow_uuid: str, execution_uuid: str) -> str: try: @@ -275,7 +266,7 @@ class WorkflowsRouterGroup(group.RouterGroup): @self.route( '//debug//state', methods=['GET'], - auth_type=group.AuthType.USER_TOKEN_OR_API_KEY + auth_type=group.AuthType.USER_TOKEN_OR_API_KEY, ) async def _(workflow_uuid: str, execution_uuid: str) -> str: try: @@ -288,15 +279,13 @@ class WorkflowsRouterGroup(group.RouterGroup): @self.route( '//executions//logs', methods=['GET'], - auth_type=group.AuthType.USER_TOKEN_OR_API_KEY + auth_type=group.AuthType.USER_TOKEN_OR_API_KEY, ) async def _(workflow_uuid: str, execution_uuid: str) -> str: limit = int(quart.request.args.get('limit', 100)) offset = int(quart.request.args.get('offset', 0)) try: - result = await self.ap.workflow_service.get_execution_logs( - workflow_uuid, execution_uuid, limit, offset - ) + result = await self.ap.workflow_service.get_execution_logs(workflow_uuid, execution_uuid, limit, offset) return self.success(data=result) except ValueError as e: return self.http_status(404, -1, str(e)) @@ -305,13 +294,11 @@ class WorkflowsRouterGroup(group.RouterGroup): @self.route( '//executions//rerun', methods=['POST'], - auth_type=group.AuthType.USER_TOKEN_OR_API_KEY + auth_type=group.AuthType.USER_TOKEN_OR_API_KEY, ) async def _(workflow_uuid: str, execution_uuid: str) -> str: try: - new_execution_id = await self.ap.workflow_service.rerun_execution( - workflow_uuid, execution_uuid - ) + new_execution_id = await self.ap.workflow_service.rerun_execution(workflow_uuid, execution_uuid) return self.success(data={'execution_uuid': new_execution_id}) except ValueError as e: return self.http_status(404, -1, str(e)) @@ -329,7 +316,7 @@ class WorkflowsRouterGroup(group.RouterGroup): @group.group_class('executions', '/api/v1/executions') class ExecutionsRouterGroup(group.RouterGroup): """Workflow execution API router group""" - + async def initialize(self) -> None: # Get all executions (across all workflows) @self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) @@ -337,11 +324,7 @@ class ExecutionsRouterGroup(group.RouterGroup): limit = int(quart.request.args.get('limit', 50)) offset = int(quart.request.args.get('offset', 0)) status = quart.request.args.get('status') - executions = await self.ap.workflow_service.get_executions( - limit=limit, - offset=offset, - status=status - ) + executions = await self.ap.workflow_service.get_executions(limit=limit, offset=offset, status=status) return self.success(data=executions) # Get single execution diff --git a/src/langbot/pkg/api/http/service/bot.py b/src/langbot/pkg/api/http/service/bot.py index b5b52726..06898dac 100644 --- a/src/langbot/pkg/api/http/service/bot.py +++ b/src/langbot/pkg/api/http/service/bot.py @@ -133,7 +133,7 @@ class BotService: # Handle binding_type and binding_uuid for the new unified binding model # If binding_type is explicitly set to 'workflow', skip pipeline validation binding_type = bot_data.get('binding_type') - + # set use_pipeline_name (for backward compatibility with 'pipeline' binding_type) if 'use_pipeline_uuid' in bot_data: result = await self.ap.persistence_mgr.execute_async( @@ -150,7 +150,7 @@ class BotService: bot_data['binding_type'] = 'pipeline' else: raise Exception('Pipeline not found') - + # If binding_uuid is set directly (for workflow), sync use_pipeline_uuid for backward compatibility if 'binding_uuid' in bot_data and binding_type == 'workflow': # For workflow binding, we don't sync to use_pipeline_uuid diff --git a/src/langbot/pkg/api/http/service/workflow.py b/src/langbot/pkg/api/http/service/workflow.py index 2fe66e18..418615f5 100644 --- a/src/langbot/pkg/api/http/service/workflow.py +++ b/src/langbot/pkg/api/http/service/workflow.py @@ -1,4 +1,5 @@ """Workflow service for managing workflow CRUD and execution""" + from __future__ import annotations import asyncio @@ -45,29 +46,26 @@ class WorkflowService: DEFAULT_MAX_EXECUTION_TIME = 300 STALE_EXECUTION_TIMEOUT_SECONDS = 300 - + ap: app.Application - + def __init__(self, ap: app.Application) -> None: self.ap = ap self.executor = WorkflowExecutor(ap) self.registry = NodeTypeRegistry.instance() - + # Import workflow nodes to trigger registration from ....workflow import nodes # noqa: F401 - + async def get_workflows( - self, - sort_by: str = 'created_at', - sort_order: str = 'DESC', - enabled_only: bool = False + self, sort_by: str = 'created_at', sort_order: str = 'DESC', enabled_only: bool = False ) -> list[dict]: """Get all workflows""" query = sqlalchemy.select(persistence_workflow.Workflow) - + if enabled_only: query = query.where(persistence_workflow.Workflow.is_enabled == True) - + if sort_by == 'created_at': if sort_order == 'DESC': query = query.order_by(persistence_workflow.Workflow.created_at.desc()) @@ -83,30 +81,25 @@ class WorkflowService: query = query.order_by(persistence_workflow.Workflow.name.desc()) else: query = query.order_by(persistence_workflow.Workflow.name.asc()) - + result = await self.ap.persistence_mgr.execute_async(query) workflows = result.all() - - return [ - self._serialize_workflow(workflow) - for workflow in workflows - ] - + + return [self._serialize_workflow(workflow) for workflow in workflows] + async def get_workflow(self, workflow_uuid: str) -> Optional[dict]: """Get a single workflow by UUID""" result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.select(persistence_workflow.Workflow).where( - persistence_workflow.Workflow.uuid == workflow_uuid - ) + sqlalchemy.select(persistence_workflow.Workflow).where(persistence_workflow.Workflow.uuid == workflow_uuid) ) - + workflow = result.first() - + if workflow is None: return None - + return self._serialize_workflow(workflow) - + async def create_workflow(self, workflow_data: dict) -> str: """Create a new workflow""" # Check limitation @@ -116,9 +109,9 @@ class WorkflowService: existing_workflows = await self.get_workflows() if len(existing_workflows) >= max_workflows: raise ValueError(f'Maximum number of workflows ({max_workflows}) reached') - + workflow_uuid = str(uuid.uuid4()) - + # Prepare workflow data new_workflow = { 'uuid': workflow_uuid, @@ -127,54 +120,60 @@ class WorkflowService: 'emoji': workflow_data.get('emoji', '🔄'), 'version': 1, 'is_enabled': workflow_data.get('is_enabled', True), - 'definition': workflow_data.get('definition', { - 'nodes': [], - 'edges': [], - 'variables': {}, - }), + 'definition': workflow_data.get( + 'definition', + { + 'nodes': [], + 'edges': [], + 'variables': {}, + }, + ), 'global_config': workflow_data.get('global_config', {}), - 'extensions_preferences': workflow_data.get('extensions_preferences', { - 'enable_all_plugins': True, - 'enable_all_mcp_servers': True, - 'plugins': [], - 'mcp_servers': [], - }), + 'extensions_preferences': workflow_data.get( + 'extensions_preferences', + { + 'enable_all_plugins': True, + 'enable_all_mcp_servers': True, + 'plugins': [], + 'mcp_servers': [], + }, + ), } - + await self.ap.persistence_mgr.execute_async( sqlalchemy.insert(persistence_workflow.Workflow).values(**new_workflow) ) - + return workflow_uuid - + async def update_workflow(self, workflow_uuid: str, workflow_data: dict) -> None: """Update an existing workflow""" # Remove protected fields protected_fields = ['uuid', 'created_at'] for field in protected_fields: workflow_data.pop(field, None) - + # Get current workflow to check version current = await self.get_workflow(workflow_uuid) if current is None: raise ValueError(f'Workflow {workflow_uuid} not found') - + # Handle nodes, edges and variables fields - merge them into definition # This handles the case where frontend sends nodes/edges/variables at top level nodes = workflow_data.pop('nodes', None) edges = workflow_data.pop('edges', None) variables = workflow_data.pop('variables', None) - + # Remove fields that don't exist as database columns workflow_data.pop('settings', None) workflow_data.pop('triggers', None) - + if nodes is not None or edges is not None or variables is not None: # Get current definition or create new one definition = workflow_data.get('definition', current.get('definition', {})) if not isinstance(definition, dict): definition = {} - + # Update nodes, edges and variables in definition if nodes is not None: definition['nodes'] = nodes @@ -182,27 +181,27 @@ class WorkflowService: definition['edges'] = edges if variables is not None: definition['variables'] = variables - + workflow_data['definition'] = definition - + # Increment version if definition changed if 'definition' in workflow_data: workflow_data['version'] = current.get('version', 0) + 1 - + # Save version history await self._save_version_history( workflow_uuid, current.get('version', 1), current.get('definition', {}), - current.get('global_config', {}) + current.get('global_config', {}), ) - + await self.ap.persistence_mgr.execute_async( sqlalchemy.update(persistence_workflow.Workflow) .where(persistence_workflow.Workflow.uuid == workflow_uuid) .values(**workflow_data) ) - + async def delete_workflow(self, workflow_uuid: str) -> None: """Delete a workflow""" # Delete related records first @@ -216,14 +215,12 @@ class WorkflowService: persistence_workflow.WorkflowTrigger.workflow_uuid == workflow_uuid ) ) - + # Delete the workflow await self.ap.persistence_mgr.execute_async( - sqlalchemy.delete(persistence_workflow.Workflow).where( - persistence_workflow.Workflow.uuid == workflow_uuid - ) + sqlalchemy.delete(persistence_workflow.Workflow).where(persistence_workflow.Workflow.uuid == workflow_uuid) ) - + async def copy_workflow(self, workflow_uuid: str) -> str: """Copy a workflow""" # Check limitation @@ -233,51 +230,54 @@ class WorkflowService: existing_workflows = await self.get_workflows() if len(existing_workflows) >= max_workflows: raise ValueError(f'Maximum number of workflows ({max_workflows}) reached') - + # Get original workflow original = await self.get_workflow(workflow_uuid) if original is None: raise ValueError(f'Workflow {workflow_uuid} not found') - + # Create copy new_uuid = str(uuid.uuid4()) new_workflow = { 'uuid': new_uuid, - 'name': f"{original['name']} (Copy)", + 'name': f'{original["name"]} (Copy)', 'description': original.get('description', ''), 'emoji': original.get('emoji', '🔄'), 'version': 1, 'is_enabled': False, # Disabled by default 'definition': original.get('definition', {}), 'global_config': original.get('global_config', {}), - 'extensions_preferences': original.get('extensions_preferences', { - 'enable_all_plugins': True, - 'enable_all_mcp_servers': True, - 'plugins': [], - 'mcp_servers': [], - }), + 'extensions_preferences': original.get( + 'extensions_preferences', + { + 'enable_all_plugins': True, + 'enable_all_mcp_servers': True, + 'plugins': [], + 'mcp_servers': [], + }, + ), } - + await self.ap.persistence_mgr.execute_async( sqlalchemy.insert(persistence_workflow.Workflow).values(**new_workflow) ) - + return new_uuid - + async def execute_workflow( - self, - workflow_uuid: str, + self, + workflow_uuid: str, trigger_type: str = 'manual', trigger_data: Optional[dict] = None, session_id: Optional[str] = None, user_id: Optional[str] = None, - bot_id: Optional[str] = None + bot_id: Optional[str] = None, ) -> str: """Execute a workflow and return execution ID""" workflow_dict = await self.get_workflow(workflow_uuid) if workflow_dict is None: raise ValueError(f'Workflow {workflow_uuid} not found') - + # Create execution record execution_uuid = str(uuid.uuid4()) execution_record = { @@ -290,11 +290,11 @@ class WorkflowService: 'variables': {}, 'start_time': datetime.now(), } - + await self.ap.persistence_mgr.execute_async( sqlalchemy.insert(persistence_workflow.WorkflowExecution).values(**execution_record) ) - + # Build WorkflowDefinition from dict definition = workflow_dict.get('definition', {}) workflow = WorkflowDefinition( @@ -325,7 +325,7 @@ class WorkflowService: ], variables=definition.get('variables', {}), ) - + raw_trigger_data = trigger_data or {} # Create execution context @@ -364,7 +364,7 @@ class WorkflowService: ), raw_message=message_context_data.get('raw_message', {}), ) - + max_execution_time = self.DEFAULT_MAX_EXECUTION_TIME workflow_settings = definition.get('settings', {}) if isinstance(definition, dict) else {} if isinstance(workflow_settings, dict): @@ -391,11 +391,7 @@ class WorkflowService: if getattr(state, 'status', None) == NodeStatus.FAILED ] error_message = next( - ( - state.error - for state in failed_nodes - if getattr(state, 'error', None) - ), + (state.error for state in failed_nodes if getattr(state, 'error', None)), 'Workflow execution failed', ) @@ -453,53 +449,41 @@ class WorkflowService: ) from e return execution_uuid - + async def get_executions( - self, - workflow_uuid: Optional[str] = None, - limit: int = 50, - offset: int = 0, - status: Optional[str] = None + self, workflow_uuid: Optional[str] = None, limit: int = 50, offset: int = 0, status: Optional[str] = None ) -> dict: """Get workflow executions with total count""" base_filter = [] - + if workflow_uuid: - base_filter.append( - persistence_workflow.WorkflowExecution.workflow_uuid == workflow_uuid - ) - + base_filter.append(persistence_workflow.WorkflowExecution.workflow_uuid == workflow_uuid) + if status: - base_filter.append( - persistence_workflow.WorkflowExecution.status == status - ) - + base_filter.append(persistence_workflow.WorkflowExecution.status == status) + # Get total count - count_query = sqlalchemy.select( - sqlalchemy.func.count(persistence_workflow.WorkflowExecution.uuid) - ) + count_query = sqlalchemy.select(sqlalchemy.func.count(persistence_workflow.WorkflowExecution.uuid)) for f in base_filter: count_query = count_query.where(f) count_result = await self.ap.persistence_mgr.execute_async(count_query) total = count_result.scalar() or 0 - + # Get paginated results query = sqlalchemy.select(persistence_workflow.WorkflowExecution) for f in base_filter: query = query.where(f) - - query = query.order_by( - persistence_workflow.WorkflowExecution.created_at.desc() - ).limit(limit).offset(offset) - + + query = query.order_by(persistence_workflow.WorkflowExecution.created_at.desc()).limit(limit).offset(offset) + result = await self.ap.persistence_mgr.execute_async(query) executions = result.all() - + return { 'executions': [self._serialize_execution(execution) for execution in executions], 'total': total, } - + async def get_execution(self, execution_uuid: str) -> Optional[dict]: """Get a single execution by UUID""" result = await self.ap.persistence_mgr.execute_async( @@ -507,42 +491,42 @@ class WorkflowService: persistence_workflow.WorkflowExecution.uuid == execution_uuid ) ) - + execution = result.first() - + if execution is None: return None - + data = self._serialize_execution(execution) - node_exec_query = sqlalchemy.select(persistence_workflow.WorkflowNodeExecution).where( - persistence_workflow.WorkflowNodeExecution.execution_uuid == execution_uuid - ).order_by(persistence_workflow.WorkflowNodeExecution.id.asc()) + node_exec_query = ( + sqlalchemy.select(persistence_workflow.WorkflowNodeExecution) + .where(persistence_workflow.WorkflowNodeExecution.execution_uuid == execution_uuid) + .order_by(persistence_workflow.WorkflowNodeExecution.id.asc()) + ) node_exec_result = await self.ap.persistence_mgr.execute_async(node_exec_query) node_executions = node_exec_result.all() - data['node_executions'] = [ - self._serialize_node_execution(node_exec) for node_exec in node_executions - ] + data['node_executions'] = [self._serialize_node_execution(node_exec) for node_exec in node_executions] return data - + async def get_node_types(self) -> list[dict]: """Get all available node types""" # Process pending registrations self.registry.process_pending_registrations() node_types = self.registry.list_all() - + # Enrich node schemas with pipeline config metadata return self._enrich_node_type_configs(node_types) - + async def get_node_types_by_category(self) -> dict[str, list[dict]]: """Get node types organized by category""" self.registry.process_pending_registrations() categories = self.registry.get_categories() - + # Enrich node schemas with pipeline config metadata for category, nodes in categories.items(): categories[category] = self._enrich_node_type_configs(nodes) - + return categories async def get_node_types_by_category_meta(self) -> list[dict]: @@ -564,48 +548,49 @@ class WorkflowService: for order, category_name in enumerate(ordered_categories): if category_name not in categories: continue - result.append({ - 'name': category_name, - 'label': label_map.get(category_name, { - 'en-US': category_name, - 'en': category_name, - 'zh-Hans': category_name, - 'zh-CN': category_name, - }), - 'order': order, - }) + result.append( + { + 'name': category_name, + 'label': label_map.get( + category_name, + { + 'en-US': category_name, + 'en': category_name, + 'zh-Hans': category_name, + 'zh-CN': category_name, + }, + ), + 'order': order, + } + ) return result - + def _enrich_node_type_configs(self, node_types: list[dict]) -> list[dict]: """Enrich node schemas with node YAML config metadata if available""" # Get workflow node configs loaded from YAML files workflow_node_configs = getattr(self.ap, 'workflow_node_configs', {}) - + for node_schema in node_types: node_type = node_schema.get('type', '') - + # First, try to load config from dedicated node YAML file if node_type in workflow_node_configs: node_config = workflow_node_configs[node_type] - + # Enrich inputs from YAML if Python class has empty inputs yaml_inputs = node_config.get('inputs', []) if yaml_inputs and not node_schema.get('inputs'): - node_schema['inputs'] = [ - self._normalize_port_item(inp) for inp in yaml_inputs - ] - + node_schema['inputs'] = [self._normalize_port_item(inp) for inp in yaml_inputs] + # Enrich outputs from YAML if Python class has empty outputs yaml_outputs = node_config.get('outputs', []) if yaml_outputs and not node_schema.get('outputs'): - node_schema['outputs'] = [ - self._normalize_port_item(out) for out in yaml_outputs - ] - + node_schema['outputs'] = [self._normalize_port_item(out) for out in yaml_outputs] + # Enrich config_schema from YAML yaml_configs = node_config.get('config', []) - + # Normalize and add YAML configs to config_schema existing_names = {cfg.get('name') for cfg in node_schema.get('config_schema', [])} for cfg in yaml_configs: @@ -613,11 +598,11 @@ class WorkflowService: normalized_cfg = self._normalize_config_item(cfg) node_schema['config_schema'].append(normalized_cfg) existing_names.add(cfg.get('name')) - + # Second, try to load from pipeline config metadata (for backward compatibility) config_schema_source = node_schema.get('config_schema_source') config_stages = node_schema.get('config_stages', []) - + if config_schema_source and config_stages: # Get pipeline config metadata pipeline_meta = self._get_pipeline_config_meta(config_schema_source) @@ -628,7 +613,7 @@ class WorkflowService: stage_data = self._find_stage_in_pipeline(pipeline_meta, stage_name) if stage_data and 'config' in stage_data: enriched_configs.extend(stage_data['config']) - + # Merge with existing config_schema (avoid duplicates by name) existing_names = {cfg.get('name') for cfg in node_schema.get('config_schema', [])} for cfg in enriched_configs: @@ -637,9 +622,9 @@ class WorkflowService: normalized_cfg = self._normalize_config_item(cfg) node_schema['config_schema'].append(normalized_cfg) existing_names.add(cfg.get('name')) - + return node_types - + def _normalize_port_item(self, port: dict) -> dict: """Normalize a port (input/output) item from YAML format to frontend-compatible format""" label = port.get('label') @@ -652,11 +637,14 @@ class WorkflowService: port = {**port, 'label': normalized_label} else: name = port.get('name', '') - port = {**port, 'label': { - 'en_US': name, - 'en': name, - 'zh_Hans': name, - }} + port = { + **port, + 'label': { + 'en_US': name, + 'en': name, + 'zh_Hans': name, + }, + } description = port.get('description') if isinstance(description, dict): @@ -667,11 +655,14 @@ class WorkflowService: } port = {**port, 'description': normalized_desc} else: - port = {**port, 'description': { - 'en_US': '', - 'en': '', - 'zh_Hans': '', - }} + port = { + **port, + 'description': { + 'en_US': '', + 'en': '', + 'zh_Hans': '', + }, + } return port @@ -690,12 +681,15 @@ class WorkflowService: else: # Create default label from name (handles both None and non-dict cases) name = cfg.get('name', '') - cfg = {**cfg, 'label': { - 'en_US': name, - 'en': name, - 'zh_Hans': name, - }} - + cfg = { + **cfg, + 'label': { + 'en_US': name, + 'en': name, + 'zh_Hans': name, + }, + } + # Ensure description is in proper i18n format description = cfg.get('description') if isinstance(description, dict): @@ -706,12 +700,15 @@ class WorkflowService: } cfg = {**cfg, 'description': normalized_desc} else: - cfg = {**cfg, 'description': { - 'en_US': '', - 'en': '', - 'zh_Hans': '', - }} - + cfg = { + **cfg, + 'description': { + 'en_US': '', + 'en': '', + 'zh_Hans': '', + }, + } + # Handle options - convert from list of {name, label} to list of {name, label} options = cfg.get('options') if isinstance(options, list) and len(options) > 0 and isinstance(options[0], dict): @@ -732,9 +729,9 @@ class WorkflowService: } normalized_options.append({**opt, 'label': normalized_opt_label}) cfg = {**cfg, 'options': normalized_options} - + return cfg - + def _get_pipeline_config_meta(self, source: str) -> Optional[dict]: """Get pipeline config metadata by source prefix""" if source.startswith('pipeline:'): @@ -744,7 +741,7 @@ class WorkflowService: meta_attr = f'pipeline_config_meta_{pipeline_type}' return getattr(self.ap, meta_attr, None) return None - + def _find_stage_in_pipeline(self, pipeline_meta: dict, stage_name: str) -> Optional[dict]: """Find a stage in pipeline config metadata""" stages = pipeline_meta.get('stages', []) @@ -752,7 +749,7 @@ class WorkflowService: if stage.get('name') == stage_name: return stage return None - + async def get_versions(self, workflow_uuid: str) -> list[dict]: """Get version history for a workflow""" result = await self.ap.persistence_mgr.execute_async( @@ -760,44 +757,39 @@ class WorkflowService: .where(persistence_workflow.WorkflowVersion.workflow_uuid == workflow_uuid) .order_by(persistence_workflow.WorkflowVersion.version.desc()) ) - + versions = result.all() - + return [ - self.ap.persistence_mgr.serialize_model( - persistence_workflow.WorkflowVersion, - version - ) + self.ap.persistence_mgr.serialize_model(persistence_workflow.WorkflowVersion, version) for version in versions ] - + async def rollback_to_version(self, workflow_uuid: str, version: int) -> None: """Rollback workflow to a specific version""" result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.select(persistence_workflow.WorkflowVersion) - .where( + sqlalchemy.select(persistence_workflow.WorkflowVersion).where( persistence_workflow.WorkflowVersion.workflow_uuid == workflow_uuid, - persistence_workflow.WorkflowVersion.version == version + persistence_workflow.WorkflowVersion.version == version, ) ) - + version_record = result.first() - + if version_record is None: raise ValueError(f'Version {version} not found for workflow {workflow_uuid}') - + # Update workflow with the old version's definition - await self.update_workflow(workflow_uuid, { - 'definition': version_record.definition, - 'global_config': version_record.global_config, - }) - + await self.update_workflow( + workflow_uuid, + { + 'definition': version_record.definition, + 'global_config': version_record.global_config, + }, + ) + async def _save_version_history( - self, - workflow_uuid: str, - version: int, - definition: dict, - global_config: dict + self, workflow_uuid: str, version: int, definition: dict, global_config: dict ) -> None: """Save workflow version to history""" # Check if version already exists (database-agnostic) @@ -809,7 +801,7 @@ class WorkflowService: ) if existing.first() is not None: return - + await self.ap.persistence_mgr.execute_async( sqlalchemy.insert(persistence_workflow.WorkflowVersion).values( workflow_uuid=workflow_uuid, @@ -818,14 +810,11 @@ class WorkflowService: global_config=global_config, ) ) - + def _serialize_workflow(self, workflow) -> dict: """Serialize workflow entity to dict""" - result = self.ap.persistence_mgr.serialize_model( - persistence_workflow.Workflow, - workflow - ) - + result = self.ap.persistence_mgr.serialize_model(persistence_workflow.Workflow, workflow) + # Extract nodes and edges from definition to top level for frontend compatibility definition = result.get('definition', {}) if isinstance(definition, dict): @@ -836,7 +825,7 @@ class WorkflowService: result['nodes'] = [] result['edges'] = [] result['variables'] = {} - + return result def _serialize_execution(self, execution) -> dict: @@ -856,7 +845,7 @@ class WorkflowService: data['started_at'] = data.get('start_time') data['completed_at'] = data.get('end_time') return data - + async def update_workflow_extensions( self, workflow_uuid: str, @@ -869,100 +858,100 @@ class WorkflowService: workflow = await self.get_workflow(workflow_uuid) if workflow is None: raise ValueError(f'Workflow {workflow_uuid} not found') - + extensions_preferences = workflow.get('extensions_preferences', {}) extensions_preferences['enable_all_plugins'] = enable_all_plugins extensions_preferences['enable_all_mcp_servers'] = enable_all_mcp_servers extensions_preferences['plugins'] = bound_plugins if bound_mcp_servers is not None: extensions_preferences['mcp_servers'] = bound_mcp_servers - + await self.ap.persistence_mgr.execute_async( sqlalchemy.update(persistence_workflow.Workflow) .where(persistence_workflow.Workflow.uuid == workflow_uuid) .values(extensions_preferences=extensions_preferences) ) - + async def publish_workflow(self, workflow_uuid: str) -> None: """Publish a workflow (set is_enabled to True)""" workflow = await self.get_workflow(workflow_uuid) if workflow is None: raise ValueError(f'Workflow {workflow_uuid} not found') - + await self.ap.persistence_mgr.execute_async( sqlalchemy.update(persistence_workflow.Workflow) .where(persistence_workflow.Workflow.uuid == workflow_uuid) .values(is_enabled=True) ) - + async def unpublish_workflow(self, workflow_uuid: str) -> None: """Unpublish a workflow (set is_enabled to False)""" workflow = await self.get_workflow(workflow_uuid) if workflow is None: raise ValueError(f'Workflow {workflow_uuid} not found') - + await self.ap.persistence_mgr.execute_async( sqlalchemy.update(persistence_workflow.Workflow) .where(persistence_workflow.Workflow.uuid == workflow_uuid) .values(is_enabled=False) ) - + async def get_workflow_stats(self, workflow_uuid: str) -> dict: """Get workflow statistics""" # Verify workflow exists workflow = await self.get_workflow(workflow_uuid) if workflow is None: raise ValueError(f'Workflow {workflow_uuid} not found') - + # Get total execution count total_result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.select(sqlalchemy.func.count(persistence_workflow.WorkflowExecution.uuid)) - .where(persistence_workflow.WorkflowExecution.workflow_uuid == workflow_uuid) + sqlalchemy.select(sqlalchemy.func.count(persistence_workflow.WorkflowExecution.uuid)).where( + persistence_workflow.WorkflowExecution.workflow_uuid == workflow_uuid + ) ) total_executions = total_result.scalar() - + # Get executions by status status_result = await self.ap.persistence_mgr.execute_async( sqlalchemy.select( persistence_workflow.WorkflowExecution.status, - sqlalchemy.func.count(persistence_workflow.WorkflowExecution.uuid) + sqlalchemy.func.count(persistence_workflow.WorkflowExecution.uuid), ) .where(persistence_workflow.WorkflowExecution.workflow_uuid == workflow_uuid) .group_by(persistence_workflow.WorkflowExecution.status) ) status_counts = {row[0]: row[1] for row in status_result.all()} - + # Get success and failure counts success_count = status_counts.get('completed', 0) failed_count = status_counts.get('failed', 0) pending_count = status_counts.get('pending', 0) running_count = status_counts.get('running', 0) cancelled_count = status_counts.get('cancelled', 0) - + # Calculate success rate (as 0-1 ratio for frontend) success_rate = 0.0 if total_executions > 0: success_rate = success_count / total_executions - + # Get average execution time (for completed executions) avg_time_result = await self.ap.persistence_mgr.execute_async( sqlalchemy.select( sqlalchemy.func.avg( - sqlalchemy.func.strftime('%s', persistence_workflow.WorkflowExecution.end_time) - - sqlalchemy.func.strftime('%s', persistence_workflow.WorkflowExecution.start_time) + sqlalchemy.func.strftime('%s', persistence_workflow.WorkflowExecution.end_time) + - sqlalchemy.func.strftime('%s', persistence_workflow.WorkflowExecution.start_time) ) - ) - .where( + ).where( persistence_workflow.WorkflowExecution.workflow_uuid == workflow_uuid, persistence_workflow.WorkflowExecution.status == 'completed', persistence_workflow.WorkflowExecution.start_time.isnot(None), - persistence_workflow.WorkflowExecution.end_time.isnot(None) + persistence_workflow.WorkflowExecution.end_time.isnot(None), ) ) avg_execution_time = avg_time_result.scalar() # Ensure we always return a valid number, not None avg_execution_time = float(avg_execution_time) if avg_execution_time is not None else 0.0 - + # Get last execution time last_execution_result = await self.ap.persistence_mgr.execute_async( sqlalchemy.select(persistence_workflow.WorkflowExecution.created_at) @@ -971,7 +960,7 @@ class WorkflowService: .limit(1) ) last_execution = last_execution_result.first() - + return { 'total_executions': int(total_executions), 'successful_executions': int(success_count), @@ -980,22 +969,24 @@ class WorkflowService: 'running_executions': int(running_count), 'cancelled_executions': int(cancelled_count), 'success_rate': round(success_rate, 4), # Already 0-1 ratio - 'average_duration_ms': round(avg_execution_time * 1000, 2) if avg_execution_time > 0 else 0, # Convert to milliseconds + 'average_duration_ms': round(avg_execution_time * 1000, 2) + if avg_execution_time > 0 + else 0, # Convert to milliseconds 'last_execution_time': last_execution[0] if last_execution else None, } - + async def start_debug_execution( self, workflow_uuid: str, context: Optional[dict] = None, variables: Optional[dict] = None, - breakpoints: Optional[list[str]] = None + breakpoints: Optional[list[str]] = None, ) -> str: """Start a debug execution and return execution ID""" workflow = await self.get_workflow(workflow_uuid) if workflow is None: raise ValueError(f'Workflow {workflow_uuid} not found') - + execution_uuid = str(uuid.uuid4()) execution_record = { 'uuid': execution_uuid, @@ -1007,13 +998,13 @@ class WorkflowService: 'variables': variables or {}, 'start_time': datetime.now(), } - + await self.ap.persistence_mgr.execute_async( sqlalchemy.insert(persistence_workflow.WorkflowExecution).values(**execution_record) ) - + return execution_uuid - + async def pause_debug_execution(self, workflow_uuid: str, execution_uuid: str) -> None: """Pause a debug execution""" await self.ap.persistence_mgr.execute_async( @@ -1021,7 +1012,7 @@ class WorkflowService: .where(persistence_workflow.WorkflowExecution.uuid == execution_uuid) .values(status='paused') ) - + async def resume_debug_execution(self, workflow_uuid: str, execution_uuid: str) -> None: """Resume a paused debug execution""" await self.ap.persistence_mgr.execute_async( @@ -1029,65 +1020,57 @@ class WorkflowService: .where(persistence_workflow.WorkflowExecution.uuid == execution_uuid) .values(status='running') ) - + async def step_debug_execution(self, workflow_uuid: str, execution_uuid: str) -> dict: """Step through a debug execution""" return {'status': 'stepped', 'execution_uuid': execution_uuid} - + async def stop_debug_execution(self, workflow_uuid: str, execution_uuid: str) -> None: """Stop a debug execution""" await self.ap.persistence_mgr.execute_async( sqlalchemy.update(persistence_workflow.WorkflowExecution) .where(persistence_workflow.WorkflowExecution.uuid == execution_uuid) - .values( - status='cancelled', - end_time=datetime.now() - ) + .values(status='cancelled', end_time=datetime.now()) ) - + async def get_debug_state(self, workflow_uuid: str, execution_uuid: str) -> dict: """Get the current debug state""" execution = await self.get_execution(execution_uuid) if execution is None: raise ValueError(f'Execution {execution_uuid} not found') - + return { 'execution_uuid': execution_uuid, 'status': execution.get('status'), 'variables': execution.get('variables', {}), } - + async def cancel_execution(self, execution_uuid: str) -> None: """Cancel a workflow execution""" execution = await self.get_execution(execution_uuid) if execution is None: raise ValueError(f'Execution {execution_uuid} not found') - + if execution.get('status') not in ['pending', 'running']: raise RuntimeError(f'Cannot cancel execution with status {execution.get("status")}') - + await self.ap.persistence_mgr.execute_async( sqlalchemy.update(persistence_workflow.WorkflowExecution) .where(persistence_workflow.WorkflowExecution.uuid == execution_uuid) - .values( - status='cancelled', - end_time=datetime.now() - ) + .values(status='cancelled', end_time=datetime.now()) ) - + async def rerun_execution(self, workflow_uuid: str, execution_uuid: str) -> str: """Rerun a workflow execution with the same trigger data""" original_execution = await self.get_execution(execution_uuid) if original_execution is None: raise ValueError(f'Execution {execution_uuid} not found') - + trigger_data = original_execution.get('trigger_data', {}) new_execution_uuid = await self.execute_workflow( - workflow_uuid, - trigger_type=original_execution.get('trigger_type', 'manual'), - trigger_data=trigger_data + workflow_uuid, trigger_type=original_execution.get('trigger_type', 'manual'), trigger_data=trigger_data ) - + return new_execution_uuid async def cleanup_stale_executions(self, timeout_seconds: int | None = None) -> int: @@ -1118,13 +1101,9 @@ class WorkflowService: ) return int(getattr(result, 'rowcount', 0) or 0) - + async def get_execution_logs( - self, - workflow_uuid: str, - execution_uuid: str, - limit: int = 100, - offset: int = 0 + self, workflow_uuid: str, execution_uuid: str, limit: int = 100, offset: int = 0 ) -> dict: """Get execution logs for a workflow execution""" execution = await self.get_execution(execution_uuid) @@ -1133,11 +1112,13 @@ class WorkflowService: if execution.get('workflow_uuid') != workflow_uuid: raise ValueError(f'Execution {execution_uuid} not found in workflow {workflow_uuid}') - query = sqlalchemy.select(persistence_workflow.WorkflowNodeExecution).where( - persistence_workflow.WorkflowNodeExecution.execution_uuid == execution_uuid - ).order_by( - persistence_workflow.WorkflowNodeExecution.id.asc() - ).limit(limit).offset(offset) + query = ( + sqlalchemy.select(persistence_workflow.WorkflowNodeExecution) + .where(persistence_workflow.WorkflowNodeExecution.execution_uuid == execution_uuid) + .order_by(persistence_workflow.WorkflowNodeExecution.id.asc()) + .limit(limit) + .offset(offset) + ) result = await self.ap.persistence_mgr.execute_async(query) node_executions = result.all() @@ -1147,11 +1128,9 @@ class WorkflowService: serialized = self._serialize_node_execution(node_exec) timestamp = serialized.get('completed_at') or serialized.get('started_at') or execution.get('started_at') level = 'error' if serialized.get('status') == 'failed' else 'info' - message = ( - f"{serialized.get('node_type')}::{serialized.get('node_id')} - {serialized.get('status')}" - ) + message = f'{serialized.get("node_type")}::{serialized.get("node_id")} - {serialized.get("status")}' if serialized.get('error'): - message = f"{message} - {serialized.get('error')}" + message = f'{message} - {serialized.get("error")}' logs.append( { 'id': str(serialized.get('id', serialized.get('node_id'))), diff --git a/src/langbot/pkg/core/app.py b/src/langbot/pkg/core/app.py index 6382283b..df8f4ffe 100644 --- a/src/langbot/pkg/core/app.py +++ b/src/langbot/pkg/core/app.py @@ -246,9 +246,7 @@ class Application: try: cancelled = await self.workflow_service.cleanup_stale_executions() if cancelled > 0: - self.logger.info( - f'Workflow execution auto-cleanup: cancelled {cancelled} stale executions' - ) + self.logger.info(f'Workflow execution auto-cleanup: cancelled {cancelled} stale executions') except Exception as e: self.logger.warning(f'Workflow execution auto-cleanup error: {e}') await asyncio.sleep(check_interval_seconds) diff --git a/src/langbot/pkg/entity/persistence/bot.py b/src/langbot/pkg/entity/persistence/bot.py index 22e2372b..b8ef3f15 100644 --- a/src/langbot/pkg/entity/persistence/bot.py +++ b/src/langbot/pkg/entity/persistence/bot.py @@ -17,13 +17,13 @@ class Bot(Base): use_pipeline_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) use_pipeline_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) pipeline_routing_rules = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, server_default='[]') - + # New unified binding fields # binding_type: 'pipeline' or 'workflow' binding_type = sqlalchemy.Column(sqlalchemy.String(32), nullable=False, server_default='pipeline') # binding_uuid: UUID of the bound Pipeline or Workflow binding_uuid = sqlalchemy.Column(sqlalchemy.String(64), nullable=True) - + created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now()) updated_at = sqlalchemy.Column( sqlalchemy.DateTime, diff --git a/src/langbot/pkg/entity/persistence/workflow.py b/src/langbot/pkg/entity/persistence/workflow.py index 9e38fa3d..f8c2d03f 100644 --- a/src/langbot/pkg/entity/persistence/workflow.py +++ b/src/langbot/pkg/entity/persistence/workflow.py @@ -1,4 +1,5 @@ """Workflow persistence entities""" + import sqlalchemy from .base import Base @@ -15,22 +16,22 @@ class Workflow(Base): emoji = sqlalchemy.Column(sqlalchemy.String(10), nullable=True, default='🔄') version = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, default=1) is_enabled = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=True) - + # Workflow definition stored as JSON # Contains: nodes, edges, variables, settings definition = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={}) - + # Global config (inherited from Pipeline capabilities) # Contains: safety, output configs global_config = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={}) - + # Extensions preferences (same as Pipeline) extensions_preferences = sqlalchemy.Column( sqlalchemy.JSON, nullable=False, default={'enable_all_plugins': True, 'enable_all_mcp_servers': True, 'plugins': [], 'mcp_servers': []}, ) - + created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now()) updated_at = sqlalchemy.Column( sqlalchemy.DateTime, @@ -53,9 +54,7 @@ class WorkflowVersion(Base): created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now()) created_by = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) - __table_args__ = ( - sqlalchemy.UniqueConstraint('workflow_uuid', 'version', name='uq_workflow_version'), - ) + __table_args__ = (sqlalchemy.UniqueConstraint('workflow_uuid', 'version', name='uq_workflow_version'),) class WorkflowTrigger(Base): diff --git a/src/langbot/pkg/persistence/migrations/dbm026_workflow_tables.py b/src/langbot/pkg/persistence/migrations/dbm026_workflow_tables.py index 69443348..069d574f 100644 --- a/src/langbot/pkg/persistence/migrations/dbm026_workflow_tables.py +++ b/src/langbot/pkg/persistence/migrations/dbm026_workflow_tables.py @@ -1,4 +1,5 @@ """Add workflow tables and update bot binding fields""" + import sqlalchemy from .. import migration @@ -9,7 +10,8 @@ class DBMigrateWorkflowTables(migration.DBMigration): async def upgrade(self): # Create workflows table - await self.ap.persistence_mgr.execute_async(sqlalchemy.text(""" + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text(""" CREATE TABLE IF NOT EXISTS workflows ( uuid VARCHAR(255) PRIMARY KEY, name VARCHAR(255) NOT NULL, @@ -23,10 +25,12 @@ class DBMigrateWorkflowTables(migration.DBMigration): created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) - """)) + """) + ) # Create workflow_versions table - await self.ap.persistence_mgr.execute_async(sqlalchemy.text(""" + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text(""" CREATE TABLE IF NOT EXISTS workflow_versions ( id INTEGER PRIMARY KEY AUTOINCREMENT, workflow_uuid VARCHAR(255) NOT NULL, @@ -37,10 +41,12 @@ class DBMigrateWorkflowTables(migration.DBMigration): created_by VARCHAR(255), UNIQUE(workflow_uuid, version) ) - """)) + """) + ) # Create workflow_triggers table - await self.ap.persistence_mgr.execute_async(sqlalchemy.text(""" + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text(""" CREATE TABLE IF NOT EXISTS workflow_triggers ( uuid VARCHAR(255) PRIMARY KEY, workflow_uuid VARCHAR(255) NOT NULL, @@ -51,10 +57,12 @@ class DBMigrateWorkflowTables(migration.DBMigration): created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) - """)) + """) + ) # Create workflow_executions table - await self.ap.persistence_mgr.execute_async(sqlalchemy.text(""" + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text(""" CREATE TABLE IF NOT EXISTS workflow_executions ( uuid VARCHAR(255) PRIMARY KEY, workflow_uuid VARCHAR(255) NOT NULL, @@ -68,10 +76,12 @@ class DBMigrateWorkflowTables(migration.DBMigration): error TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) - """)) + """) + ) # Create workflow_node_executions table - await self.ap.persistence_mgr.execute_async(sqlalchemy.text(""" + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text(""" CREATE TABLE IF NOT EXISTS workflow_node_executions ( id INTEGER PRIMARY KEY AUTOINCREMENT, execution_uuid VARCHAR(255) NOT NULL, @@ -85,10 +95,12 @@ class DBMigrateWorkflowTables(migration.DBMigration): error TEXT, retry_count INTEGER NOT NULL DEFAULT 0 ) - """)) + """) + ) # Create workflow_scheduled_jobs table - await self.ap.persistence_mgr.execute_async(sqlalchemy.text(""" + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text(""" CREATE TABLE IF NOT EXISTS workflow_scheduled_jobs ( uuid VARCHAR(255) PRIMARY KEY, trigger_uuid VARCHAR(255) NOT NULL, @@ -97,45 +109,50 @@ class DBMigrateWorkflowTables(migration.DBMigration): last_run_time TIMESTAMP, is_enabled BOOLEAN NOT NULL DEFAULT 1 ) - """)) + """) + ) # Create indexes - await self.ap.persistence_mgr.execute_async(sqlalchemy.text( - "CREATE INDEX IF NOT EXISTS idx_workflow_versions_uuid ON workflow_versions(workflow_uuid)" - )) - await self.ap.persistence_mgr.execute_async(sqlalchemy.text( - "CREATE INDEX IF NOT EXISTS idx_workflow_triggers_uuid ON workflow_triggers(workflow_uuid)" - )) - await self.ap.persistence_mgr.execute_async(sqlalchemy.text( - "CREATE INDEX IF NOT EXISTS idx_workflow_executions_uuid ON workflow_executions(workflow_uuid)" - )) - await self.ap.persistence_mgr.execute_async(sqlalchemy.text( - "CREATE INDEX IF NOT EXISTS idx_workflow_node_executions_uuid ON workflow_node_executions(execution_uuid)" - )) - await self.ap.persistence_mgr.execute_async(sqlalchemy.text( - "CREATE INDEX IF NOT EXISTS idx_workflow_scheduled_jobs_trigger ON workflow_scheduled_jobs(trigger_uuid)" - )) + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text('CREATE INDEX IF NOT EXISTS idx_workflow_versions_uuid ON workflow_versions(workflow_uuid)') + ) + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text('CREATE INDEX IF NOT EXISTS idx_workflow_triggers_uuid ON workflow_triggers(workflow_uuid)') + ) + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text( + 'CREATE INDEX IF NOT EXISTS idx_workflow_executions_uuid ON workflow_executions(workflow_uuid)' + ) + ) + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text( + 'CREATE INDEX IF NOT EXISTS idx_workflow_node_executions_uuid ON workflow_node_executions(execution_uuid)' + ) + ) + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text( + 'CREATE INDEX IF NOT EXISTS idx_workflow_scheduled_jobs_trigger ON workflow_scheduled_jobs(trigger_uuid)' + ) + ) # Update bots table: add binding_type column (default to 'pipeline' for backward compatibility) # Check if column exists first (SQLite doesn't support IF NOT EXISTS for columns) try: - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text("SELECT binding_type FROM bots LIMIT 1") - ) + await self.ap.persistence_mgr.execute_async(sqlalchemy.text('SELECT binding_type FROM bots LIMIT 1')) except Exception: # Column doesn't exist, add it - await self.ap.persistence_mgr.execute_async(sqlalchemy.text( - "ALTER TABLE bots ADD COLUMN binding_type VARCHAR(20) NOT NULL DEFAULT 'pipeline'" - )) + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text("ALTER TABLE bots ADD COLUMN binding_type VARCHAR(20) NOT NULL DEFAULT 'pipeline'") + ) async def downgrade(self): # Drop tables in reverse order - await self.ap.persistence_mgr.execute_async(sqlalchemy.text("DROP TABLE IF EXISTS workflow_scheduled_jobs")) - await self.ap.persistence_mgr.execute_async(sqlalchemy.text("DROP TABLE IF EXISTS workflow_node_executions")) - await self.ap.persistence_mgr.execute_async(sqlalchemy.text("DROP TABLE IF EXISTS workflow_executions")) - await self.ap.persistence_mgr.execute_async(sqlalchemy.text("DROP TABLE IF EXISTS workflow_triggers")) - await self.ap.persistence_mgr.execute_async(sqlalchemy.text("DROP TABLE IF EXISTS workflow_versions")) - await self.ap.persistence_mgr.execute_async(sqlalchemy.text("DROP TABLE IF EXISTS workflows")) - + await self.ap.persistence_mgr.execute_async(sqlalchemy.text('DROP TABLE IF EXISTS workflow_scheduled_jobs')) + await self.ap.persistence_mgr.execute_async(sqlalchemy.text('DROP TABLE IF EXISTS workflow_node_executions')) + await self.ap.persistence_mgr.execute_async(sqlalchemy.text('DROP TABLE IF EXISTS workflow_executions')) + await self.ap.persistence_mgr.execute_async(sqlalchemy.text('DROP TABLE IF EXISTS workflow_triggers')) + await self.ap.persistence_mgr.execute_async(sqlalchemy.text('DROP TABLE IF EXISTS workflow_versions')) + await self.ap.persistence_mgr.execute_async(sqlalchemy.text('DROP TABLE IF EXISTS workflows')) + # Remove binding_type column from bots (SQLite doesn't support DROP COLUMN directly) # This would need a table recreation in SQLite, so we'll skip it in downgrade diff --git a/src/langbot/pkg/persistence/migrations/dbm027_bot_binding_fields.py b/src/langbot/pkg/persistence/migrations/dbm027_bot_binding_fields.py index 6e90c963..5de8aae3 100644 --- a/src/langbot/pkg/persistence/migrations/dbm027_bot_binding_fields.py +++ b/src/langbot/pkg/persistence/migrations/dbm027_bot_binding_fields.py @@ -1,4 +1,5 @@ """Add binding_uuid field to bots table and migrate data""" + import sqlalchemy from .. import migration @@ -11,33 +12,35 @@ class DBMigrateBotBindingFields(migration.DBMigration): # Add binding_uuid column to bots table # Check if column exists first (SQLite doesn't support IF NOT EXISTS for columns) try: - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text("SELECT binding_uuid FROM bots LIMIT 1") - ) + await self.ap.persistence_mgr.execute_async(sqlalchemy.text('SELECT binding_uuid FROM bots LIMIT 1')) except Exception: # Column doesn't exist, add it - await self.ap.persistence_mgr.execute_async(sqlalchemy.text( - "ALTER TABLE bots ADD COLUMN binding_uuid VARCHAR(64)" - )) + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text('ALTER TABLE bots ADD COLUMN binding_uuid VARCHAR(64)') + ) # Migrate existing data: copy use_pipeline_uuid to binding_uuid for records # that have a pipeline bound and binding_uuid is not set yet - await self.ap.persistence_mgr.execute_async(sqlalchemy.text(""" + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text(""" UPDATE bots SET binding_uuid = use_pipeline_uuid WHERE use_pipeline_uuid IS NOT NULL AND use_pipeline_uuid != '' AND (binding_uuid IS NULL OR binding_uuid = '') - """)) + """) + ) # Ensure binding_type is 'pipeline' for records that were migrated - await self.ap.persistence_mgr.execute_async(sqlalchemy.text(""" + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text(""" UPDATE bots SET binding_type = 'pipeline' WHERE binding_uuid IS NOT NULL AND binding_uuid != '' AND (binding_type IS NULL OR binding_type = '') - """)) + """) + ) async def downgrade(self): # SQLite doesn't support DROP COLUMN directly diff --git a/src/langbot/pkg/platform/botmgr.py b/src/langbot/pkg/platform/botmgr.py index d85b48eb..d2ec3826 100644 --- a/src/langbot/pkg/platform/botmgr.py +++ b/src/langbot/pkg/platform/botmgr.py @@ -58,17 +58,17 @@ class RuntimeBot: def get_binding_info(self) -> tuple[str, str | None]: """Get the binding type and UUID for this bot. - + Returns: tuple: (binding_type, binding_uuid) where binding_type is 'pipeline' or 'workflow' """ binding_type = getattr(self.bot_entity, 'binding_type', 'pipeline') or 'pipeline' binding_uuid = getattr(self.bot_entity, 'binding_uuid', None) - + # Fallback to use_pipeline_uuid for backward compatibility if not binding_uuid and binding_type == 'pipeline': binding_uuid = self.bot_entity.use_pipeline_uuid - + return binding_type, binding_uuid def resolve_pipeline_uuid( @@ -89,14 +89,14 @@ class RuntimeBot: as routing rules are no longer used. """ binding_type, binding_uuid = self.get_binding_info() - + # If bound to workflow, return None for pipeline_uuid # The caller should check binding_type and handle accordingly if binding_type == 'workflow': # For workflow binding, we still need to return something # The actual workflow handling should be done by the caller return None, False - + return binding_uuid, False async def _record_discarded_message( diff --git a/src/langbot/pkg/workflow/entities.py b/src/langbot/pkg/workflow/entities.py index b8bffbed..44107a0a 100644 --- a/src/langbot/pkg/workflow/entities.py +++ b/src/langbot/pkg/workflow/entities.py @@ -1,4 +1,5 @@ """Workflow entities and data models""" + from __future__ import annotations import enum @@ -9,45 +10,50 @@ 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 = "" + 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 = "" + name: str = '' position: Position = Position() config: dict[str, Any] = {} inputs: list[PortDefinition] = [] outputs: list[PortDefinition] = [] - + # UI metadata - description: str = "" - comment: str = "" # User comment/annotation + 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" + source_port: str = 'output' target_node: str - target_port: str = "input" + 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] = {} @@ -56,59 +62,52 @@ class TriggerDefinition(pydantic.BaseModel): 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 - + error_handling: str = 'stop' # stop, continue, retry + # Logging - log_level: str = "info" + 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 - } + + 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 + '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] = [] @@ -117,46 +116,48 @@ class ExtensionsPreferences(pydantic.BaseModel): class ConversationVariable(pydantic.BaseModel): """Conversation-level variable definition""" + name: str - type: str = "string" # string, number, boolean, object, array - description: 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 = "🔄" + 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 @@ -164,25 +165,28 @@ class WorkflowDefinition(pydantic.BaseModel): class ExecutionStatus(enum.Enum): """Workflow execution status""" - PENDING = "pending" - RUNNING = "running" - WAITING = "waiting" - COMPLETED = "completed" - FAILED = "failed" - CANCELLED = "cancelled" + + 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" + + 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] = {} @@ -195,12 +199,13 @@ class NodeState(pydantic.BaseModel): 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 = "" + sender_name: str = '' + platform: str = '' + conversation_id: str = '' is_group: bool = False group_id: Optional[str] = None mentions: list[str] = [] @@ -210,6 +215,7 @@ class MessageContext(pydantic.BaseModel): class ExecutionStep(pydantic.BaseModel): """Execution history step""" + timestamp: datetime node_id: str node_type: str @@ -222,57 +228,58 @@ class ExecutionStep(pydantic.BaseModel): 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: + + 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) diff --git a/src/langbot/pkg/workflow/executor.py b/src/langbot/pkg/workflow/executor.py index 70b14c74..ddcf92e4 100644 --- a/src/langbot/pkg/workflow/executor.py +++ b/src/langbot/pkg/workflow/executor.py @@ -1,4 +1,5 @@ """Workflow execution engine""" + from __future__ import annotations import ast @@ -33,21 +34,15 @@ logger = logging.getLogger(__name__) class ExecutionLog: """Execution log entry""" - - def __init__( - self, - level: str, - message: str, - node_id: Optional[str] = None, - data: Optional[dict] = None - ): + + def __init__(self, level: str, message: str, node_id: Optional[str] = None, data: Optional[dict] = None): self.id = str(uuid.uuid4()) self.timestamp = datetime.now().isoformat() self.level = level self.message = message self.node_id = node_id self.data = data or {} - + def to_dict(self) -> dict: return { 'id': self.id, @@ -61,7 +56,7 @@ class ExecutionLog: class DebugExecutionState: """State for a debug execution""" - + def __init__(self, execution_id: str, breakpoints: list[str] = None): self.execution_id = execution_id self.status: str = 'running' @@ -74,7 +69,7 @@ class DebugExecutionState: self._pause_event = asyncio.Event() self._pause_event.set() # Initially not paused self._stop_event = asyncio.Event() - + def add_log(self, level: str, message: str, node_id: str = None, data: dict = None): """Add a log entry""" log = ExecutionLog(level, message, node_id, data) @@ -82,28 +77,28 @@ class DebugExecutionState: self.pending_logs.append(log) logger.log( getattr(logging, level.upper(), logging.INFO), - f"[Workflow Debug] {message}", - extra={'node_id': node_id, 'data': data} + f'[Workflow Debug] {message}', + extra={'node_id': node_id, 'data': data}, ) - + def get_pending_logs(self) -> list[dict]: """Get and clear pending logs""" logs = [log.to_dict() for log in self.pending_logs] self.pending_logs = [] return logs - + def pause(self): """Pause execution""" self.is_paused = True self._pause_event.clear() self.add_log('info', 'Execution paused') - + def resume(self): """Resume execution""" self.is_paused = False self._pause_event.set() self.add_log('info', 'Execution resumed') - + def stop(self): """Stop execution""" self.is_stopped = True @@ -111,13 +106,13 @@ class DebugExecutionState: self._stop_event.set() self._pause_event.set() # Release any pause self.add_log('info', 'Execution stopped') - + async def wait_if_paused(self): """Wait if execution is paused""" if self.is_paused: self.add_log('info', 'Waiting for resume...') await self._pause_event.wait() - + def check_breakpoint(self, node_id: str) -> bool: """Check if there's a breakpoint at the given node""" return node_id in self.breakpoints @@ -174,14 +169,14 @@ def _eval_node(node: ast.AST) -> Any: 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__}") + raise ValueError(f'Unsupported unary op: {type(node.op).__name__}') return op_fn(_eval_node(node.operand)) # Binary operators: x + y, x * y, etc. 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__}") + raise ValueError(f'Unsupported binary op: {type(node.op).__name__}') return op_fn(_eval_node(node.left), _eval_node(node.right)) # Comparisons: x == y, x > y, x in y, etc. (chained) @@ -190,7 +185,7 @@ def _eval_node(node: ast.AST) -> Any: 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__}") + raise ValueError(f'Unsupported comparison: {type(op).__name__}') right = _eval_node(comparator) if not op_fn(left, right): return False @@ -220,9 +215,9 @@ def _eval_node(node: ast.AST) -> Any: return True if node.id == 'False': return False - raise ValueError(f"Unsupported variable reference: {node.id}") + raise ValueError(f'Unsupported variable reference: {node.id}') - raise ValueError(f"Unsupported expression node: {type(node).__name__}") + raise ValueError(f'Unsupported expression node: {type(node).__name__}') class WorkflowExecutor: @@ -230,85 +225,73 @@ class WorkflowExecutor: Workflow execution engine. Handles the execution of workflow definitions with proper control flow. """ - + def __init__(self, ap: Optional['app.Application'] = None): self.ap = ap self.registry = NodeTypeRegistry.instance() self._edges: list[EdgeDefinition] = [] - + async def execute( - self, - workflow: WorkflowDefinition, - context: ExecutionContext, - start_node_id: Optional[str] = None + self, workflow: WorkflowDefinition, context: ExecutionContext, start_node_id: Optional[str] = None ) -> ExecutionContext: """ Execute a workflow. - + Args: workflow: Workflow definition context: Execution context start_node_id: Optional starting node (for resumption) - + Returns: Updated execution context """ context.status = ExecutionStatus.RUNNING context.start_time = datetime.now() - + try: # Build execution graph node_map = {node.id: node for node in workflow.nodes} edge_map = self._build_edge_map(workflow.edges) self._edges = workflow.edges - + # Initialize node states for node in workflow.nodes: if node.id not in context.node_states: context.node_states[node.id] = NodeState(node_id=node.id) - + # Find start node(s) if start_node_id: start_nodes = [node_map[start_node_id]] else: start_nodes = self._find_start_nodes(workflow.nodes, workflow.edges) - + if not start_nodes: - raise ValueError("No start nodes found in workflow") - + raise ValueError('No start nodes found in workflow') + # Execute from start nodes for start_node in start_nodes: await self._execute_from_node( - start_node, - node_map, - edge_map, - context, - workflow.settings.max_retries, - path=set() + start_node, node_map, edge_map, context, workflow.settings.max_retries, path=set() ) - + # Check final status all_completed = all( - state.status in (NodeStatus.COMPLETED, NodeStatus.SKIPPED) - for state in context.node_states.values() + state.status in (NodeStatus.COMPLETED, NodeStatus.SKIPPED) for state in context.node_states.values() ) - + if all_completed: context.status = ExecutionStatus.COMPLETED else: # Some nodes might still be waiting - has_failed = any( - state.status == NodeStatus.FAILED - for state in context.node_states.values() - ) + has_failed = any(state.status == NodeStatus.FAILED for state in context.node_states.values()) if has_failed: context.status = ExecutionStatus.FAILED - + except Exception as e: context.status = ExecutionStatus.FAILED context.error = str(e) logger.error( - "Workflow execution failed", + 'Workflow execution failed', exc_info=True, extra={ 'workflow_id': workflow.uuid, @@ -322,12 +305,12 @@ class WorkflowExecutor: }, }, ) - + finally: context.end_time = datetime.now() - + return context - + async def _execute_from_node( self, node: NodeDefinition, @@ -335,27 +318,27 @@ class WorkflowExecutor: edge_map: dict[str, list[EdgeDefinition]], context: ExecutionContext, max_retries: int = 3, - path: set[str] | None = None + path: set[str] | None = None, ): """Execute workflow starting from a specific node""" - + # Initialize path set for cycle detection (path-based, not global visited) if path is None: path = set() - + # Check for circular dependency on the *current path* only # This correctly allows diamond shapes (A→B, A→C, B→D, C→D) if node.id in path: - logger.warning(f"Circular dependency detected at node: {node.id}") + logger.warning(f'Circular dependency detected at node: {node.id}') context.node_states[node.id].status = NodeStatus.SKIPPED - context.node_states[node.id].error = "Circular dependency detected" + context.node_states[node.id].error = 'Circular dependency detected' context.node_states[node.id].end_time = datetime.now() await self._persist_node_execution(node, context.node_states[node.id], context) return - + # Add node to current path path.add(node.id) - + # Check if node should be skipped if await self._should_skip_node(node, context): existing_state = context.node_states[node.id] @@ -364,18 +347,18 @@ class WorkflowExecutor: await self._persist_node_execution(node, existing_state, context) path.discard(node.id) return - + # Execute current node await self._execute_node(node, context, max_retries) - + # If node failed and we should stop on error, return if context.node_states[node.id].status == NodeStatus.FAILED: path.discard(node.id) return - + node_state = context.node_states[node.id] node_type_name = node.type.split('.')[-1] if '.' in node.type else node.type - + # ── Control flow integration ──────────────────────────────── # For loop / iterator nodes: run the LoopExecutor over # downstream body nodes for each item, then continue to the @@ -389,7 +372,7 @@ class WorkflowExecutor: items = [items] if items else [] max_iter = int(node.config.get('max_iterations', 100)) items = items[:max_iter] - + # Collect downstream "body" nodes (connected via edges) outgoing_edges = edge_map.get(node.id, []) body_nodes = [] @@ -397,7 +380,7 @@ class WorkflowExecutor: target = node_map.get(edge.target_node) if target: body_nodes.append(target) - + if body_nodes and items: loop_exec = LoopExecutor(self) results = await loop_exec.execute_loop(items, body_nodes, context, max_iter) @@ -406,10 +389,10 @@ class WorkflowExecutor: else: node_state.outputs['results'] = [] node_state.outputs['completed'] = True - + path.discard(node.id) return # body nodes already executed by LoopExecutor - + # For parallel nodes: run downstream branches concurrently if node_type_name == 'parallel': outgoing_edges = edge_map.get(node.id, []) @@ -418,82 +401,70 @@ class WorkflowExecutor: target = node_map.get(edge.target_node) if target: branch_nodes.append([target]) - + if branch_nodes: par_exec = ParallelExecutor(self) results = await par_exec.execute_parallel(branch_nodes, context) node_state.outputs['results'] = results - + path.discard(node.id) return # branch nodes already executed by ParallelExecutor - + # ── Standard edge-based continuation ──────────────────────── # Get outgoing edges outgoing_edges = edge_map.get(node.id, []) - + # Execute next nodes based on edge conditions for edge in outgoing_edges: target_node = node_map.get(edge.target_node) if not target_node: continue - + # Check edge condition if edge.condition: condition_met = await self._evaluate_condition(edge.condition, context) if not condition_met: continue - + # Check if all inputs are ready if await self._inputs_ready(target_node, edge_map, context): - await self._execute_from_node( - target_node, - node_map, - edge_map, - context, - max_retries, - path - ) - + await self._execute_from_node(target_node, node_map, edge_map, context, max_retries, path) + # Remove node from path when backtracking (allows diamond revisit) path.discard(node.id) - - async def _execute_node( - self, - node: NodeDefinition, - context: ExecutionContext, - max_retries: int = 3 - ): + + async def _execute_node(self, node: NodeDefinition, context: ExecutionContext, max_retries: int = 3): """Execute a single node with retry logic""" - + node_state = context.node_states[node.id] node_state.status = NodeStatus.RUNNING node_state.start_time = datetime.now() - + # Get node instance (pass ap for access to services) node_instance = self.registry.create_instance(node.type, node.id, node.config, ap=self.ap) - + if not node_instance: node_state.status = NodeStatus.FAILED - node_state.error = f"Unknown node type: {node.type}" + node_state.error = f'Unknown node type: {node.type}' node_state.end_time = datetime.now() self._record_execution_step(node, node_state, context) await self._persist_node_execution(node, node_state, context) return - + # Resolve inputs inputs = await self._resolve_inputs(node, context) node_state.inputs = inputs - + # Validate inputs validation_errors = await node_instance.validate_inputs(inputs) if validation_errors: node_state.status = NodeStatus.FAILED - node_state.error = "; ".join(validation_errors) + node_state.error = '; '.join(validation_errors) node_state.end_time = datetime.now() self._record_execution_step(node, node_state, context) await self._persist_node_execution(node, node_state, context) return - + # Execute with retries for attempt in range(max_retries + 1): try: @@ -505,7 +476,7 @@ class WorkflowExecutor: except Exception as e: node_state.retry_count = attempt + 1 logger.error( - f"Node {node.id} ({node.type}) execution failed (attempt {attempt + 1}/{max_retries + 1}): {e}", + f'Node {node.id} ({node.type}) execution failed (attempt {attempt + 1}/{max_retries + 1}): {e}', exc_info=True, extra={ 'node_id': node.id, @@ -515,7 +486,7 @@ class WorkflowExecutor: 'execution_id': context.execution_id, }, ) - + if attempt < max_retries: await asyncio.sleep(1) # Brief delay before retry else: @@ -523,7 +494,7 @@ class WorkflowExecutor: node_state.error = str(e) node_state.end_time = datetime.now() logger.error( - f"Node {node.id} ({node.type}) permanently failed after {max_retries + 1} attempts", + f'Node {node.id} ({node.type}) permanently failed after {max_retries + 1} attempts', extra={ 'node_id': node.id, 'node_type': node.type, @@ -531,28 +502,24 @@ class WorkflowExecutor: 'execution_id': context.execution_id, }, ) - + self._record_execution_step(node, node_state, context) await self._persist_node_execution(node, node_state, context) - - async def _resolve_inputs( - self, - node: NodeDefinition, - context: ExecutionContext - ) -> dict[str, Any]: + + async def _resolve_inputs(self, node: NodeDefinition, context: ExecutionContext) -> dict[str, Any]: """Resolve input values for a node from connected nodes and context""" inputs = {} - + # Get inputs from context variables if 'message' in context.variables: inputs['message'] = context.variables['message'] - + # Get inputs from message context if context.message_context: inputs['message_content'] = context.message_context.message_content inputs['sender_id'] = context.message_context.sender_id inputs['platform'] = context.message_context.platform - + # Get inputs from node config that reference other nodes for key, value in node.config.items(): if isinstance(value, str) and value.startswith('{{') and value.endswith('}}'): @@ -560,7 +527,7 @@ class WorkflowExecutor: inputs[key] = resolved else: inputs[key] = value - + # Get inputs from connected upstream nodes via edges # Build a reverse map: for each incoming edge to this node, find the # source node and the specific source/target port. @@ -581,33 +548,33 @@ class WorkflowExecutor: elif source_state.outputs: # Last resort: use the first available output inputs[target_port] = next(iter(source_state.outputs.values())) - + return inputs - + async def _resolve_expression(self, expression: str, context: ExecutionContext) -> Any: """Resolve a variable expression like 'nodes.node1.outputs.text'""" parts = expression.strip().split('.') - + if not parts: return None - + if parts[0] == 'nodes' and len(parts) >= 4: # nodes.node_id.outputs.output_name node_id = parts[1] if parts[2] == 'outputs' and node_id in context.node_states: output_name = '.'.join(parts[3:]) return context.node_states[node_id].outputs.get(output_name) - + elif parts[0] == 'variables': # variables.var_name var_name = '.'.join(parts[1:]) return context.variables.get(var_name) - + elif parts[0] == 'conversation_variables': # conversation_variables.var_name var_name = '.'.join(parts[1:]) return context.conversation_variables.get(var_name) - + elif parts[0] == 'message': # message.content, message.sender_id, etc. if context.message_context: @@ -620,21 +587,22 @@ class WorkflowExecutor: return context.message_context.platform elif attr == 'conversation_id': return context.message_context.conversation_id - + return None - + async def _evaluate_condition(self, condition: str, context: ExecutionContext) -> bool: """Evaluate a condition expression safely using AST whitelist""" try: # Resolve variable references in condition if '{{' in condition: import re + pattern = r'\{\{([^}]+)\}\}' - + # First pass: replace all variable references with placeholders placeholders = {} placeholder_idx = 0 - + def replace_with_placeholder(match): nonlocal placeholder_idx var_expr = match.group(1) @@ -642,47 +610,38 @@ class WorkflowExecutor: placeholders[placeholder] = var_expr placeholder_idx += 1 return placeholder - + condition_with_placeholders = re.sub(pattern, replace_with_placeholder, condition) - + # Second pass: resolve each placeholder asynchronously for placeholder, var_expr in placeholders.items(): value = await self._resolve_expression(var_expr, context) if isinstance(value, str): - condition_with_placeholders = condition_with_placeholders.replace( - placeholder, f'"{value}"' - ) + condition_with_placeholders = condition_with_placeholders.replace(placeholder, f'"{value}"') elif value is None: - condition_with_placeholders = condition_with_placeholders.replace( - placeholder, 'None' - ) + condition_with_placeholders = condition_with_placeholders.replace(placeholder, 'None') else: - condition_with_placeholders = condition_with_placeholders.replace( - placeholder, str(value) - ) - + condition_with_placeholders = condition_with_placeholders.replace(placeholder, str(value)) + condition = condition_with_placeholders - + # Safe expression evaluation using AST whitelist result = _safe_eval(condition) return bool(result) - + except Exception as e: - logger.warning(f"Condition evaluation failed: {condition} - {e}") + logger.warning(f'Condition evaluation failed: {condition} - {e}') return False - + async def _should_skip_node(self, node: NodeDefinition, context: ExecutionContext) -> bool: """Check if a node should be skipped""" state = context.node_states.get(node.id) if state and state.status in (NodeStatus.COMPLETED, NodeStatus.RUNNING, NodeStatus.SKIPPED): return True return False - + async def _inputs_ready( - self, - node: NodeDefinition, - edge_map: dict[str, list[EdgeDefinition]], - context: ExecutionContext + self, node: NodeDefinition, edge_map: dict[str, list[EdgeDefinition]], context: ExecutionContext ) -> bool: """Check if all inputs for a node are ready""" # Find all edges that connect to this node @@ -691,32 +650,28 @@ class WorkflowExecutor: for edge in edges: if edge.target_node == node.id: incoming_nodes.add(source_id) - + # Check if all incoming nodes have completed for source_id in incoming_nodes: state = context.node_states.get(source_id) if not state or state.status not in (NodeStatus.COMPLETED, NodeStatus.SKIPPED): return False - + return True - - def _find_start_nodes( - self, - nodes: list[NodeDefinition], - edges: list[EdgeDefinition] - ) -> list[NodeDefinition]: + + def _find_start_nodes(self, nodes: list[NodeDefinition], edges: list[EdgeDefinition]) -> list[NodeDefinition]: """Find nodes that have no incoming edges (start nodes)""" target_nodes = {edge.target_node for edge in edges} start_nodes = [node for node in nodes if node.id not in target_nodes] - + # Also check for trigger nodes trigger_types = {'message_trigger', 'cron_trigger', 'webhook_trigger', 'event_trigger'} for node in nodes: if node.type in trigger_types and node not in start_nodes: start_nodes.insert(0, node) - + return start_nodes - + def _build_edge_map(self, edges: list[EdgeDefinition]) -> dict[str, list[EdgeDefinition]]: """Build a map of source node ID to outgoing edges""" edge_map: dict[str, list[EdgeDefinition]] = {} @@ -725,18 +680,13 @@ class WorkflowExecutor: edge_map[edge.source_node] = [] edge_map[edge.source_node].append(edge) return edge_map - - def _record_execution_step( - self, - node: NodeDefinition, - node_state: NodeState, - context: ExecutionContext - ): + + def _record_execution_step(self, node: NodeDefinition, node_state: NodeState, context: ExecutionContext): """Record an execution step in the history""" duration_ms = 0 if node_state.start_time and node_state.end_time: duration_ms = int((node_state.end_time - node_state.start_time).total_seconds() * 1000) - + step = ExecutionStep( timestamp=datetime.now(), node_id=node.id, @@ -745,7 +695,7 @@ class WorkflowExecutor: inputs=node_state.inputs, outputs=node_state.outputs, duration_ms=duration_ms, - error=node_state.error + error=node_state.error, ) context.history.append(step) @@ -793,22 +743,20 @@ class WorkflowExecutor: class ParallelExecutor: """Execute multiple branches in parallel""" - + def __init__(self, executor: WorkflowExecutor): self.executor = executor - + async def execute_parallel( - self, - branches: list[list[NodeDefinition]], - context: ExecutionContext + self, branches: list[list[NodeDefinition]], context: ExecutionContext ) -> list[dict[str, Any]]: """ Execute multiple branches in parallel. - + Args: branches: List of node sequences to execute in parallel context: Execution context - + Returns: List of results from each branch """ @@ -816,27 +764,23 @@ class ParallelExecutor: for branch in branches: task = self._execute_branch(branch, context) tasks.append(task) - + results = await asyncio.gather(*tasks, return_exceptions=True) - + processed_results = [] for result in results: if isinstance(result, Exception): processed_results.append({'error': str(result)}) else: processed_results.append(result) - + return processed_results - - async def _execute_branch( - self, - nodes: list[NodeDefinition], - context: ExecutionContext - ) -> dict[str, Any]: + + async def _execute_branch(self, nodes: list[NodeDefinition], context: ExecutionContext) -> dict[str, Any]: """Execute a single branch""" # Create a copy of context for this branch branch_outputs = {} - + for node in nodes: await self.executor._execute_node(node, context, max_retries=3) state = context.node_states.get(node.id) @@ -845,69 +789,65 @@ class ParallelExecutor: elif state and state.status == NodeStatus.FAILED: branch_outputs['error'] = state.error break - + return branch_outputs class LoopExecutor: """Execute loop iterations""" - + def __init__(self, executor: WorkflowExecutor): self.executor = executor - + async def execute_loop( - self, - items: list[Any], - loop_body: list[NodeDefinition], - context: ExecutionContext, - max_iterations: int = 100 + self, items: list[Any], loop_body: list[NodeDefinition], context: ExecutionContext, max_iterations: int = 100 ) -> list[dict[str, Any]]: """ Execute a loop over items. - + Args: items: Items to iterate over loop_body: Nodes to execute for each item context: Execution context max_iterations: Maximum number of iterations - + Returns: List of results from each iteration """ results = [] - + for i, item in enumerate(items[:max_iterations]): # Set loop variables context.variables['loop_item'] = item context.variables['loop_index'] = i context.variables['loop_is_first'] = i == 0 context.variables['loop_is_last'] = i == len(items) - 1 - + iteration_result = {} - + for node in loop_body: # Reset node state for this iteration context.node_states[node.id] = NodeState(node_id=node.id) - + await self.executor._execute_node(node, context, max_retries=3) - + state = context.node_states.get(node.id) if state: iteration_result[node.id] = state.outputs - + # Check for break condition if state.outputs.get('break', False): results.append(iteration_result) return results - + results.append(iteration_result) - + # Clean up loop variables context.variables.pop('loop_item', None) context.variables.pop('loop_index', None) context.variables.pop('loop_is_first', None) context.variables.pop('loop_is_last', None) - + return results @@ -916,30 +856,30 @@ class DebugWorkflowExecutor(WorkflowExecutor): Debug-enabled workflow executor with step-by-step execution support. Extends WorkflowExecutor with debugging capabilities. """ - + # Class-level storage for active debug sessions _debug_states: dict[str, DebugExecutionState] = {} - + def __init__(self, ap: Optional['app.Application'] = None): super().__init__(ap) - + @classmethod def get_debug_state(cls, execution_id: str) -> Optional[DebugExecutionState]: """Get debug state for an execution""" return cls._debug_states.get(execution_id) - + @classmethod def create_debug_state(cls, execution_id: str, breakpoints: list[str] = None) -> DebugExecutionState: """Create a new debug state""" state = DebugExecutionState(execution_id, breakpoints) cls._debug_states[execution_id] = state return state - + @classmethod def remove_debug_state(cls, execution_id: str): """Remove debug state for an execution""" cls._debug_states.pop(execution_id, None) - + async def execute_debug( self, workflow: WorkflowDefinition, @@ -948,87 +888,78 @@ class DebugWorkflowExecutor(WorkflowExecutor): ) -> ExecutionContext: """ Execute a workflow in debug mode. - + Args: workflow: Workflow definition context: Execution context debug_state: Debug execution state - + Returns: Updated execution context """ context.status = ExecutionStatus.RUNNING context.start_time = datetime.now() debug_state.add_log('info', f'Starting debug execution for workflow: {workflow.name}') - + try: # Build execution graph node_map = {node.id: node for node in workflow.nodes} edge_map = self._build_edge_map(workflow.edges) self._edges = workflow.edges - + # Initialize node states for node in workflow.nodes: if node.id not in context.node_states: context.node_states[node.id] = NodeState(node_id=node.id) - + # Find start node(s) start_nodes = self._find_start_nodes(workflow.nodes, workflow.edges) - + if not start_nodes: - raise ValueError("No start nodes found in workflow") - + raise ValueError('No start nodes found in workflow') + debug_state.add_log('info', f'Found {len(start_nodes)} start node(s)') - + # Execute from start nodes for start_node in start_nodes: if debug_state.is_stopped: break - + await self._execute_debug_from_node( - start_node, - node_map, - edge_map, - context, - debug_state, - workflow.settings.max_retries + start_node, node_map, edge_map, context, debug_state, workflow.settings.max_retries ) - + # Set final status if debug_state.is_stopped: context.status = ExecutionStatus.CANCELLED debug_state.status = 'cancelled' else: all_completed = all( - state.status in (NodeStatus.COMPLETED, NodeStatus.SKIPPED) - for state in context.node_states.values() + state.status in (NodeStatus.COMPLETED, NodeStatus.SKIPPED) for state in context.node_states.values() ) - + if all_completed: context.status = ExecutionStatus.COMPLETED debug_state.status = 'completed' debug_state.add_log('info', 'Workflow execution completed successfully') else: - has_failed = any( - state.status == NodeStatus.FAILED - for state in context.node_states.values() - ) + has_failed = any(state.status == NodeStatus.FAILED for state in context.node_states.values()) if has_failed: context.status = ExecutionStatus.FAILED debug_state.status = 'error' - + except Exception as e: context.status = ExecutionStatus.FAILED context.error = str(e) debug_state.status = 'error' debug_state.add_log('error', f'Workflow execution failed: {e}', data={'traceback': traceback.format_exc()}) - logger.error(f"Debug workflow execution failed: {e}\n{traceback.format_exc()}") - + logger.error(f'Debug workflow execution failed: {e}\n{traceback.format_exc()}') + finally: context.end_time = datetime.now() - + return context - + async def _execute_debug_from_node( self, node: NodeDefinition, @@ -1036,157 +967,133 @@ class DebugWorkflowExecutor(WorkflowExecutor): edge_map: dict[str, list[EdgeDefinition]], context: ExecutionContext, debug_state: DebugExecutionState, - max_retries: int = 3 + max_retries: int = 3, ): """Execute workflow from a node with debug support""" - + # Check if stopped if debug_state.is_stopped: return - + # Wait if paused await debug_state.wait_if_paused() - + # Check if should skip if await self._should_skip_node(node, context): if context.node_states[node.id].status == NodeStatus.SKIPPED: debug_state.add_log('info', f'Skipping node: {node.id}', node_id=node.id) return - + # Check breakpoint if debug_state.check_breakpoint(node.id): debug_state.add_log('info', f'Hit breakpoint at node: {node.id}', node_id=node.id) debug_state.pause() await debug_state.wait_if_paused() - + # Update current node debug_state.current_node_id = node.id debug_state.add_log('info', f'Executing node: {node.id} ({node.type})', node_id=node.id) - + # Execute node await self._execute_debug_node(node, context, debug_state, max_retries) - + # Check if stopped or failed if debug_state.is_stopped: return if context.node_states[node.id].status == NodeStatus.FAILED: return - + # Get outgoing edges outgoing_edges = edge_map.get(node.id, []) - + # Execute next nodes for edge in outgoing_edges: if debug_state.is_stopped: break - + target_node = node_map.get(edge.target_node) if not target_node: continue - + # Check edge condition if edge.condition: condition_met = await self._evaluate_condition(edge.condition, context) if not condition_met: - debug_state.add_log( - 'debug', - f'Edge condition not met: {edge.condition}', - node_id=node.id - ) + debug_state.add_log('debug', f'Edge condition not met: {edge.condition}', node_id=node.id) continue - + # Check if all inputs are ready if await self._inputs_ready(target_node, edge_map, context): - await self._execute_debug_from_node( - target_node, - node_map, - edge_map, - context, - debug_state, - max_retries - ) - + await self._execute_debug_from_node(target_node, node_map, edge_map, context, debug_state, max_retries) + async def _execute_debug_node( - self, - node: NodeDefinition, - context: ExecutionContext, - debug_state: DebugExecutionState, - max_retries: int = 3 + self, node: NodeDefinition, context: ExecutionContext, debug_state: DebugExecutionState, max_retries: int = 3 ): """Execute a single node with debug logging""" - + node_state = context.node_states[node.id] node_state.status = NodeStatus.RUNNING node_state.start_time = datetime.now() - + # Get node instance (pass ap for access to services) node_instance = self.registry.create_instance(node.type, node.id, node.config, ap=self.ap) - + if not node_instance: node_state.status = NodeStatus.FAILED - node_state.error = f"Unknown node type: {node.type}" + node_state.error = f'Unknown node type: {node.type}' node_state.end_time = datetime.now() debug_state.add_log('error', f'Unknown node type: {node.type}', node_id=node.id) self._record_execution_step(node, node_state, context) await self._persist_node_execution(node, node_state, context) return - + # Resolve inputs inputs = await self._resolve_inputs(node, context) node_state.inputs = inputs debug_state.add_log( - 'debug', - 'Node inputs resolved', - node_id=node.id, - data={'inputs': self._safe_serialize(inputs)} + 'debug', 'Node inputs resolved', node_id=node.id, data={'inputs': self._safe_serialize(inputs)} ) - + # Validate inputs validation_errors = await node_instance.validate_inputs(inputs) if validation_errors: node_state.status = NodeStatus.FAILED - node_state.error = "; ".join(validation_errors) + node_state.error = '; '.join(validation_errors) node_state.end_time = datetime.now() - debug_state.add_log( - 'error', - f'Input validation failed: {node_state.error}', - node_id=node.id - ) + debug_state.add_log('error', f'Input validation failed: {node_state.error}', node_id=node.id) self._record_execution_step(node, node_state, context) await self._persist_node_execution(node, node_state, context) return - + # Execute with retries for attempt in range(max_retries + 1): if debug_state.is_stopped: node_state.status = NodeStatus.FAILED - node_state.error = "Execution stopped" + node_state.error = 'Execution stopped' node_state.end_time = datetime.now() break - + try: outputs = await node_instance.execute(inputs, context) node_state.outputs = outputs node_state.status = NodeStatus.COMPLETED node_state.end_time = datetime.now() - + duration_ms = int((node_state.end_time - node_state.start_time).total_seconds() * 1000) debug_state.add_log( 'info', f'Node completed in {duration_ms}ms', node_id=node.id, - data={'outputs': self._safe_serialize(outputs), 'duration_ms': duration_ms} + data={'outputs': self._safe_serialize(outputs), 'duration_ms': duration_ms}, ) break - + except Exception as e: node_state.retry_count = attempt + 1 debug_state.add_log( - 'warning', - f'Node execution failed (attempt {attempt + 1}/{max_retries + 1}): {e}', - node_id=node.id + 'warning', f'Node execution failed (attempt {attempt + 1}/{max_retries + 1}): {e}', node_id=node.id ) - + if attempt < max_retries: await asyncio.sleep(1) else: @@ -1197,12 +1104,12 @@ class DebugWorkflowExecutor(WorkflowExecutor): 'error', f'Node failed after {max_retries + 1} attempts: {e}', node_id=node.id, - data={'error': str(e), 'traceback': traceback.format_exc()} + data={'error': str(e), 'traceback': traceback.format_exc()}, ) - + self._record_execution_step(node, node_state, context) await self._persist_node_execution(node, node_state, context) - + async def step_execute( self, workflow: WorkflowDefinition, @@ -1211,33 +1118,33 @@ class DebugWorkflowExecutor(WorkflowExecutor): ) -> dict: """ Execute one step (one node) in debug mode. - + Returns: Dict with node_id, node_state, and completed status """ # Find next node to execute next_node = self._find_next_executable_node(workflow, context) - + if not next_node: debug_state.status = 'completed' return {'completed': True} - + # Execute single node debug_state.current_node_id = next_node.id await self._execute_debug_node(next_node, context, debug_state, workflow.settings.max_retries) - + node_state = context.node_states.get(next_node.id) - + # Check if workflow is complete all_done = all( state.status in (NodeStatus.COMPLETED, NodeStatus.SKIPPED, NodeStatus.FAILED) for state in context.node_states.values() ) - + if all_done: debug_state.status = 'completed' context.status = ExecutionStatus.COMPLETED - + return { 'node_id': next_node.id, 'node_state': { @@ -1248,33 +1155,36 @@ class DebugWorkflowExecutor(WorkflowExecutor): }, 'completed': all_done, } - + def _find_next_executable_node( - self, - workflow: WorkflowDefinition, - context: ExecutionContext + self, workflow: WorkflowDefinition, context: ExecutionContext ) -> Optional[NodeDefinition]: """Find the next node that can be executed""" edge_map = self._build_edge_map(workflow.edges) - + for node in workflow.nodes: state = context.node_states.get(node.id) - + # Skip completed, running, or failed nodes - if state and state.status in (NodeStatus.COMPLETED, NodeStatus.RUNNING, NodeStatus.FAILED, NodeStatus.SKIPPED): + if state and state.status in ( + NodeStatus.COMPLETED, + NodeStatus.RUNNING, + NodeStatus.FAILED, + NodeStatus.SKIPPED, + ): continue - + # Check if this node's inputs are ready incoming_nodes = set() for source_id, edges in edge_map.items(): for edge in edges: if edge.target_node == node.id: incoming_nodes.add(source_id) - + # If no incoming nodes, it's a start node if not incoming_nodes: return node - + # Check if all incoming nodes are done all_incoming_done = True for source_id in incoming_nodes: @@ -1282,12 +1192,12 @@ class DebugWorkflowExecutor(WorkflowExecutor): if not source_state or source_state.status not in (NodeStatus.COMPLETED, NodeStatus.SKIPPED): all_incoming_done = False break - + if all_incoming_done: return node - + return None - + def _safe_serialize(self, data: Any) -> Any: """Safely serialize data for logging""" if data is None: @@ -1305,13 +1215,9 @@ class DebugWorkflowExecutor(WorkflowExecutor): try: return str(data)[:1000] # Limit string length except Exception: - return "" - - def get_execution_state( - self, - context: ExecutionContext, - debug_state: DebugExecutionState - ) -> dict: + return '' + + def get_execution_state(self, context: ExecutionContext, debug_state: DebugExecutionState) -> dict: """Get current execution state for API response""" node_states = {} for node_id, state in context.node_states.items(): @@ -1323,9 +1229,10 @@ class DebugWorkflowExecutor(WorkflowExecutor): 'startTime': state.start_time.isoformat() if state.start_time else None, 'endTime': state.end_time.isoformat() if state.end_time else None, 'duration': int((state.end_time - state.start_time).total_seconds() * 1000) - if state.start_time and state.end_time else None, + if state.start_time and state.end_time + else None, } - + return { 'status': debug_state.status, 'current_node_id': debug_state.current_node_id, diff --git a/src/langbot/pkg/workflow/node.py b/src/langbot/pkg/workflow/node.py index fe22bccf..c4a90619 100644 --- a/src/langbot/pkg/workflow/node.py +++ b/src/langbot/pkg/workflow/node.py @@ -1,4 +1,5 @@ """Workflow node base class and decorators""" + from __future__ import annotations import abc @@ -13,35 +14,37 @@ if TYPE_CHECKING: class NodePort(pydantic.BaseModel): """Node port definition""" + name: str - type: str = "any" # any, string, number, boolean, object, array - description: 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 = "" + 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 = "" + 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 @@ -50,91 +53,87 @@ class NodeConfig(pydantic.BaseModel): 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 = "" - + 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]: + 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}") + 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}") + 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") + 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}") + 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}") + errors.append(f'Config {cfg.name} must be <= {cfg.max_value}') return errors - + # Type mapping from backend to frontend DynamicFormItemType _TYPE_MAP = { 'string': 'string', @@ -160,26 +159,26 @@ class WorkflowNode(abc.ABC): 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, @@ -189,11 +188,11 @@ class WorkflowNode(abc.ABC): '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'] = [ @@ -202,22 +201,22 @@ class WorkflowNode(abc.ABC): '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 """ @@ -235,7 +234,7 @@ class WorkflowNode(abc.ABC): 'zh_Hans': desc_zh, 'en_US': desc_en, } - + return { 'type': f'{cls.category}.{cls.type_name}', 'name': cls.name, @@ -258,16 +257,18 @@ _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 diff --git a/src/langbot/pkg/workflow/nodes/call_pipeline.py b/src/langbot/pkg/workflow/nodes/call_pipeline.py index 8829564a..5dffc935 100644 --- a/src/langbot/pkg/workflow/nodes/call_pipeline.py +++ b/src/langbot/pkg/workflow/nodes/call_pipeline.py @@ -22,15 +22,15 @@ from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig 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" + 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]] = [] @@ -66,7 +66,11 @@ class CallPipelineNode(WorkflowNode): message_event = self._build_message_event(query_text, context) message_chain = message_event.message_chain - launcher_type = provider_session.LauncherTypes.GROUP if context.message_context and context.message_context.is_group else provider_session.LauncherTypes.PERSON + launcher_type = ( + provider_session.LauncherTypes.GROUP + if context.message_context and context.message_context.is_group + else provider_session.LauncherTypes.PERSON + ) launcher_id = context.session_id or context.execution_id sender_id = ( context.message_context.sender_id @@ -143,7 +147,9 @@ class CallPipelineNode(WorkflowNode): return platform_events.FriendMessage( sender=sender, message_chain=message_chain, - time=context.message_context.raw_message.get('time') if context.message_context and context.message_context.raw_message else None, + time=context.message_context.raw_message.get('time') + if context.message_context and context.message_context.raw_message + else None, ) diff --git a/src/langbot/pkg/workflow/nodes/code_executor.py b/src/langbot/pkg/workflow/nodes/code_executor.py index ecb8eeb8..e7cddac7 100644 --- a/src/langbot/pkg/workflow/nodes/code_executor.py +++ b/src/langbot/pkg/workflow/nodes/code_executor.py @@ -17,25 +17,25 @@ from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig 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" + 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") + code = self.get_config('code', '') + language = self.get_config('language', 'python') - if language == "python": + if language == 'python': return await self._execute_python(code, inputs, context) else: return await self._execute_javascript(code, inputs, context) @@ -52,22 +52,43 @@ class CodeExecutorNode(WorkflowNode): 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, + '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()} + 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": ""} + return {'output': f'[JS execution not implemented: {code[:50]}...]', 'console': ''} diff --git a/src/langbot/pkg/workflow/nodes/condition.py b/src/langbot/pkg/workflow/nodes/condition.py index b870d011..4ea12194 100644 --- a/src/langbot/pkg/workflow/nodes/condition.py +++ b/src/langbot/pkg/workflow/nodes/condition.py @@ -16,82 +16,83 @@ from ..safe_eval import safe_eval_with_vars 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" + 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") + 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") + if condition_type == 'expression': + expression = self.get_config('expression', 'false') result = await self._evaluate_expression(expression, input_data, context) - elif condition_type == "comparison": + 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", "") + elif condition_type == 'contains': + left = self.get_config('left_value', '') + right = self.get_config('right_value', '') result = right in left - elif condition_type == "empty": + elif condition_type == 'empty': result = not bool(input_data) - elif condition_type == "regex": + elif condition_type == 'regex': import re - left = self.get_config("left_value", "") - pattern = self.get_config("right_value", "") + + 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} + return {'true': input_data, 'false': None} else: - return {"true": None, "false": input_data} + 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} + 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", "==") + 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 == "==": + if operator == '==': return left_num == right_num - elif operator == "!=": + elif operator == '!=': return left_num != right_num - elif operator == ">": + elif operator == '>': return left_num > right_num - elif operator == "<": + elif operator == '<': return left_num < right_num - elif operator == ">=": + elif operator == '>=': return left_num >= right_num - elif operator == "<=": + elif operator == '<=': return left_num <= right_num except ValueError: - if operator == "==": + if operator == '==': return left == right - elif operator == "!=": + elif operator == '!=': return left != right - elif operator in (">", "<", ">=", "<="): + elif operator in ('>', '<', '>=', '<='): return False return False diff --git a/src/langbot/pkg/workflow/nodes/coze_bot.py b/src/langbot/pkg/workflow/nodes/coze_bot.py index bf7f48f1..b71b0dca 100644 --- a/src/langbot/pkg/workflow/nodes/coze_bot.py +++ b/src/langbot/pkg/workflow/nodes/coze_bot.py @@ -15,35 +15,35 @@ from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig 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" + 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") + 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, + '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, }, } diff --git a/src/langbot/pkg/workflow/nodes/cron_trigger.py b/src/langbot/pkg/workflow/nodes/cron_trigger.py index 3c3be6c2..e11c5a3d 100644 --- a/src/langbot/pkg/workflow/nodes/cron_trigger.py +++ b/src/langbot/pkg/workflow/nodes/cron_trigger.py @@ -15,15 +15,15 @@ from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig 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" + 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]] = [] @@ -33,7 +33,7 @@ class CronTriggerNode(WorkflowNode): from datetime import datetime return { - "timestamp": datetime.now().isoformat(), - "schedule": self.get_config("cron", ""), - "context": context.trigger_data, + 'timestamp': datetime.now().isoformat(), + 'schedule': self.get_config('cron', ''), + 'context': context.trigger_data, } diff --git a/src/langbot/pkg/workflow/nodes/data_transform.py b/src/langbot/pkg/workflow/nodes/data_transform.py index cad4a186..a3cc78e0 100644 --- a/src/langbot/pkg/workflow/nodes/data_transform.py +++ b/src/langbot/pkg/workflow/nodes/data_transform.py @@ -16,52 +16,52 @@ from ..safe_eval import safe_eval_with_vars 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" + 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") + data = inputs.get('data') + transform_type = self.get_config('transform_type', 'template') - if transform_type == "template": - template = self.get_config("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", "$") + elif transform_type == 'jsonpath': + expression = self.get_config('expression', '$') result = self._apply_jsonpath(expression, data) - elif transform_type == "expression": - expression = self.get_config("expression", "") + elif transform_type == 'expression': + expression = self.get_config('expression', '') result = self._evaluate_expression(expression, data, context) else: result = data - return {"result": result} + 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)) + result = result.replace(f'{{{{data.{key}}}}}', str(value)) for key, value in context.variables.items(): - result = result.replace(f"{{{{variables.{key}}}}}", str(value)) + result = result.replace(f'{{{{variables.{key}}}}}', str(value)) return result def _apply_jsonpath(self, expression: str, data: Any) -> Any: - if expression == "$": + if expression == '$': return data - if expression.startswith("$."): - parts = expression[2:].split(".") + if expression.startswith('$.'): + parts = expression[2:].split('.') result = data for part in parts: if isinstance(result, dict): @@ -74,7 +74,7 @@ class DataTransformNode(WorkflowNode): return data def _evaluate_expression(self, expression: str, data: Any, context: ExecutionContext) -> Any: - local_vars = {"data": data, "variables": context.variables} + local_vars = {'data': data, 'variables': context.variables} try: return safe_eval_with_vars(expression, local_vars) except Exception: diff --git a/src/langbot/pkg/workflow/nodes/database_query.py b/src/langbot/pkg/workflow/nodes/database_query.py index 4a3a8e1c..3c3f41ce 100644 --- a/src/langbot/pkg/workflow/nodes/database_query.py +++ b/src/langbot/pkg/workflow/nodes/database_query.py @@ -15,37 +15,37 @@ from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig 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" + 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") - query = self.get_config("query", "") - query_type = self.get_config("query_type", "select") - timeout = self.get_config("timeout", 30) + connection_type = self.get_config('connection_type', 'postgresql') + query = self.get_config('query', '') + query_type = self.get_config('query_type', 'select') + timeout = self.get_config('timeout', 30) - parameters = inputs.get("parameters", {}) + 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, + 'results': [], + 'row_count': 0, + 'success': False, + '_debug': { + 'connection_type': connection_type, + 'query': query, + 'query_type': query_type, + 'timeout': timeout, + 'parameters': parameters, }, } diff --git a/src/langbot/pkg/workflow/nodes/dify_knowledge_query.py b/src/langbot/pkg/workflow/nodes/dify_knowledge_query.py index e1f5ad0d..ed6285e0 100644 --- a/src/langbot/pkg/workflow/nodes/dify_knowledge_query.py +++ b/src/langbot/pkg/workflow/nodes/dify_knowledge_query.py @@ -15,33 +15,33 @@ from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig 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" + 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", "") + 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, + 'results': [], + 'success': False, + '_debug': { + 'base_url': base_url, + 'api_key': api_key[:8] + '...' if api_key else '', + 'dataset_id': dataset_id, + 'query': query, }, } diff --git a/src/langbot/pkg/workflow/nodes/dify_workflow.py b/src/langbot/pkg/workflow/nodes/dify_workflow.py index 3e316060..76513159 100644 --- a/src/langbot/pkg/workflow/nodes/dify_workflow.py +++ b/src/langbot/pkg/workflow/nodes/dify_workflow.py @@ -15,35 +15,35 @@ from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig 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" + 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") + 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, + '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, }, } diff --git a/src/langbot/pkg/workflow/nodes/end.py b/src/langbot/pkg/workflow/nodes/end.py index e9c18cdf..2fbf37b4 100644 --- a/src/langbot/pkg/workflow/nodes/end.py +++ b/src/langbot/pkg/workflow/nodes/end.py @@ -15,31 +15,32 @@ from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig 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" + 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") + result = inputs.get('result') + output_format = self.get_config('output_format', 'passthrough') - if output_format == "text": - return {"output": str(result)} - elif output_format == "json": + if output_format == 'text': + return {'output': str(result)} + elif output_format == 'json': import json + try: - return {"output": json.dumps(result, ensure_ascii=False)} + return {'output': json.dumps(result, ensure_ascii=False)} except Exception: - return {"output": str(result)} + return {'output': str(result)} else: - return {"output": result} + return {'output': result} diff --git a/src/langbot/pkg/workflow/nodes/event_trigger.py b/src/langbot/pkg/workflow/nodes/event_trigger.py index a2bec906..56202cc9 100644 --- a/src/langbot/pkg/workflow/nodes/event_trigger.py +++ b/src/langbot/pkg/workflow/nodes/event_trigger.py @@ -15,15 +15,15 @@ from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig 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" + 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]] = [] @@ -35,7 +35,7 @@ class EventTriggerNode(WorkflowNode): 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()), + 'event_type': trigger_data.get('event_type', ''), + 'event_data': trigger_data.get('event_data', {}), + 'timestamp': trigger_data.get('timestamp', datetime.now().isoformat()), } diff --git a/src/langbot/pkg/workflow/nodes/http_request.py b/src/langbot/pkg/workflow/nodes/http_request.py index bd25d15a..e1b2ba29 100644 --- a/src/langbot/pkg/workflow/nodes/http_request.py +++ b/src/langbot/pkg/workflow/nodes/http_request.py @@ -15,15 +15,15 @@ from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig 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" + 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]] = [] @@ -32,39 +32,44 @@ class HTTPRequestNode(WorkflowNode): 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") + 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 + headers = inputs.get('headers', {}) + headers['Content-Type'] = content_type - auth_type = self.get_config("auth_type", "none") - auth_config = self.get_config("auth_config", {}) + 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", "") + 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") + 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, + 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) + 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)} + 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)} + return {'response': None, 'status_code': 0, 'headers': {}, 'error': str(e)} diff --git a/src/langbot/pkg/workflow/nodes/iterator.py b/src/langbot/pkg/workflow/nodes/iterator.py index 99879eef..bb8e19ab 100644 --- a/src/langbot/pkg/workflow/nodes/iterator.py +++ b/src/langbot/pkg/workflow/nodes/iterator.py @@ -12,49 +12,52 @@ from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig 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" + 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), + 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"), + 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": "最大迭代次数"}, + 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", []) + items = inputs.get('items', []) if not isinstance(items, list): items = [items] if items else [] - max_iterations = self.get_config("max_iterations", 1000) + 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, + 'item': items[0] if items else None, + 'index': 0, + 'is_first': True, + 'is_last': len(items) <= 1, + 'results': [], + 'completed': len(items) == 0, + '_items': items, } diff --git a/src/langbot/pkg/workflow/nodes/knowledge_retrieval.py b/src/langbot/pkg/workflow/nodes/knowledge_retrieval.py index f7697508..732a9da3 100644 --- a/src/langbot/pkg/workflow/nodes/knowledge_retrieval.py +++ b/src/langbot/pkg/workflow/nodes/knowledge_retrieval.py @@ -15,20 +15,20 @@ from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig 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" + 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}]"} + query = inputs.get('query', '') + return {'documents': [], 'citations': [], 'context': f'[Knowledge base search for: {query}]'} diff --git a/src/langbot/pkg/workflow/nodes/langflow_flow.py b/src/langbot/pkg/workflow/nodes/langflow_flow.py index 46f4f47b..37778b0c 100644 --- a/src/langbot/pkg/workflow/nodes/langflow_flow.py +++ b/src/langbot/pkg/workflow/nodes/langflow_flow.py @@ -15,33 +15,33 @@ from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig 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" + 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", "") + 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, + '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, }, } diff --git a/src/langbot/pkg/workflow/nodes/llm_call.py b/src/langbot/pkg/workflow/nodes/llm_call.py index 2c1bbcbd..a6f02308 100644 --- a/src/langbot/pkg/workflow/nodes/llm_call.py +++ b/src/langbot/pkg/workflow/nodes/llm_call.py @@ -18,108 +18,120 @@ logger = logging.getLogger(__name__) 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" + 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), + 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"), + 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": "模型"}, + 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": "系统提示词"}, + 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": "用户提示词模板"}, + 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, + 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": "最大令牌数"}, + 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, "")) + 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, "")) + 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) + 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", "") + model_uuid = self.get_config('model', '') if not model_uuid: - raise ValueError("No model configured for LLM call node") + raise ValueError('No model configured for LLM call node') if not self.ap: - raise RuntimeError("Application instance not available — cannot call LLM") + 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 - ) + 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)) + 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") + temperature = self.get_config('temperature') if temperature is not None: - extra_args["temperature"] = float(temperature) - max_tokens = self.get_config("max_tokens", 0) + 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) + extra_args['max_tokens'] = int(max_tokens) # Invoke LLM - logger.info(f"LLM call node {self.node_id}: invoking model {model_uuid}") + 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, @@ -129,7 +141,7 @@ class LLMCallNode(WorkflowNode): ) # Extract response text - response_text = "" + response_text = '' if isinstance(result_message.content, str): response_text = result_message.content elif isinstance(result_message.content, list): @@ -141,23 +153,23 @@ class LLMCallNode(WorkflowNode): response_text += elem # Extract usage info if available - usage = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0} + 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, + '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, + '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, + 'response': response_text, + 'usage': usage, } diff --git a/src/langbot/pkg/workflow/nodes/loop.py b/src/langbot/pkg/workflow/nodes/loop.py index ebfdc559..9dbb60f0 100644 --- a/src/langbot/pkg/workflow/nodes/loop.py +++ b/src/langbot/pkg/workflow/nodes/loop.py @@ -12,51 +12,57 @@ from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig 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" + 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), + 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"), + 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"], + 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": "最大迭代次数"}, + 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", []) + items = inputs.get('items', []) if not isinstance(items, list): items = [items] if items else [] - max_iterations = self.get_config("max_iterations", 100) + 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, + 'item': items[0] if items else None, + 'index': 0, + 'results': [], + 'completed': len(items) == 0, + '_items': items, } diff --git a/src/langbot/pkg/workflow/nodes/mcp_tool.py b/src/langbot/pkg/workflow/nodes/mcp_tool.py index f9ea2aa8..af571004 100644 --- a/src/langbot/pkg/workflow/nodes/mcp_tool.py +++ b/src/langbot/pkg/workflow/nodes/mcp_tool.py @@ -20,21 +20,21 @@ class MCPToolNode(WorkflowNode): """MCP tool node - invoke MCP (Model Context Protocol) tools""" # Node type for registration - type_name = "mcp_tool" - + type_name = 'mcp_tool' + # Category and icon - these are not i18n - category = "integration" - icon = "Wrench" - + 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" - + 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]] = [] @@ -42,29 +42,29 @@ class MCPToolNode(WorkflowNode): 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) + 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) + 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, + '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, }, } diff --git a/src/langbot/pkg/workflow/nodes/memory_store.py b/src/langbot/pkg/workflow/nodes/memory_store.py index 0b528244..c93cf402 100644 --- a/src/langbot/pkg/workflow/nodes/memory_store.py +++ b/src/langbot/pkg/workflow/nodes/memory_store.py @@ -13,35 +13,31 @@ 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: + + 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 + 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: + + 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 + scoped_key = f'{scope}:{key}' if scope else key self._data[scoped_key] = value - - def delete(self, key: str, scope: str = "execution") -> None: + + def delete(self, key: str, scope: str = 'execution') -> None: """Delete a value from memory""" - scoped_key = f"{scope}:{key}" if scope else key + 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]: + + 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: + 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): @@ -56,48 +52,48 @@ class MemoryHelper: 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" + 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) + 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") + value = inputs.get('value') # Wrap context.memory dict with MemoryHelper for structured operations memory = MemoryHelper(context.memory) try: - if operation == "get": + if operation == 'get': result = memory.get(key, scope=scope) - return {"result": result, "success": True} - elif operation == "set": + return {'result': result, 'success': True} + elif operation == 'set': memory.set(key, value, scope=scope, ttl=ttl) - return {"result": value, "success": True} - elif operation == "delete": + return {'result': value, 'success': True} + elif operation == 'delete': memory.delete(key, scope=scope) - return {"result": None, "success": True} - elif operation == "append": + 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": + return {'result': result, 'success': True} + elif operation == 'list': result = memory.list_all(scope=scope) - return {"result": result, "success": True} + return {'result': result, 'success': True} else: - return {"result": None, "success": False, "error": f"Unknown operation: {operation}"} + return {'result': None, 'success': False, 'error': f'Unknown operation: {operation}'} except Exception as e: - return {"result": None, "success": False, "error": str(e)} + return {'result': None, 'success': False, 'error': str(e)} diff --git a/src/langbot/pkg/workflow/nodes/merge.py b/src/langbot/pkg/workflow/nodes/merge.py index 0ba33591..d73b39a3 100644 --- a/src/langbot/pkg/workflow/nodes/merge.py +++ b/src/langbot/pkg/workflow/nodes/merge.py @@ -15,51 +15,51 @@ from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig 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" + 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") + strategy = self.get_config('merge_strategy', 'object') - values = [inputs.get("input_1"), inputs.get("input_2"), inputs.get("input_3"), inputs.get("input_4")] + 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": + 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} + 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 == 'array': + return {'merged': non_null_values, 'array': non_null_values} - elif strategy == "first_non_null": + elif strategy == 'first_non_null': first = non_null_values[0] if non_null_values else None - return {"merged": first, "array": non_null_values} + return {'merged': first, 'array': non_null_values} - elif strategy == "concat": + elif strategy == 'concat': if all(isinstance(v, str) for v in non_null_values): - return {"merged": "".join(non_null_values), "array": 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} + 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} - return {"merged": non_null_values, "array": non_null_values} + return {'merged': non_null_values, 'array': non_null_values} diff --git a/src/langbot/pkg/workflow/nodes/message_trigger.py b/src/langbot/pkg/workflow/nodes/message_trigger.py index e16e0c12..f55a2a48 100644 --- a/src/langbot/pkg/workflow/nodes/message_trigger.py +++ b/src/langbot/pkg/workflow/nodes/message_trigger.py @@ -17,40 +17,40 @@ from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig 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" - + 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(), + '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, + '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, } diff --git a/src/langbot/pkg/workflow/nodes/n8n_workflow.py b/src/langbot/pkg/workflow/nodes/n8n_workflow.py index cbfe2771..61e32799 100644 --- a/src/langbot/pkg/workflow/nodes/n8n_workflow.py +++ b/src/langbot/pkg/workflow/nodes/n8n_workflow.py @@ -15,33 +15,33 @@ from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig 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" + 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", {}) + 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, + 'result': None, + 'success': False, + '_debug': { + 'webhook_url': webhook_url, + 'auth_type': auth_type, + 'timeout': timeout, + 'payload': payload, }, } diff --git a/src/langbot/pkg/workflow/nodes/opening_statement.py b/src/langbot/pkg/workflow/nodes/opening_statement.py index ec2f136d..d239f692 100644 --- a/src/langbot/pkg/workflow/nodes/opening_statement.py +++ b/src/langbot/pkg/workflow/nodes/opening_statement.py @@ -15,23 +15,23 @@ from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig 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" + 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) + 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 []} + return {'statement': statement, 'suggested_questions': suggestions if show else []} diff --git a/src/langbot/pkg/workflow/nodes/parallel.py b/src/langbot/pkg/workflow/nodes/parallel.py index 4dae7add..6e8f8c2e 100644 --- a/src/langbot/pkg/workflow/nodes/parallel.py +++ b/src/langbot/pkg/workflow/nodes/parallel.py @@ -12,38 +12,44 @@ from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig 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" + 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), + 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)"), + 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": "等待全部完成"}, + 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": "快速失败"}, + 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": [], + 'results': {}, + 'errors': [], } diff --git a/src/langbot/pkg/workflow/nodes/parameter_extractor.py b/src/langbot/pkg/workflow/nodes/parameter_extractor.py index 8d551da3..8a39247b 100644 --- a/src/langbot/pkg/workflow/nodes/parameter_extractor.py +++ b/src/langbot/pkg/workflow/nodes/parameter_extractor.py @@ -15,25 +15,25 @@ from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig 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" + 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]: - param_defs = self.get_config("parameters", []) + param_defs = self.get_config('parameters', []) extracted = {} for param in param_defs: - extracted[param.get("name", "")] = None + extracted[param.get('name', '')] = None - return {"parameters": extracted, "extraction_success": False} + return {'parameters': extracted, 'extraction_success': False} diff --git a/src/langbot/pkg/workflow/nodes/question_classifier.py b/src/langbot/pkg/workflow/nodes/question_classifier.py index 1346644f..5d79e076 100644 --- a/src/langbot/pkg/workflow/nodes/question_classifier.py +++ b/src/langbot/pkg/workflow/nodes/question_classifier.py @@ -15,28 +15,28 @@ from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig 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" + 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]: - categories = self.get_config("categories", []) + 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}, + '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": {}} + return {'category': 'unknown', 'confidence': 0.0, 'all_scores': {}} diff --git a/src/langbot/pkg/workflow/nodes/redis_operation.py b/src/langbot/pkg/workflow/nodes/redis_operation.py index 809c8403..21af7967 100644 --- a/src/langbot/pkg/workflow/nodes/redis_operation.py +++ b/src/langbot/pkg/workflow/nodes/redis_operation.py @@ -15,39 +15,39 @@ from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig 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" + 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) + 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") + 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, + 'result': None, + 'success': False, + '_debug': { + 'connection_url': connection_url, + 'operation': operation, + 'key': key, + 'hash_field': hash_field, + 'ttl': ttl, + 'value': value, }, } diff --git a/src/langbot/pkg/workflow/nodes/reply_message.py b/src/langbot/pkg/workflow/nodes/reply_message.py index 091e77e5..51b73d9d 100644 --- a/src/langbot/pkg/workflow/nodes/reply_message.py +++ b/src/langbot/pkg/workflow/nodes/reply_message.py @@ -18,44 +18,44 @@ logger = logging.getLogger(__name__) 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" + 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, ""): - message = inputs.get("content") - if message in (None, "") and context.message_context: + message = inputs.get('message') + if message in (None, ''): + message = inputs.get('input') + if message in (None, ''): + message = inputs.get('response') + if message in (None, ''): + message = inputs.get('content') + if message in (None, '') and context.message_context: message = context.message_context.message_content if message is None: - message = "" + message = '' - template = self.get_config("message_template") + template = self.get_config('message_template') if template: message = template for key, value in inputs.items(): - message = message.replace(f"{{{{{key}}}}}", str(value)) + message = message.replace(f'{{{{{key}}}}}', str(value)) for key, value in context.variables.items(): - message = message.replace(f"{{{{variables.{key}}}}}", str(value)) + message = message.replace(f'{{{{variables.{key}}}}}', str(value)) logger.info( - "ReplyMessageNode resolved message", + 'ReplyMessageNode resolved message', extra={ 'node_id': self.node_id, 'execution_id': context.execution_id, @@ -68,7 +68,7 @@ class ReplyMessageNode(WorkflowNode): if not str(message).strip(): logger.warning( - "ReplyMessageNode has empty message after resolution", + 'ReplyMessageNode has empty message after resolution', extra={ 'node_id': self.node_id, 'execution_id': context.execution_id, @@ -79,6 +79,7 @@ class ReplyMessageNode(WorkflowNode): # 实际发送消息 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', @@ -87,11 +88,11 @@ class ReplyMessageNode(WorkflowNode): ) else: logger.warning( - "ReplyMessageNode missing application instance", + '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}"} + return {'status': 'sent', 'message_id': f'reply_{context.execution_id}'} diff --git a/src/langbot/pkg/workflow/nodes/send_message.py b/src/langbot/pkg/workflow/nodes/send_message.py index 460fad71..9440b4d7 100644 --- a/src/langbot/pkg/workflow/nodes/send_message.py +++ b/src/langbot/pkg/workflow/nodes/send_message.py @@ -15,19 +15,19 @@ from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig 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" + 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]: - return {"status": "sent", "message_id": f"msg_{context.execution_id}"} + return {'status': 'sent', 'message_id': f'msg_{context.execution_id}'} diff --git a/src/langbot/pkg/workflow/nodes/set_variable.py b/src/langbot/pkg/workflow/nodes/set_variable.py index 37ba8bdb..193cad6b 100644 --- a/src/langbot/pkg/workflow/nodes/set_variable.py +++ b/src/langbot/pkg/workflow/nodes/set_variable.py @@ -15,50 +15,50 @@ from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig 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" + 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") + 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": + if scope == 'conversation': current = context.get_conversation_variable(name) else: current = context.get_variable(name) - if operation == "set": + if operation == 'set': final_value = value - elif operation == "append": + 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": + elif operation == 'increment': final_value = (current or 0) + (value if isinstance(value, (int, float)) else 1) - elif operation == "decrement": + elif operation == 'decrement': final_value = (current or 0) - (value if isinstance(value, (int, float)) else 1) else: final_value = value - if scope == "conversation": + if scope == 'conversation': context.set_conversation_variable(name, final_value) else: context.set_variable(name, final_value) - return {"value": final_value} + return {'value': final_value} diff --git a/src/langbot/pkg/workflow/nodes/store_data.py b/src/langbot/pkg/workflow/nodes/store_data.py index 260092b0..12036eff 100644 --- a/src/langbot/pkg/workflow/nodes/store_data.py +++ b/src/langbot/pkg/workflow/nodes/store_data.py @@ -15,31 +15,31 @@ from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig 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" + 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", "") + 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 + full_key = f'{prefix}{key}' if prefix else key - if storage_type == "session": + if storage_type == 'session': context.set_conversation_variable(full_key, value) else: context.set_variable(full_key, value) - return {"status": "stored"} + return {'status': 'stored'} diff --git a/src/langbot/pkg/workflow/nodes/switch.py b/src/langbot/pkg/workflow/nodes/switch.py index 5dffa715..bc1b1db2 100644 --- a/src/langbot/pkg/workflow/nodes/switch.py +++ b/src/langbot/pkg/workflow/nodes/switch.py @@ -15,42 +15,42 @@ from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig 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" + 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") + 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")} + 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} + 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("}}"): + if expression.startswith('{{') and expression.endswith('}}'): var_path = expression[2:-2].strip() - parts = var_path.split(".") + parts = var_path.split('.') - if parts[0] == "input": + if parts[0] == 'input': result = data for part in parts[1:]: if isinstance(result, dict): @@ -58,7 +58,7 @@ class SwitchNode(WorkflowNode): else: return None return result - elif parts[0] == "variables": - return context.variables.get(".".join(parts[1:])) + elif parts[0] == 'variables': + return context.variables.get('.'.join(parts[1:])) return expression diff --git a/src/langbot/pkg/workflow/nodes/variable_aggregator.py b/src/langbot/pkg/workflow/nodes/variable_aggregator.py index 07500923..6778da27 100644 --- a/src/langbot/pkg/workflow/nodes/variable_aggregator.py +++ b/src/langbot/pkg/workflow/nodes/variable_aggregator.py @@ -15,37 +15,37 @@ from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig 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" + 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") + variables = inputs.get('variables', {}) + mode = self.get_config('aggregation_mode', 'merge') aggregated = {} - if mode == "merge": + if mode == 'merge': if isinstance(variables, dict): aggregated.update(variables) - elif mode == "override": + elif mode == 'override': if isinstance(variables, dict): aggregated = variables.copy() - elif mode == "append": + 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} + return {'aggregated': aggregated} diff --git a/src/langbot/pkg/workflow/nodes/wait.py b/src/langbot/pkg/workflow/nodes/wait.py index bceda864..49b76fb6 100644 --- a/src/langbot/pkg/workflow/nodes/wait.py +++ b/src/langbot/pkg/workflow/nodes/wait.py @@ -15,15 +15,15 @@ from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig 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" + 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]] = [] @@ -32,14 +32,14 @@ class WaitNode(WorkflowNode): 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") + duration = self.get_config('duration', 1) + duration_type = self.get_config('duration_type', 'seconds') - if duration_type == "minutes": + if duration_type == 'minutes': duration *= 60 - elif duration_type == "hours": + elif duration_type == 'hours': duration *= 3600 await asyncio.sleep(duration) - return {"output": inputs.get("input")} + return {'output': inputs.get('input')} diff --git a/src/langbot/pkg/workflow/nodes/webhook_trigger.py b/src/langbot/pkg/workflow/nodes/webhook_trigger.py index eaa13725..b638591c 100644 --- a/src/langbot/pkg/workflow/nodes/webhook_trigger.py +++ b/src/langbot/pkg/workflow/nodes/webhook_trigger.py @@ -15,15 +15,15 @@ from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig 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" + 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]] = [] @@ -33,8 +33,8 @@ class WebhookTriggerNode(WorkflowNode): 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"), + 'body': trigger_data.get('body', {}), + 'headers': trigger_data.get('headers', {}), + 'query': trigger_data.get('query', {}), + 'method': trigger_data.get('method', 'POST'), } diff --git a/src/langbot/pkg/workflow/registry.py b/src/langbot/pkg/workflow/registry.py index 560d25f0..7ee364df 100644 --- a/src/langbot/pkg/workflow/registry.py +++ b/src/langbot/pkg/workflow/registry.py @@ -1,4 +1,5 @@ """Node type registry""" + from __future__ import annotations from typing import Any, Optional @@ -11,9 +12,9 @@ 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]] = { @@ -24,31 +25,31 @@ class NodeTypeRegistry: '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: @@ -57,13 +58,13 @@ class NodeTypeRegistry: 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(): @@ -81,62 +82,56 @@ class NodeTypeRegistry: 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]: + + 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() - ] - + 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 + 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() - } - + 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(): @@ -145,11 +140,11 @@ class NodeTypeRegistry: 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() diff --git a/src/langbot/pkg/workflow/safe_eval.py b/src/langbot/pkg/workflow/safe_eval.py index 497e7f7c..f732e941 100644 --- a/src/langbot/pkg/workflow/safe_eval.py +++ b/src/langbot/pkg/workflow/safe_eval.py @@ -8,6 +8,7 @@ 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 @@ -74,7 +75,7 @@ def _eval_node(node: ast.AST, variables: dict[str, Any]) -> Any: 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}") + raise ValueError(f'Unsupported variable reference: {node.id}') # Attribute access: obj.attr (only on allowed variables) if isinstance(node, ast.Attribute): @@ -99,14 +100,14 @@ def _eval_node(node: ast.AST, variables: dict[str, Any]) -> Any: 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__}") + 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__}") + 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) @@ -115,7 +116,7 @@ def _eval_node(node: ast.AST, variables: dict[str, Any]) -> Any: 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__}") + raise ValueError(f'Unsupported comparison: {type(op).__name__}') right = _eval_node(comparator, variables) if not op_fn(left, right): return False @@ -132,9 +133,7 @@ def _eval_node(node: ast.AST, variables: dict[str, Any]) -> Any: # Ternary if isinstance(node, ast.IfExp): return ( - _eval_node(node.body, variables) - if _eval_node(node.test, variables) - else _eval_node(node.orelse, variables) + _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]``) @@ -143,9 +142,6 @@ def _eval_node(node: ast.AST, variables: dict[str, Any]) -> Any: # 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) - } + 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__}") + raise ValueError(f'Unsupported expression node: {type(node).__name__}')