This commit is contained in:
Typer_Body
2026-05-08 00:56:27 +08:00
parent eb9f38b102
commit 75fdfe6806
51 changed files with 1585 additions and 1643 deletions
@@ -3,4 +3,3 @@ from .workflows import WorkflowsRouterGroup, ExecutionsRouterGroup
from .websocket_chat import WorkflowWebSocketChatRouterGroup from .websocket_chat import WorkflowWebSocketChatRouterGroup
__all__ = ['WorkflowsRouterGroup', 'ExecutionsRouterGroup', 'WorkflowWebSocketChatRouterGroup'] __all__ = ['WorkflowsRouterGroup', 'ExecutionsRouterGroup', 'WorkflowWebSocketChatRouterGroup']
@@ -19,9 +19,7 @@ class WorkflowsRouterGroup(group.RouterGroup):
sort_order = quart.request.args.get('sort_order', 'DESC') sort_order = quart.request.args.get('sort_order', 'DESC')
enabled_only = quart.request.args.get('enabled_only', 'false').lower() == 'true' enabled_only = quart.request.args.get('enabled_only', 'false').lower() == 'true'
return self.success( return self.success(
data={'workflows': await self.ap.workflow_service.get_workflows( data={'workflows': await self.ap.workflow_service.get_workflows(sort_by, sort_order, enabled_only)}
sort_by, sort_order, enabled_only
)}
) )
elif quart.request.method == 'POST': elif quart.request.method == 'POST':
json_data = await quart.request.json json_data = await quart.request.json
@@ -31,10 +29,12 @@ class WorkflowsRouterGroup(group.RouterGroup):
# Get node types (available nodes for the editor) # Get node types (available nodes for the editor)
@self.route('/_/node-types', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) @self.route('/_/node-types', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _() -> str: async def _() -> str:
return self.success(data={ return self.success(
'node_types': await self.ap.workflow_service.get_node_types(), data={
'categories': await self.ap.workflow_service.get_node_types_by_category_meta(), '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 # Get node types by category
@self.route('/_/node-types/categories', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) @self.route('/_/node-types/categories', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
@@ -105,7 +105,7 @@ class WorkflowsRouterGroup(group.RouterGroup):
trigger_data=trigger_data, trigger_data=trigger_data,
session_id=session_id, session_id=session_id,
user_id=user_id, user_id=user_id,
bot_id=bot_id bot_id=bot_id,
) )
return self.success(data={'execution_id': execution_id}) return self.success(data={'execution_id': execution_id})
except ValueError as e: except ValueError as e:
@@ -119,9 +119,7 @@ class WorkflowsRouterGroup(group.RouterGroup):
limit = int(quart.request.args.get('limit', 50)) limit = int(quart.request.args.get('limit', 50))
offset = int(quart.request.args.get('offset', 0)) offset = int(quart.request.args.get('offset', 0))
executions = await self.ap.workflow_service.get_executions( executions = await self.ap.workflow_service.get_executions(
workflow_uuid=workflow_uuid, workflow_uuid=workflow_uuid, limit=limit, offset=offset
limit=limit,
offset=offset
) )
return self.success(data=executions) return self.success(data=executions)
@@ -146,9 +144,7 @@ class WorkflowsRouterGroup(group.RouterGroup):
# Rollback to a specific version # Rollback to a specific version
@self.route( @self.route(
'/<workflow_uuid>/rollback/<int:version>', '/<workflow_uuid>/rollback/<int:version>', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY
) )
async def _(workflow_uuid: str, version: int) -> str: async def _(workflow_uuid: str, version: int) -> str:
try: try:
@@ -192,14 +188,12 @@ class WorkflowsRouterGroup(group.RouterGroup):
try: try:
await self.ap.workflow_service.update_workflow_extensions( await self.ap.workflow_service.update_workflow_extensions(
workflow_uuid, bound_plugins, bound_mcp_servers, workflow_uuid, bound_plugins, bound_mcp_servers, enable_all_plugins, enable_all_mcp_servers
enable_all_plugins, enable_all_mcp_servers
) )
return self.success() return self.success()
except ValueError as e: except ValueError as e:
return self.http_status(404, -1, str(e)) return self.http_status(404, -1, str(e))
# Debug API - Start debug execution # Debug API - Start debug execution
@self.route('/<workflow_uuid>/debug/start', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) @self.route('/<workflow_uuid>/debug/start', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _(workflow_uuid: str) -> str: async def _(workflow_uuid: str) -> str:
@@ -210,10 +204,7 @@ class WorkflowsRouterGroup(group.RouterGroup):
try: try:
execution_id = await self.ap.workflow_service.start_debug_execution( execution_id = await self.ap.workflow_service.start_debug_execution(
workflow_uuid, workflow_uuid, context=context, variables=variables, breakpoints=breakpoints
context=context,
variables=variables,
breakpoints=breakpoints
) )
return self.success(data={'execution_id': execution_id}) return self.success(data={'execution_id': execution_id})
except ValueError as e: except ValueError as e:
@@ -223,7 +214,7 @@ class WorkflowsRouterGroup(group.RouterGroup):
@self.route( @self.route(
'/<workflow_uuid>/debug/<execution_uuid>/pause', '/<workflow_uuid>/debug/<execution_uuid>/pause',
methods=['POST'], 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: async def _(workflow_uuid: str, execution_uuid: str) -> str:
try: try:
@@ -236,7 +227,7 @@ class WorkflowsRouterGroup(group.RouterGroup):
@self.route( @self.route(
'/<workflow_uuid>/debug/<execution_uuid>/resume', '/<workflow_uuid>/debug/<execution_uuid>/resume',
methods=['POST'], 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: async def _(workflow_uuid: str, execution_uuid: str) -> str:
try: try:
@@ -249,7 +240,7 @@ class WorkflowsRouterGroup(group.RouterGroup):
@self.route( @self.route(
'/<workflow_uuid>/debug/<execution_uuid>/step', '/<workflow_uuid>/debug/<execution_uuid>/step',
methods=['POST'], 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: async def _(workflow_uuid: str, execution_uuid: str) -> str:
try: try:
@@ -262,7 +253,7 @@ class WorkflowsRouterGroup(group.RouterGroup):
@self.route( @self.route(
'/<workflow_uuid>/debug/<execution_uuid>/stop', '/<workflow_uuid>/debug/<execution_uuid>/stop',
methods=['POST'], 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: async def _(workflow_uuid: str, execution_uuid: str) -> str:
try: try:
@@ -275,7 +266,7 @@ class WorkflowsRouterGroup(group.RouterGroup):
@self.route( @self.route(
'/<workflow_uuid>/debug/<execution_uuid>/state', '/<workflow_uuid>/debug/<execution_uuid>/state',
methods=['GET'], 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: async def _(workflow_uuid: str, execution_uuid: str) -> str:
try: try:
@@ -288,15 +279,13 @@ class WorkflowsRouterGroup(group.RouterGroup):
@self.route( @self.route(
'/<workflow_uuid>/executions/<execution_uuid>/logs', '/<workflow_uuid>/executions/<execution_uuid>/logs',
methods=['GET'], 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: async def _(workflow_uuid: str, execution_uuid: str) -> str:
limit = int(quart.request.args.get('limit', 100)) limit = int(quart.request.args.get('limit', 100))
offset = int(quart.request.args.get('offset', 0)) offset = int(quart.request.args.get('offset', 0))
try: try:
result = await self.ap.workflow_service.get_execution_logs( result = await self.ap.workflow_service.get_execution_logs(workflow_uuid, execution_uuid, limit, offset)
workflow_uuid, execution_uuid, limit, offset
)
return self.success(data=result) return self.success(data=result)
except ValueError as e: except ValueError as e:
return self.http_status(404, -1, str(e)) return self.http_status(404, -1, str(e))
@@ -305,13 +294,11 @@ class WorkflowsRouterGroup(group.RouterGroup):
@self.route( @self.route(
'/<workflow_uuid>/executions/<execution_uuid>/rerun', '/<workflow_uuid>/executions/<execution_uuid>/rerun',
methods=['POST'], 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: async def _(workflow_uuid: str, execution_uuid: str) -> str:
try: try:
new_execution_id = await self.ap.workflow_service.rerun_execution( new_execution_id = await self.ap.workflow_service.rerun_execution(workflow_uuid, execution_uuid)
workflow_uuid, execution_uuid
)
return self.success(data={'execution_uuid': new_execution_id}) return self.success(data={'execution_uuid': new_execution_id})
except ValueError as e: except ValueError as e:
return self.http_status(404, -1, str(e)) return self.http_status(404, -1, str(e))
@@ -337,11 +324,7 @@ class ExecutionsRouterGroup(group.RouterGroup):
limit = int(quart.request.args.get('limit', 50)) limit = int(quart.request.args.get('limit', 50))
offset = int(quart.request.args.get('offset', 0)) offset = int(quart.request.args.get('offset', 0))
status = quart.request.args.get('status') status = quart.request.args.get('status')
executions = await self.ap.workflow_service.get_executions( executions = await self.ap.workflow_service.get_executions(limit=limit, offset=offset, status=status)
limit=limit,
offset=offset,
status=status
)
return self.success(data=executions) return self.success(data=executions)
# Get single execution # Get single execution
+132 -153
View File
@@ -1,4 +1,5 @@
"""Workflow service for managing workflow CRUD and execution""" """Workflow service for managing workflow CRUD and execution"""
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
@@ -57,10 +58,7 @@ class WorkflowService:
from ....workflow import nodes # noqa: F401 from ....workflow import nodes # noqa: F401
async def get_workflows( async def get_workflows(
self, self, sort_by: str = 'created_at', sort_order: str = 'DESC', enabled_only: bool = False
sort_by: str = 'created_at',
sort_order: str = 'DESC',
enabled_only: bool = False
) -> list[dict]: ) -> list[dict]:
"""Get all workflows""" """Get all workflows"""
query = sqlalchemy.select(persistence_workflow.Workflow) query = sqlalchemy.select(persistence_workflow.Workflow)
@@ -87,17 +85,12 @@ class WorkflowService:
result = await self.ap.persistence_mgr.execute_async(query) result = await self.ap.persistence_mgr.execute_async(query)
workflows = result.all() workflows = result.all()
return [ return [self._serialize_workflow(workflow) for workflow in workflows]
self._serialize_workflow(workflow)
for workflow in workflows
]
async def get_workflow(self, workflow_uuid: str) -> Optional[dict]: async def get_workflow(self, workflow_uuid: str) -> Optional[dict]:
"""Get a single workflow by UUID""" """Get a single workflow by UUID"""
result = await self.ap.persistence_mgr.execute_async( result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_workflow.Workflow).where( sqlalchemy.select(persistence_workflow.Workflow).where(persistence_workflow.Workflow.uuid == workflow_uuid)
persistence_workflow.Workflow.uuid == workflow_uuid
)
) )
workflow = result.first() workflow = result.first()
@@ -127,18 +120,24 @@ class WorkflowService:
'emoji': workflow_data.get('emoji', '🔄'), 'emoji': workflow_data.get('emoji', '🔄'),
'version': 1, 'version': 1,
'is_enabled': workflow_data.get('is_enabled', True), 'is_enabled': workflow_data.get('is_enabled', True),
'definition': workflow_data.get('definition', { 'definition': workflow_data.get(
'nodes': [], 'definition',
'edges': [], {
'variables': {}, 'nodes': [],
}), 'edges': [],
'variables': {},
},
),
'global_config': workflow_data.get('global_config', {}), 'global_config': workflow_data.get('global_config', {}),
'extensions_preferences': workflow_data.get('extensions_preferences', { 'extensions_preferences': workflow_data.get(
'enable_all_plugins': True, 'extensions_preferences',
'enable_all_mcp_servers': True, {
'plugins': [], 'enable_all_plugins': True,
'mcp_servers': [], 'enable_all_mcp_servers': True,
}), 'plugins': [],
'mcp_servers': [],
},
),
} }
await self.ap.persistence_mgr.execute_async( await self.ap.persistence_mgr.execute_async(
@@ -194,7 +193,7 @@ class WorkflowService:
workflow_uuid, workflow_uuid,
current.get('version', 1), current.get('version', 1),
current.get('definition', {}), current.get('definition', {}),
current.get('global_config', {}) current.get('global_config', {}),
) )
await self.ap.persistence_mgr.execute_async( await self.ap.persistence_mgr.execute_async(
@@ -219,9 +218,7 @@ class WorkflowService:
# Delete the workflow # Delete the workflow
await self.ap.persistence_mgr.execute_async( await self.ap.persistence_mgr.execute_async(
sqlalchemy.delete(persistence_workflow.Workflow).where( sqlalchemy.delete(persistence_workflow.Workflow).where(persistence_workflow.Workflow.uuid == workflow_uuid)
persistence_workflow.Workflow.uuid == workflow_uuid
)
) )
async def copy_workflow(self, workflow_uuid: str) -> str: async def copy_workflow(self, workflow_uuid: str) -> str:
@@ -243,19 +240,22 @@ class WorkflowService:
new_uuid = str(uuid.uuid4()) new_uuid = str(uuid.uuid4())
new_workflow = { new_workflow = {
'uuid': new_uuid, 'uuid': new_uuid,
'name': f"{original['name']} (Copy)", 'name': f'{original["name"]} (Copy)',
'description': original.get('description', ''), 'description': original.get('description', ''),
'emoji': original.get('emoji', '🔄'), 'emoji': original.get('emoji', '🔄'),
'version': 1, 'version': 1,
'is_enabled': False, # Disabled by default 'is_enabled': False, # Disabled by default
'definition': original.get('definition', {}), 'definition': original.get('definition', {}),
'global_config': original.get('global_config', {}), 'global_config': original.get('global_config', {}),
'extensions_preferences': original.get('extensions_preferences', { 'extensions_preferences': original.get(
'enable_all_plugins': True, 'extensions_preferences',
'enable_all_mcp_servers': True, {
'plugins': [], 'enable_all_plugins': True,
'mcp_servers': [], 'enable_all_mcp_servers': True,
}), 'plugins': [],
'mcp_servers': [],
},
),
} }
await self.ap.persistence_mgr.execute_async( await self.ap.persistence_mgr.execute_async(
@@ -271,7 +271,7 @@ class WorkflowService:
trigger_data: Optional[dict] = None, trigger_data: Optional[dict] = None,
session_id: Optional[str] = None, session_id: Optional[str] = None,
user_id: Optional[str] = None, user_id: Optional[str] = None,
bot_id: Optional[str] = None bot_id: Optional[str] = None,
) -> str: ) -> str:
"""Execute a workflow and return execution ID""" """Execute a workflow and return execution ID"""
workflow_dict = await self.get_workflow(workflow_uuid) workflow_dict = await self.get_workflow(workflow_uuid)
@@ -391,11 +391,7 @@ class WorkflowService:
if getattr(state, 'status', None) == NodeStatus.FAILED if getattr(state, 'status', None) == NodeStatus.FAILED
] ]
error_message = next( 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', 'Workflow execution failed',
) )
@@ -455,29 +451,19 @@ class WorkflowService:
return execution_uuid return execution_uuid
async def get_executions( async def get_executions(
self, self, workflow_uuid: Optional[str] = None, limit: int = 50, offset: int = 0, status: Optional[str] = None
workflow_uuid: Optional[str] = None,
limit: int = 50,
offset: int = 0,
status: Optional[str] = None
) -> dict: ) -> dict:
"""Get workflow executions with total count""" """Get workflow executions with total count"""
base_filter = [] base_filter = []
if workflow_uuid: if workflow_uuid:
base_filter.append( base_filter.append(persistence_workflow.WorkflowExecution.workflow_uuid == workflow_uuid)
persistence_workflow.WorkflowExecution.workflow_uuid == workflow_uuid
)
if status: if status:
base_filter.append( base_filter.append(persistence_workflow.WorkflowExecution.status == status)
persistence_workflow.WorkflowExecution.status == status
)
# Get total count # Get total count
count_query = sqlalchemy.select( count_query = sqlalchemy.select(sqlalchemy.func.count(persistence_workflow.WorkflowExecution.uuid))
sqlalchemy.func.count(persistence_workflow.WorkflowExecution.uuid)
)
for f in base_filter: for f in base_filter:
count_query = count_query.where(f) count_query = count_query.where(f)
count_result = await self.ap.persistence_mgr.execute_async(count_query) count_result = await self.ap.persistence_mgr.execute_async(count_query)
@@ -488,9 +474,7 @@ class WorkflowService:
for f in base_filter: for f in base_filter:
query = query.where(f) query = query.where(f)
query = query.order_by( query = query.order_by(persistence_workflow.WorkflowExecution.created_at.desc()).limit(limit).offset(offset)
persistence_workflow.WorkflowExecution.created_at.desc()
).limit(limit).offset(offset)
result = await self.ap.persistence_mgr.execute_async(query) result = await self.ap.persistence_mgr.execute_async(query)
executions = result.all() executions = result.all()
@@ -515,14 +499,14 @@ class WorkflowService:
data = self._serialize_execution(execution) data = self._serialize_execution(execution)
node_exec_query = sqlalchemy.select(persistence_workflow.WorkflowNodeExecution).where( node_exec_query = (
persistence_workflow.WorkflowNodeExecution.execution_uuid == execution_uuid sqlalchemy.select(persistence_workflow.WorkflowNodeExecution)
).order_by(persistence_workflow.WorkflowNodeExecution.id.asc()) .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_exec_result = await self.ap.persistence_mgr.execute_async(node_exec_query)
node_executions = node_exec_result.all() node_executions = node_exec_result.all()
data['node_executions'] = [ data['node_executions'] = [self._serialize_node_execution(node_exec) for node_exec in node_executions]
self._serialize_node_execution(node_exec) for node_exec in node_executions
]
return data return data
async def get_node_types(self) -> list[dict]: async def get_node_types(self) -> list[dict]:
@@ -564,16 +548,21 @@ class WorkflowService:
for order, category_name in enumerate(ordered_categories): for order, category_name in enumerate(ordered_categories):
if category_name not in categories: if category_name not in categories:
continue continue
result.append({ result.append(
'name': category_name, {
'label': label_map.get(category_name, { 'name': category_name,
'en-US': category_name, 'label': label_map.get(
'en': category_name, category_name,
'zh-Hans': category_name, {
'zh-CN': category_name, 'en-US': category_name,
}), 'en': category_name,
'order': order, 'zh-Hans': category_name,
}) 'zh-CN': category_name,
},
),
'order': order,
}
)
return result return result
@@ -592,16 +581,12 @@ class WorkflowService:
# Enrich inputs from YAML if Python class has empty inputs # Enrich inputs from YAML if Python class has empty inputs
yaml_inputs = node_config.get('inputs', []) yaml_inputs = node_config.get('inputs', [])
if yaml_inputs and not node_schema.get('inputs'): if yaml_inputs and not node_schema.get('inputs'):
node_schema['inputs'] = [ node_schema['inputs'] = [self._normalize_port_item(inp) for inp in yaml_inputs]
self._normalize_port_item(inp) for inp in yaml_inputs
]
# Enrich outputs from YAML if Python class has empty outputs # Enrich outputs from YAML if Python class has empty outputs
yaml_outputs = node_config.get('outputs', []) yaml_outputs = node_config.get('outputs', [])
if yaml_outputs and not node_schema.get('outputs'): if yaml_outputs and not node_schema.get('outputs'):
node_schema['outputs'] = [ node_schema['outputs'] = [self._normalize_port_item(out) for out in yaml_outputs]
self._normalize_port_item(out) for out in yaml_outputs
]
# Enrich config_schema from YAML # Enrich config_schema from YAML
yaml_configs = node_config.get('config', []) yaml_configs = node_config.get('config', [])
@@ -652,11 +637,14 @@ class WorkflowService:
port = {**port, 'label': normalized_label} port = {**port, 'label': normalized_label}
else: else:
name = port.get('name', '') name = port.get('name', '')
port = {**port, 'label': { port = {
'en_US': name, **port,
'en': name, 'label': {
'zh_Hans': name, 'en_US': name,
}} 'en': name,
'zh_Hans': name,
},
}
description = port.get('description') description = port.get('description')
if isinstance(description, dict): if isinstance(description, dict):
@@ -667,11 +655,14 @@ class WorkflowService:
} }
port = {**port, 'description': normalized_desc} port = {**port, 'description': normalized_desc}
else: else:
port = {**port, 'description': { port = {
'en_US': '', **port,
'en': '', 'description': {
'zh_Hans': '', 'en_US': '',
}} 'en': '',
'zh_Hans': '',
},
}
return port return port
@@ -690,11 +681,14 @@ class WorkflowService:
else: else:
# Create default label from name (handles both None and non-dict cases) # Create default label from name (handles both None and non-dict cases)
name = cfg.get('name', '') name = cfg.get('name', '')
cfg = {**cfg, 'label': { cfg = {
'en_US': name, **cfg,
'en': name, 'label': {
'zh_Hans': name, 'en_US': name,
}} 'en': name,
'zh_Hans': name,
},
}
# Ensure description is in proper i18n format # Ensure description is in proper i18n format
description = cfg.get('description') description = cfg.get('description')
@@ -706,11 +700,14 @@ class WorkflowService:
} }
cfg = {**cfg, 'description': normalized_desc} cfg = {**cfg, 'description': normalized_desc}
else: else:
cfg = {**cfg, 'description': { cfg = {
'en_US': '', **cfg,
'en': '', 'description': {
'zh_Hans': '', 'en_US': '',
}} 'en': '',
'zh_Hans': '',
},
}
# Handle options - convert from list of {name, label} to list of {name, label} # Handle options - convert from list of {name, label} to list of {name, label}
options = cfg.get('options') options = cfg.get('options')
@@ -764,20 +761,16 @@ class WorkflowService:
versions = result.all() versions = result.all()
return [ return [
self.ap.persistence_mgr.serialize_model( self.ap.persistence_mgr.serialize_model(persistence_workflow.WorkflowVersion, version)
persistence_workflow.WorkflowVersion,
version
)
for version in versions for version in versions
] ]
async def rollback_to_version(self, workflow_uuid: str, version: int) -> None: async def rollback_to_version(self, workflow_uuid: str, version: int) -> None:
"""Rollback workflow to a specific version""" """Rollback workflow to a specific version"""
result = await self.ap.persistence_mgr.execute_async( result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_workflow.WorkflowVersion) sqlalchemy.select(persistence_workflow.WorkflowVersion).where(
.where(
persistence_workflow.WorkflowVersion.workflow_uuid == workflow_uuid, persistence_workflow.WorkflowVersion.workflow_uuid == workflow_uuid,
persistence_workflow.WorkflowVersion.version == version persistence_workflow.WorkflowVersion.version == version,
) )
) )
@@ -787,17 +780,16 @@ class WorkflowService:
raise ValueError(f'Version {version} not found for workflow {workflow_uuid}') raise ValueError(f'Version {version} not found for workflow {workflow_uuid}')
# Update workflow with the old version's definition # Update workflow with the old version's definition
await self.update_workflow(workflow_uuid, { await self.update_workflow(
'definition': version_record.definition, workflow_uuid,
'global_config': version_record.global_config, {
}) 'definition': version_record.definition,
'global_config': version_record.global_config,
},
)
async def _save_version_history( async def _save_version_history(
self, self, workflow_uuid: str, version: int, definition: dict, global_config: dict
workflow_uuid: str,
version: int,
definition: dict,
global_config: dict
) -> None: ) -> None:
"""Save workflow version to history""" """Save workflow version to history"""
# Check if version already exists (database-agnostic) # Check if version already exists (database-agnostic)
@@ -821,10 +813,7 @@ class WorkflowService:
def _serialize_workflow(self, workflow) -> dict: def _serialize_workflow(self, workflow) -> dict:
"""Serialize workflow entity to dict""" """Serialize workflow entity to dict"""
result = self.ap.persistence_mgr.serialize_model( result = self.ap.persistence_mgr.serialize_model(persistence_workflow.Workflow, workflow)
persistence_workflow.Workflow,
workflow
)
# Extract nodes and edges from definition to top level for frontend compatibility # Extract nodes and edges from definition to top level for frontend compatibility
definition = result.get('definition', {}) definition = result.get('definition', {})
@@ -916,8 +905,9 @@ class WorkflowService:
# Get total execution count # Get total execution count
total_result = await self.ap.persistence_mgr.execute_async( total_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(sqlalchemy.func.count(persistence_workflow.WorkflowExecution.uuid)) sqlalchemy.select(sqlalchemy.func.count(persistence_workflow.WorkflowExecution.uuid)).where(
.where(persistence_workflow.WorkflowExecution.workflow_uuid == workflow_uuid) persistence_workflow.WorkflowExecution.workflow_uuid == workflow_uuid
)
) )
total_executions = total_result.scalar() total_executions = total_result.scalar()
@@ -925,7 +915,7 @@ class WorkflowService:
status_result = await self.ap.persistence_mgr.execute_async( status_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select( sqlalchemy.select(
persistence_workflow.WorkflowExecution.status, 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) .where(persistence_workflow.WorkflowExecution.workflow_uuid == workflow_uuid)
.group_by(persistence_workflow.WorkflowExecution.status) .group_by(persistence_workflow.WorkflowExecution.status)
@@ -948,15 +938,14 @@ class WorkflowService:
avg_time_result = await self.ap.persistence_mgr.execute_async( avg_time_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select( sqlalchemy.select(
sqlalchemy.func.avg( sqlalchemy.func.avg(
sqlalchemy.func.strftime('%s', persistence_workflow.WorkflowExecution.end_time) - 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.start_time)
) )
) ).where(
.where(
persistence_workflow.WorkflowExecution.workflow_uuid == workflow_uuid, persistence_workflow.WorkflowExecution.workflow_uuid == workflow_uuid,
persistence_workflow.WorkflowExecution.status == 'completed', persistence_workflow.WorkflowExecution.status == 'completed',
persistence_workflow.WorkflowExecution.start_time.isnot(None), 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() avg_execution_time = avg_time_result.scalar()
@@ -980,7 +969,9 @@ class WorkflowService:
'running_executions': int(running_count), 'running_executions': int(running_count),
'cancelled_executions': int(cancelled_count), 'cancelled_executions': int(cancelled_count),
'success_rate': round(success_rate, 4), # Already 0-1 ratio '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, 'last_execution_time': last_execution[0] if last_execution else None,
} }
@@ -989,7 +980,7 @@ class WorkflowService:
workflow_uuid: str, workflow_uuid: str,
context: Optional[dict] = None, context: Optional[dict] = None,
variables: Optional[dict] = None, variables: Optional[dict] = None,
breakpoints: Optional[list[str]] = None breakpoints: Optional[list[str]] = None,
) -> str: ) -> str:
"""Start a debug execution and return execution ID""" """Start a debug execution and return execution ID"""
workflow = await self.get_workflow(workflow_uuid) workflow = await self.get_workflow(workflow_uuid)
@@ -1039,10 +1030,7 @@ class WorkflowService:
await self.ap.persistence_mgr.execute_async( await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_workflow.WorkflowExecution) sqlalchemy.update(persistence_workflow.WorkflowExecution)
.where(persistence_workflow.WorkflowExecution.uuid == execution_uuid) .where(persistence_workflow.WorkflowExecution.uuid == execution_uuid)
.values( .values(status='cancelled', end_time=datetime.now())
status='cancelled',
end_time=datetime.now()
)
) )
async def get_debug_state(self, workflow_uuid: str, execution_uuid: str) -> dict: async def get_debug_state(self, workflow_uuid: str, execution_uuid: str) -> dict:
@@ -1069,10 +1057,7 @@ class WorkflowService:
await self.ap.persistence_mgr.execute_async( await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_workflow.WorkflowExecution) sqlalchemy.update(persistence_workflow.WorkflowExecution)
.where(persistence_workflow.WorkflowExecution.uuid == execution_uuid) .where(persistence_workflow.WorkflowExecution.uuid == execution_uuid)
.values( .values(status='cancelled', end_time=datetime.now())
status='cancelled',
end_time=datetime.now()
)
) )
async def rerun_execution(self, workflow_uuid: str, execution_uuid: str) -> str: async def rerun_execution(self, workflow_uuid: str, execution_uuid: str) -> str:
@@ -1083,9 +1068,7 @@ class WorkflowService:
trigger_data = original_execution.get('trigger_data', {}) trigger_data = original_execution.get('trigger_data', {})
new_execution_uuid = await self.execute_workflow( new_execution_uuid = await self.execute_workflow(
workflow_uuid, workflow_uuid, trigger_type=original_execution.get('trigger_type', 'manual'), trigger_data=trigger_data
trigger_type=original_execution.get('trigger_type', 'manual'),
trigger_data=trigger_data
) )
return new_execution_uuid return new_execution_uuid
@@ -1120,11 +1103,7 @@ class WorkflowService:
return int(getattr(result, 'rowcount', 0) or 0) return int(getattr(result, 'rowcount', 0) or 0)
async def get_execution_logs( async def get_execution_logs(
self, self, workflow_uuid: str, execution_uuid: str, limit: int = 100, offset: int = 0
workflow_uuid: str,
execution_uuid: str,
limit: int = 100,
offset: int = 0
) -> dict: ) -> dict:
"""Get execution logs for a workflow execution""" """Get execution logs for a workflow execution"""
execution = await self.get_execution(execution_uuid) execution = await self.get_execution(execution_uuid)
@@ -1133,11 +1112,13 @@ class WorkflowService:
if execution.get('workflow_uuid') != workflow_uuid: if execution.get('workflow_uuid') != workflow_uuid:
raise ValueError(f'Execution {execution_uuid} not found in workflow {workflow_uuid}') raise ValueError(f'Execution {execution_uuid} not found in workflow {workflow_uuid}')
query = sqlalchemy.select(persistence_workflow.WorkflowNodeExecution).where( query = (
persistence_workflow.WorkflowNodeExecution.execution_uuid == execution_uuid sqlalchemy.select(persistence_workflow.WorkflowNodeExecution)
).order_by( .where(persistence_workflow.WorkflowNodeExecution.execution_uuid == execution_uuid)
persistence_workflow.WorkflowNodeExecution.id.asc() .order_by(persistence_workflow.WorkflowNodeExecution.id.asc())
).limit(limit).offset(offset) .limit(limit)
.offset(offset)
)
result = await self.ap.persistence_mgr.execute_async(query) result = await self.ap.persistence_mgr.execute_async(query)
node_executions = result.all() node_executions = result.all()
@@ -1147,11 +1128,9 @@ class WorkflowService:
serialized = self._serialize_node_execution(node_exec) serialized = self._serialize_node_execution(node_exec)
timestamp = serialized.get('completed_at') or serialized.get('started_at') or execution.get('started_at') timestamp = serialized.get('completed_at') or serialized.get('started_at') or execution.get('started_at')
level = 'error' if serialized.get('status') == 'failed' else 'info' level = 'error' if serialized.get('status') == 'failed' else 'info'
message = ( message = f'{serialized.get("node_type")}::{serialized.get("node_id")} - {serialized.get("status")}'
f"{serialized.get('node_type')}::{serialized.get('node_id')} - {serialized.get('status')}"
)
if serialized.get('error'): if serialized.get('error'):
message = f"{message} - {serialized.get('error')}" message = f'{message} - {serialized.get("error")}'
logs.append( logs.append(
{ {
'id': str(serialized.get('id', serialized.get('node_id'))), 'id': str(serialized.get('id', serialized.get('node_id'))),
+1 -3
View File
@@ -246,9 +246,7 @@ class Application:
try: try:
cancelled = await self.workflow_service.cleanup_stale_executions() cancelled = await self.workflow_service.cleanup_stale_executions()
if cancelled > 0: if cancelled > 0:
self.logger.info( self.logger.info(f'Workflow execution auto-cleanup: cancelled {cancelled} stale executions')
f'Workflow execution auto-cleanup: cancelled {cancelled} stale executions'
)
except Exception as e: except Exception as e:
self.logger.warning(f'Workflow execution auto-cleanup error: {e}') self.logger.warning(f'Workflow execution auto-cleanup error: {e}')
await asyncio.sleep(check_interval_seconds) await asyncio.sleep(check_interval_seconds)
@@ -1,4 +1,5 @@
"""Workflow persistence entities""" """Workflow persistence entities"""
import sqlalchemy import sqlalchemy
from .base import Base from .base import Base
@@ -53,9 +54,7 @@ class WorkflowVersion(Base):
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now()) created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
created_by = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) created_by = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
__table_args__ = ( __table_args__ = (sqlalchemy.UniqueConstraint('workflow_uuid', 'version', name='uq_workflow_version'),)
sqlalchemy.UniqueConstraint('workflow_uuid', 'version', name='uq_workflow_version'),
)
class WorkflowTrigger(Base): class WorkflowTrigger(Base):
@@ -1,4 +1,5 @@
"""Add workflow tables and update bot binding fields""" """Add workflow tables and update bot binding fields"""
import sqlalchemy import sqlalchemy
from .. import migration from .. import migration
@@ -9,7 +10,8 @@ class DBMigrateWorkflowTables(migration.DBMigration):
async def upgrade(self): async def upgrade(self):
# Create workflows table # 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 ( CREATE TABLE IF NOT EXISTS workflows (
uuid VARCHAR(255) PRIMARY KEY, uuid VARCHAR(255) PRIMARY KEY,
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
@@ -23,10 +25,12 @@ class DBMigrateWorkflowTables(migration.DBMigration):
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) )
""")) """)
)
# Create workflow_versions table # 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 ( CREATE TABLE IF NOT EXISTS workflow_versions (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
workflow_uuid VARCHAR(255) NOT NULL, workflow_uuid VARCHAR(255) NOT NULL,
@@ -37,10 +41,12 @@ class DBMigrateWorkflowTables(migration.DBMigration):
created_by VARCHAR(255), created_by VARCHAR(255),
UNIQUE(workflow_uuid, version) UNIQUE(workflow_uuid, version)
) )
""")) """)
)
# Create workflow_triggers table # 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 ( CREATE TABLE IF NOT EXISTS workflow_triggers (
uuid VARCHAR(255) PRIMARY KEY, uuid VARCHAR(255) PRIMARY KEY,
workflow_uuid VARCHAR(255) NOT NULL, workflow_uuid VARCHAR(255) NOT NULL,
@@ -51,10 +57,12 @@ class DBMigrateWorkflowTables(migration.DBMigration):
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) )
""")) """)
)
# Create workflow_executions table # 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 ( CREATE TABLE IF NOT EXISTS workflow_executions (
uuid VARCHAR(255) PRIMARY KEY, uuid VARCHAR(255) PRIMARY KEY,
workflow_uuid VARCHAR(255) NOT NULL, workflow_uuid VARCHAR(255) NOT NULL,
@@ -68,10 +76,12 @@ class DBMigrateWorkflowTables(migration.DBMigration):
error TEXT, error TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) )
""")) """)
)
# Create workflow_node_executions table # 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 ( CREATE TABLE IF NOT EXISTS workflow_node_executions (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
execution_uuid VARCHAR(255) NOT NULL, execution_uuid VARCHAR(255) NOT NULL,
@@ -85,10 +95,12 @@ class DBMigrateWorkflowTables(migration.DBMigration):
error TEXT, error TEXT,
retry_count INTEGER NOT NULL DEFAULT 0 retry_count INTEGER NOT NULL DEFAULT 0
) )
""")) """)
)
# Create workflow_scheduled_jobs table # 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 ( CREATE TABLE IF NOT EXISTS workflow_scheduled_jobs (
uuid VARCHAR(255) PRIMARY KEY, uuid VARCHAR(255) PRIMARY KEY,
trigger_uuid VARCHAR(255) NOT NULL, trigger_uuid VARCHAR(255) NOT NULL,
@@ -97,45 +109,50 @@ class DBMigrateWorkflowTables(migration.DBMigration):
last_run_time TIMESTAMP, last_run_time TIMESTAMP,
is_enabled BOOLEAN NOT NULL DEFAULT 1 is_enabled BOOLEAN NOT NULL DEFAULT 1
) )
""")) """)
)
# Create indexes # Create indexes
await self.ap.persistence_mgr.execute_async(sqlalchemy.text( await self.ap.persistence_mgr.execute_async(
"CREATE INDEX IF NOT EXISTS idx_workflow_versions_uuid ON workflow_versions(workflow_uuid)" 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( await self.ap.persistence_mgr.execute_async(
"CREATE INDEX IF NOT EXISTS idx_workflow_triggers_uuid ON workflow_triggers(workflow_uuid)" 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( await self.ap.persistence_mgr.execute_async(
"CREATE INDEX IF NOT EXISTS idx_workflow_executions_uuid ON workflow_executions(workflow_uuid)" 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(
await self.ap.persistence_mgr.execute_async(sqlalchemy.text( sqlalchemy.text(
"CREATE INDEX IF NOT EXISTS idx_workflow_scheduled_jobs_trigger ON workflow_scheduled_jobs(trigger_uuid)" '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) # 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) # Check if column exists first (SQLite doesn't support IF NOT EXISTS for columns)
try: try:
await self.ap.persistence_mgr.execute_async( await self.ap.persistence_mgr.execute_async(sqlalchemy.text('SELECT binding_type FROM bots LIMIT 1'))
sqlalchemy.text("SELECT binding_type FROM bots LIMIT 1")
)
except Exception: except Exception:
# Column doesn't exist, add it # Column doesn't exist, add it
await self.ap.persistence_mgr.execute_async(sqlalchemy.text( await self.ap.persistence_mgr.execute_async(
"ALTER TABLE bots ADD COLUMN binding_type VARCHAR(20) NOT NULL DEFAULT 'pipeline'" sqlalchemy.text("ALTER TABLE bots ADD COLUMN binding_type VARCHAR(20) NOT NULL DEFAULT 'pipeline'")
)) )
async def downgrade(self): async def downgrade(self):
# Drop tables in reverse order # 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_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_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_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_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 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 workflows'))
# Remove binding_type column from bots (SQLite doesn't support DROP COLUMN directly) # 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 # This would need a table recreation in SQLite, so we'll skip it in downgrade
@@ -1,4 +1,5 @@
"""Add binding_uuid field to bots table and migrate data""" """Add binding_uuid field to bots table and migrate data"""
import sqlalchemy import sqlalchemy
from .. import migration from .. import migration
@@ -11,33 +12,35 @@ class DBMigrateBotBindingFields(migration.DBMigration):
# Add binding_uuid column to bots table # Add binding_uuid column to bots table
# Check if column exists first (SQLite doesn't support IF NOT EXISTS for columns) # Check if column exists first (SQLite doesn't support IF NOT EXISTS for columns)
try: try:
await self.ap.persistence_mgr.execute_async( await self.ap.persistence_mgr.execute_async(sqlalchemy.text('SELECT binding_uuid FROM bots LIMIT 1'))
sqlalchemy.text("SELECT binding_uuid FROM bots LIMIT 1")
)
except Exception: except Exception:
# Column doesn't exist, add it # Column doesn't exist, add it
await self.ap.persistence_mgr.execute_async(sqlalchemy.text( await self.ap.persistence_mgr.execute_async(
"ALTER TABLE bots ADD COLUMN binding_uuid VARCHAR(64)" sqlalchemy.text('ALTER TABLE bots ADD COLUMN binding_uuid VARCHAR(64)')
)) )
# Migrate existing data: copy use_pipeline_uuid to binding_uuid for records # Migrate existing data: copy use_pipeline_uuid to binding_uuid for records
# that have a pipeline bound and binding_uuid is not set yet # 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 UPDATE bots
SET binding_uuid = use_pipeline_uuid SET binding_uuid = use_pipeline_uuid
WHERE use_pipeline_uuid IS NOT NULL WHERE use_pipeline_uuid IS NOT NULL
AND use_pipeline_uuid != '' AND use_pipeline_uuid != ''
AND (binding_uuid IS NULL OR binding_uuid = '') AND (binding_uuid IS NULL OR binding_uuid = '')
""")) """)
)
# Ensure binding_type is 'pipeline' for records that were migrated # 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 UPDATE bots
SET binding_type = 'pipeline' SET binding_type = 'pipeline'
WHERE binding_uuid IS NOT NULL WHERE binding_uuid IS NOT NULL
AND binding_uuid != '' AND binding_uuid != ''
AND (binding_type IS NULL OR binding_type = '') AND (binding_type IS NULL OR binding_type = '')
""")) """)
)
async def downgrade(self): async def downgrade(self):
# SQLite doesn't support DROP COLUMN directly # SQLite doesn't support DROP COLUMN directly
+53 -46
View File
@@ -1,4 +1,5 @@
"""Workflow entities and data models""" """Workflow entities and data models"""
from __future__ import annotations from __future__ import annotations
import enum import enum
@@ -9,45 +10,50 @@ import pydantic
class Position(pydantic.BaseModel): class Position(pydantic.BaseModel):
"""Node position on canvas""" """Node position on canvas"""
x: float = 0 x: float = 0
y: float = 0 y: float = 0
class PortDefinition(pydantic.BaseModel): class PortDefinition(pydantic.BaseModel):
"""Node port definition""" """Node port definition"""
name: str name: str
type: str = "any" # any, string, number, boolean, object, array type: str = 'any' # any, string, number, boolean, object, array
description: str = "" description: str = ''
required: bool = True required: bool = True
class NodeDefinition(pydantic.BaseModel): class NodeDefinition(pydantic.BaseModel):
"""Workflow node definition""" """Workflow node definition"""
id: str id: str
type: str type: str
name: str = "" name: str = ''
position: Position = Position() position: Position = Position()
config: dict[str, Any] = {} config: dict[str, Any] = {}
inputs: list[PortDefinition] = [] inputs: list[PortDefinition] = []
outputs: list[PortDefinition] = [] outputs: list[PortDefinition] = []
# UI metadata # UI metadata
description: str = "" description: str = ''
comment: str = "" # User comment/annotation comment: str = '' # User comment/annotation
class EdgeDefinition(pydantic.BaseModel): class EdgeDefinition(pydantic.BaseModel):
"""Workflow edge definition (connection between nodes)""" """Workflow edge definition (connection between nodes)"""
id: str id: str
source_node: str source_node: str
source_port: str = "output" source_port: str = 'output'
target_node: str target_node: str
target_port: str = "input" target_port: str = 'input'
condition: Optional[str] = None # Optional condition expression condition: Optional[str] = None # Optional condition expression
class TriggerDefinition(pydantic.BaseModel): class TriggerDefinition(pydantic.BaseModel):
"""Workflow trigger definition""" """Workflow trigger definition"""
id: str id: str
type: str # message, cron, event, webhook type: str # message, cron, event, webhook
config: dict[str, Any] = {} config: dict[str, Any] = {}
@@ -56,16 +62,17 @@ class TriggerDefinition(pydantic.BaseModel):
class WorkflowSettings(pydantic.BaseModel): class WorkflowSettings(pydantic.BaseModel):
"""Workflow settings""" """Workflow settings"""
# Execution settings # Execution settings
max_execution_time: int = 300 # seconds max_execution_time: int = 300 # seconds
max_retries: int = 3 max_retries: int = 3
retry_delay: int = 5 # seconds retry_delay: int = 5 # seconds
# Error handling # Error handling
error_handling: str = "stop" # stop, continue, retry error_handling: str = 'stop' # stop, continue, retry
# Logging # Logging
log_level: str = "info" log_level: str = 'info'
save_execution_history: bool = True save_execution_history: bool = True
# Concurrency # Concurrency
@@ -74,41 +81,33 @@ class WorkflowSettings(pydantic.BaseModel):
class SafetyConfig(pydantic.BaseModel): class SafetyConfig(pydantic.BaseModel):
"""Safety configuration (inherited from Pipeline)""" """Safety configuration (inherited from Pipeline)"""
content_filter: dict[str, Any] = {
"enable": False, content_filter: dict[str, Any] = {'enable': False, 'sensitive_words': [], 'replace_with': '***'}
"sensitive_words": [], rate_limit: dict[str, Any] = {'enable': False, 'requests_per_minute': 60, 'burst_limit': 10}
"replace_with": "***"
}
rate_limit: dict[str, Any] = {
"enable": False,
"requests_per_minute": 60,
"burst_limit": 10
}
class OutputConfig(pydantic.BaseModel): class OutputConfig(pydantic.BaseModel):
"""Output configuration (inherited from Pipeline)""" """Output configuration (inherited from Pipeline)"""
long_text_processing: dict[str, Any] = { long_text_processing: dict[str, Any] = {
"strategy": "split", # split, truncate, file 'strategy': 'split', # split, truncate, file
"max_length": 4000, 'max_length': 4000,
"split_separator": "\n\n" 'split_separator': '\n\n',
}
force_delay: dict[str, Any] = {
"enable": False,
"min_delay_ms": 0,
"max_delay_ms": 0
} }
force_delay: dict[str, Any] = {'enable': False, 'min_delay_ms': 0, 'max_delay_ms': 0}
misc: dict[str, Any] = {} misc: dict[str, Any] = {}
class WorkflowGlobalConfig(pydantic.BaseModel): class WorkflowGlobalConfig(pydantic.BaseModel):
"""Workflow global configuration (inherited from Pipeline capabilities)""" """Workflow global configuration (inherited from Pipeline capabilities)"""
safety: SafetyConfig = SafetyConfig() safety: SafetyConfig = SafetyConfig()
output: OutputConfig = OutputConfig() output: OutputConfig = OutputConfig()
class ExtensionsPreferences(pydantic.BaseModel): class ExtensionsPreferences(pydantic.BaseModel):
"""Extensions preferences (same as Pipeline)""" """Extensions preferences (same as Pipeline)"""
enable_all_plugins: bool = True enable_all_plugins: bool = True
enable_all_mcp_servers: bool = True enable_all_mcp_servers: bool = True
plugins: list[str] = [] plugins: list[str] = []
@@ -117,19 +116,21 @@ class ExtensionsPreferences(pydantic.BaseModel):
class ConversationVariable(pydantic.BaseModel): class ConversationVariable(pydantic.BaseModel):
"""Conversation-level variable definition""" """Conversation-level variable definition"""
name: str name: str
type: str = "string" # string, number, boolean, object, array type: str = 'string' # string, number, boolean, object, array
description: str = "" description: str = ''
default_value: Any = None default_value: Any = None
max_length: Optional[int] = None # For strings max_length: Optional[int] = None # For strings
class WorkflowDefinition(pydantic.BaseModel): class WorkflowDefinition(pydantic.BaseModel):
"""Complete workflow definition""" """Complete workflow definition"""
uuid: str uuid: str
name: str name: str
description: str = "" description: str = ''
emoji: str = "🔄" emoji: str = '🔄'
version: int = 1 version: int = 1
# Workflow graph # Workflow graph
@@ -164,25 +165,28 @@ class WorkflowDefinition(pydantic.BaseModel):
class ExecutionStatus(enum.Enum): class ExecutionStatus(enum.Enum):
"""Workflow execution status""" """Workflow execution status"""
PENDING = "pending"
RUNNING = "running" PENDING = 'pending'
WAITING = "waiting" RUNNING = 'running'
COMPLETED = "completed" WAITING = 'waiting'
FAILED = "failed" COMPLETED = 'completed'
CANCELLED = "cancelled" FAILED = 'failed'
CANCELLED = 'cancelled'
class NodeStatus(enum.Enum): class NodeStatus(enum.Enum):
"""Node execution status""" """Node execution status"""
PENDING = "pending"
RUNNING = "running" PENDING = 'pending'
COMPLETED = "completed" RUNNING = 'running'
FAILED = "failed" COMPLETED = 'completed'
SKIPPED = "skipped" FAILED = 'failed'
SKIPPED = 'skipped'
class NodeState(pydantic.BaseModel): class NodeState(pydantic.BaseModel):
"""Runtime state of a node during execution""" """Runtime state of a node during execution"""
node_id: str node_id: str
status: NodeStatus = NodeStatus.PENDING status: NodeStatus = NodeStatus.PENDING
inputs: dict[str, Any] = {} inputs: dict[str, Any] = {}
@@ -195,12 +199,13 @@ class NodeState(pydantic.BaseModel):
class MessageContext(pydantic.BaseModel): class MessageContext(pydantic.BaseModel):
"""Message context for message-triggered workflows""" """Message context for message-triggered workflows"""
message_id: str message_id: str
message_content: str message_content: str
sender_id: str sender_id: str
sender_name: str = "" sender_name: str = ''
platform: str = "" platform: str = ''
conversation_id: str = "" conversation_id: str = ''
is_group: bool = False is_group: bool = False
group_id: Optional[str] = None group_id: Optional[str] = None
mentions: list[str] = [] mentions: list[str] = []
@@ -210,6 +215,7 @@ class MessageContext(pydantic.BaseModel):
class ExecutionStep(pydantic.BaseModel): class ExecutionStep(pydantic.BaseModel):
"""Execution history step""" """Execution history step"""
timestamp: datetime timestamp: datetime
node_id: str node_id: str
node_type: str node_type: str
@@ -222,6 +228,7 @@ class ExecutionStep(pydantic.BaseModel):
class ExecutionContext(pydantic.BaseModel): class ExecutionContext(pydantic.BaseModel):
"""Workflow execution context""" """Workflow execution context"""
execution_id: str execution_id: str
workflow_id: str workflow_id: str
workflow_version: int = 1 workflow_version: int = 1
@@ -255,7 +262,7 @@ class ExecutionContext(pydantic.BaseModel):
user_id: Optional[str] = None user_id: Optional[str] = None
bot_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""" """Get output from a specific node"""
if node_id in self.node_states: if node_id in self.node_states:
return self.node_states[node_id].outputs.get(output_name) return self.node_states[node_id].outputs.get(output_name)
+65 -158
View File
@@ -1,4 +1,5 @@
"""Workflow execution engine""" """Workflow execution engine"""
from __future__ import annotations from __future__ import annotations
import ast import ast
@@ -34,13 +35,7 @@ logger = logging.getLogger(__name__)
class ExecutionLog: class ExecutionLog:
"""Execution log entry""" """Execution log entry"""
def __init__( def __init__(self, level: str, message: str, node_id: Optional[str] = None, data: Optional[dict] = None):
self,
level: str,
message: str,
node_id: Optional[str] = None,
data: Optional[dict] = None
):
self.id = str(uuid.uuid4()) self.id = str(uuid.uuid4())
self.timestamp = datetime.now().isoformat() self.timestamp = datetime.now().isoformat()
self.level = level self.level = level
@@ -82,8 +77,8 @@ class DebugExecutionState:
self.pending_logs.append(log) self.pending_logs.append(log)
logger.log( logger.log(
getattr(logging, level.upper(), logging.INFO), getattr(logging, level.upper(), logging.INFO),
f"[Workflow Debug] {message}", f'[Workflow Debug] {message}',
extra={'node_id': node_id, 'data': data} extra={'node_id': node_id, 'data': data},
) )
def get_pending_logs(self) -> list[dict]: def get_pending_logs(self) -> list[dict]:
@@ -174,14 +169,14 @@ def _eval_node(node: ast.AST) -> Any:
if isinstance(node, ast.UnaryOp): if isinstance(node, ast.UnaryOp):
op_fn = _SAFE_OPS.get(type(node.op)) op_fn = _SAFE_OPS.get(type(node.op))
if op_fn is None: 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)) return op_fn(_eval_node(node.operand))
# Binary operators: x + y, x * y, etc. # Binary operators: x + y, x * y, etc.
if isinstance(node, ast.BinOp): if isinstance(node, ast.BinOp):
op_fn = _SAFE_OPS.get(type(node.op)) op_fn = _SAFE_OPS.get(type(node.op))
if op_fn is None: 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)) return op_fn(_eval_node(node.left), _eval_node(node.right))
# Comparisons: x == y, x > y, x in y, etc. (chained) # 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): for op, comparator in zip(node.ops, node.comparators):
op_fn = _SAFE_OPS.get(type(op)) op_fn = _SAFE_OPS.get(type(op))
if op_fn is None: 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) right = _eval_node(comparator)
if not op_fn(left, right): if not op_fn(left, right):
return False return False
@@ -220,9 +215,9 @@ def _eval_node(node: ast.AST) -> Any:
return True return True
if node.id == 'False': if node.id == 'False':
return 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: class WorkflowExecutor:
@@ -237,10 +232,7 @@ class WorkflowExecutor:
self._edges: list[EdgeDefinition] = [] self._edges: list[EdgeDefinition] = []
async def execute( async def execute(
self, self, workflow: WorkflowDefinition, context: ExecutionContext, start_node_id: Optional[str] = None
workflow: WorkflowDefinition,
context: ExecutionContext,
start_node_id: Optional[str] = None
) -> ExecutionContext: ) -> ExecutionContext:
""" """
Execute a workflow. Execute a workflow.
@@ -274,33 +266,24 @@ class WorkflowExecutor:
start_nodes = self._find_start_nodes(workflow.nodes, workflow.edges) start_nodes = self._find_start_nodes(workflow.nodes, workflow.edges)
if not start_nodes: if not start_nodes:
raise ValueError("No start nodes found in workflow") raise ValueError('No start nodes found in workflow')
# Execute from start nodes # Execute from start nodes
for start_node in start_nodes: for start_node in start_nodes:
await self._execute_from_node( await self._execute_from_node(
start_node, start_node, node_map, edge_map, context, workflow.settings.max_retries, path=set()
node_map,
edge_map,
context,
workflow.settings.max_retries,
path=set()
) )
# Check final status # Check final status
all_completed = all( all_completed = all(
state.status in (NodeStatus.COMPLETED, NodeStatus.SKIPPED) state.status in (NodeStatus.COMPLETED, NodeStatus.SKIPPED) for state in context.node_states.values()
for state in context.node_states.values()
) )
if all_completed: if all_completed:
context.status = ExecutionStatus.COMPLETED context.status = ExecutionStatus.COMPLETED
else: else:
# Some nodes might still be waiting # Some nodes might still be waiting
has_failed = any( has_failed = any(state.status == NodeStatus.FAILED for state in context.node_states.values())
state.status == NodeStatus.FAILED
for state in context.node_states.values()
)
if has_failed: if has_failed:
context.status = ExecutionStatus.FAILED context.status = ExecutionStatus.FAILED
@@ -308,7 +291,7 @@ class WorkflowExecutor:
context.status = ExecutionStatus.FAILED context.status = ExecutionStatus.FAILED
context.error = str(e) context.error = str(e)
logger.error( logger.error(
"Workflow execution failed", 'Workflow execution failed',
exc_info=True, exc_info=True,
extra={ extra={
'workflow_id': workflow.uuid, 'workflow_id': workflow.uuid,
@@ -335,7 +318,7 @@ class WorkflowExecutor:
edge_map: dict[str, list[EdgeDefinition]], edge_map: dict[str, list[EdgeDefinition]],
context: ExecutionContext, context: ExecutionContext,
max_retries: int = 3, max_retries: int = 3,
path: set[str] | None = None path: set[str] | None = None,
): ):
"""Execute workflow starting from a specific node""" """Execute workflow starting from a specific node"""
@@ -346,9 +329,9 @@ class WorkflowExecutor:
# Check for circular dependency on the *current path* only # Check for circular dependency on the *current path* only
# This correctly allows diamond shapes (A→B, A→C, B→D, C→D) # This correctly allows diamond shapes (A→B, A→C, B→D, C→D)
if node.id in path: 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].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() context.node_states[node.id].end_time = datetime.now()
await self._persist_node_execution(node, context.node_states[node.id], context) await self._persist_node_execution(node, context.node_states[node.id], context)
return return
@@ -445,24 +428,12 @@ class WorkflowExecutor:
# Check if all inputs are ready # Check if all inputs are ready
if await self._inputs_ready(target_node, edge_map, context): if await self._inputs_ready(target_node, edge_map, context):
await self._execute_from_node( await self._execute_from_node(target_node, node_map, edge_map, context, max_retries, path)
target_node,
node_map,
edge_map,
context,
max_retries,
path
)
# Remove node from path when backtracking (allows diamond revisit) # Remove node from path when backtracking (allows diamond revisit)
path.discard(node.id) path.discard(node.id)
async def _execute_node( async def _execute_node(self, node: NodeDefinition, context: ExecutionContext, max_retries: int = 3):
self,
node: NodeDefinition,
context: ExecutionContext,
max_retries: int = 3
):
"""Execute a single node with retry logic""" """Execute a single node with retry logic"""
node_state = context.node_states[node.id] node_state = context.node_states[node.id]
@@ -474,7 +445,7 @@ class WorkflowExecutor:
if not node_instance: if not node_instance:
node_state.status = NodeStatus.FAILED 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() node_state.end_time = datetime.now()
self._record_execution_step(node, node_state, context) self._record_execution_step(node, node_state, context)
await self._persist_node_execution(node, node_state, context) await self._persist_node_execution(node, node_state, context)
@@ -488,7 +459,7 @@ class WorkflowExecutor:
validation_errors = await node_instance.validate_inputs(inputs) validation_errors = await node_instance.validate_inputs(inputs)
if validation_errors: if validation_errors:
node_state.status = NodeStatus.FAILED node_state.status = NodeStatus.FAILED
node_state.error = "; ".join(validation_errors) node_state.error = '; '.join(validation_errors)
node_state.end_time = datetime.now() node_state.end_time = datetime.now()
self._record_execution_step(node, node_state, context) self._record_execution_step(node, node_state, context)
await self._persist_node_execution(node, node_state, context) await self._persist_node_execution(node, node_state, context)
@@ -505,7 +476,7 @@ class WorkflowExecutor:
except Exception as e: except Exception as e:
node_state.retry_count = attempt + 1 node_state.retry_count = attempt + 1
logger.error( 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, exc_info=True,
extra={ extra={
'node_id': node.id, 'node_id': node.id,
@@ -523,7 +494,7 @@ class WorkflowExecutor:
node_state.error = str(e) node_state.error = str(e)
node_state.end_time = datetime.now() node_state.end_time = datetime.now()
logger.error( 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={ extra={
'node_id': node.id, 'node_id': node.id,
'node_type': node.type, 'node_type': node.type,
@@ -535,11 +506,7 @@ class WorkflowExecutor:
self._record_execution_step(node, node_state, context) self._record_execution_step(node, node_state, context)
await self._persist_node_execution(node, node_state, context) await self._persist_node_execution(node, node_state, context)
async def _resolve_inputs( async def _resolve_inputs(self, node: NodeDefinition, context: ExecutionContext) -> dict[str, Any]:
self,
node: NodeDefinition,
context: ExecutionContext
) -> dict[str, Any]:
"""Resolve input values for a node from connected nodes and context""" """Resolve input values for a node from connected nodes and context"""
inputs = {} inputs = {}
@@ -629,6 +596,7 @@ class WorkflowExecutor:
# Resolve variable references in condition # Resolve variable references in condition
if '{{' in condition: if '{{' in condition:
import re import re
pattern = r'\{\{([^}]+)\}\}' pattern = r'\{\{([^}]+)\}\}'
# First pass: replace all variable references with placeholders # First pass: replace all variable references with placeholders
@@ -649,17 +617,11 @@ class WorkflowExecutor:
for placeholder, var_expr in placeholders.items(): for placeholder, var_expr in placeholders.items():
value = await self._resolve_expression(var_expr, context) value = await self._resolve_expression(var_expr, context)
if isinstance(value, str): if isinstance(value, str):
condition_with_placeholders = condition_with_placeholders.replace( condition_with_placeholders = condition_with_placeholders.replace(placeholder, f'"{value}"')
placeholder, f'"{value}"'
)
elif value is None: elif value is None:
condition_with_placeholders = condition_with_placeholders.replace( condition_with_placeholders = condition_with_placeholders.replace(placeholder, 'None')
placeholder, 'None'
)
else: else:
condition_with_placeholders = condition_with_placeholders.replace( condition_with_placeholders = condition_with_placeholders.replace(placeholder, str(value))
placeholder, str(value)
)
condition = condition_with_placeholders condition = condition_with_placeholders
@@ -668,7 +630,7 @@ class WorkflowExecutor:
return bool(result) return bool(result)
except Exception as e: except Exception as e:
logger.warning(f"Condition evaluation failed: {condition} - {e}") logger.warning(f'Condition evaluation failed: {condition} - {e}')
return False return False
async def _should_skip_node(self, node: NodeDefinition, context: ExecutionContext) -> bool: async def _should_skip_node(self, node: NodeDefinition, context: ExecutionContext) -> bool:
@@ -679,10 +641,7 @@ class WorkflowExecutor:
return False return False
async def _inputs_ready( async def _inputs_ready(
self, self, node: NodeDefinition, edge_map: dict[str, list[EdgeDefinition]], context: ExecutionContext
node: NodeDefinition,
edge_map: dict[str, list[EdgeDefinition]],
context: ExecutionContext
) -> bool: ) -> bool:
"""Check if all inputs for a node are ready""" """Check if all inputs for a node are ready"""
# Find all edges that connect to this node # Find all edges that connect to this node
@@ -700,11 +659,7 @@ class WorkflowExecutor:
return True return True
def _find_start_nodes( def _find_start_nodes(self, nodes: list[NodeDefinition], edges: list[EdgeDefinition]) -> list[NodeDefinition]:
self,
nodes: list[NodeDefinition],
edges: list[EdgeDefinition]
) -> list[NodeDefinition]:
"""Find nodes that have no incoming edges (start nodes)""" """Find nodes that have no incoming edges (start nodes)"""
target_nodes = {edge.target_node for edge in edges} target_nodes = {edge.target_node for edge in edges}
start_nodes = [node for node in nodes if node.id not in target_nodes] start_nodes = [node for node in nodes if node.id not in target_nodes]
@@ -726,12 +681,7 @@ class WorkflowExecutor:
edge_map[edge.source_node].append(edge) edge_map[edge.source_node].append(edge)
return edge_map return edge_map
def _record_execution_step( def _record_execution_step(self, node: NodeDefinition, node_state: NodeState, context: ExecutionContext):
self,
node: NodeDefinition,
node_state: NodeState,
context: ExecutionContext
):
"""Record an execution step in the history""" """Record an execution step in the history"""
duration_ms = 0 duration_ms = 0
if node_state.start_time and node_state.end_time: if node_state.start_time and node_state.end_time:
@@ -745,7 +695,7 @@ class WorkflowExecutor:
inputs=node_state.inputs, inputs=node_state.inputs,
outputs=node_state.outputs, outputs=node_state.outputs,
duration_ms=duration_ms, duration_ms=duration_ms,
error=node_state.error error=node_state.error,
) )
context.history.append(step) context.history.append(step)
@@ -798,9 +748,7 @@ class ParallelExecutor:
self.executor = executor self.executor = executor
async def execute_parallel( async def execute_parallel(
self, self, branches: list[list[NodeDefinition]], context: ExecutionContext
branches: list[list[NodeDefinition]],
context: ExecutionContext
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
""" """
Execute multiple branches in parallel. Execute multiple branches in parallel.
@@ -828,11 +776,7 @@ class ParallelExecutor:
return processed_results return processed_results
async def _execute_branch( async def _execute_branch(self, nodes: list[NodeDefinition], context: ExecutionContext) -> dict[str, Any]:
self,
nodes: list[NodeDefinition],
context: ExecutionContext
) -> dict[str, Any]:
"""Execute a single branch""" """Execute a single branch"""
# Create a copy of context for this branch # Create a copy of context for this branch
branch_outputs = {} branch_outputs = {}
@@ -856,11 +800,7 @@ class LoopExecutor:
self.executor = executor self.executor = executor
async def execute_loop( async def execute_loop(
self, self, items: list[Any], loop_body: list[NodeDefinition], context: ExecutionContext, max_iterations: int = 100
items: list[Any],
loop_body: list[NodeDefinition],
context: ExecutionContext,
max_iterations: int = 100
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
""" """
Execute a loop over items. Execute a loop over items.
@@ -976,7 +916,7 @@ class DebugWorkflowExecutor(WorkflowExecutor):
start_nodes = self._find_start_nodes(workflow.nodes, workflow.edges) start_nodes = self._find_start_nodes(workflow.nodes, workflow.edges)
if not start_nodes: 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)') debug_state.add_log('info', f'Found {len(start_nodes)} start node(s)')
@@ -986,12 +926,7 @@ class DebugWorkflowExecutor(WorkflowExecutor):
break break
await self._execute_debug_from_node( await self._execute_debug_from_node(
start_node, start_node, node_map, edge_map, context, debug_state, workflow.settings.max_retries
node_map,
edge_map,
context,
debug_state,
workflow.settings.max_retries
) )
# Set final status # Set final status
@@ -1000,8 +935,7 @@ class DebugWorkflowExecutor(WorkflowExecutor):
debug_state.status = 'cancelled' debug_state.status = 'cancelled'
else: else:
all_completed = all( all_completed = all(
state.status in (NodeStatus.COMPLETED, NodeStatus.SKIPPED) state.status in (NodeStatus.COMPLETED, NodeStatus.SKIPPED) for state in context.node_states.values()
for state in context.node_states.values()
) )
if all_completed: if all_completed:
@@ -1009,10 +943,7 @@ class DebugWorkflowExecutor(WorkflowExecutor):
debug_state.status = 'completed' debug_state.status = 'completed'
debug_state.add_log('info', 'Workflow execution completed successfully') debug_state.add_log('info', 'Workflow execution completed successfully')
else: else:
has_failed = any( has_failed = any(state.status == NodeStatus.FAILED for state in context.node_states.values())
state.status == NodeStatus.FAILED
for state in context.node_states.values()
)
if has_failed: if has_failed:
context.status = ExecutionStatus.FAILED context.status = ExecutionStatus.FAILED
debug_state.status = 'error' debug_state.status = 'error'
@@ -1022,7 +953,7 @@ class DebugWorkflowExecutor(WorkflowExecutor):
context.error = str(e) context.error = str(e)
debug_state.status = 'error' debug_state.status = 'error'
debug_state.add_log('error', f'Workflow execution failed: {e}', data={'traceback': traceback.format_exc()}) 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: finally:
context.end_time = datetime.now() context.end_time = datetime.now()
@@ -1036,7 +967,7 @@ class DebugWorkflowExecutor(WorkflowExecutor):
edge_map: dict[str, list[EdgeDefinition]], edge_map: dict[str, list[EdgeDefinition]],
context: ExecutionContext, context: ExecutionContext,
debug_state: DebugExecutionState, debug_state: DebugExecutionState,
max_retries: int = 3 max_retries: int = 3,
): ):
"""Execute workflow from a node with debug support""" """Execute workflow from a node with debug support"""
@@ -1088,30 +1019,15 @@ class DebugWorkflowExecutor(WorkflowExecutor):
if edge.condition: if edge.condition:
condition_met = await self._evaluate_condition(edge.condition, context) condition_met = await self._evaluate_condition(edge.condition, context)
if not condition_met: if not condition_met:
debug_state.add_log( debug_state.add_log('debug', f'Edge condition not met: {edge.condition}', node_id=node.id)
'debug',
f'Edge condition not met: {edge.condition}',
node_id=node.id
)
continue continue
# Check if all inputs are ready # Check if all inputs are ready
if await self._inputs_ready(target_node, edge_map, context): if await self._inputs_ready(target_node, edge_map, context):
await self._execute_debug_from_node( await self._execute_debug_from_node(target_node, node_map, edge_map, context, debug_state, max_retries)
target_node,
node_map,
edge_map,
context,
debug_state,
max_retries
)
async def _execute_debug_node( async def _execute_debug_node(
self, self, node: NodeDefinition, context: ExecutionContext, debug_state: DebugExecutionState, max_retries: int = 3
node: NodeDefinition,
context: ExecutionContext,
debug_state: DebugExecutionState,
max_retries: int = 3
): ):
"""Execute a single node with debug logging""" """Execute a single node with debug logging"""
@@ -1124,7 +1040,7 @@ class DebugWorkflowExecutor(WorkflowExecutor):
if not node_instance: if not node_instance:
node_state.status = NodeStatus.FAILED 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() node_state.end_time = datetime.now()
debug_state.add_log('error', f'Unknown node type: {node.type}', node_id=node.id) debug_state.add_log('error', f'Unknown node type: {node.type}', node_id=node.id)
self._record_execution_step(node, node_state, context) self._record_execution_step(node, node_state, context)
@@ -1135,23 +1051,16 @@ class DebugWorkflowExecutor(WorkflowExecutor):
inputs = await self._resolve_inputs(node, context) inputs = await self._resolve_inputs(node, context)
node_state.inputs = inputs node_state.inputs = inputs
debug_state.add_log( debug_state.add_log(
'debug', 'debug', 'Node inputs resolved', node_id=node.id, data={'inputs': self._safe_serialize(inputs)}
'Node inputs resolved',
node_id=node.id,
data={'inputs': self._safe_serialize(inputs)}
) )
# Validate inputs # Validate inputs
validation_errors = await node_instance.validate_inputs(inputs) validation_errors = await node_instance.validate_inputs(inputs)
if validation_errors: if validation_errors:
node_state.status = NodeStatus.FAILED node_state.status = NodeStatus.FAILED
node_state.error = "; ".join(validation_errors) node_state.error = '; '.join(validation_errors)
node_state.end_time = datetime.now() node_state.end_time = datetime.now()
debug_state.add_log( debug_state.add_log('error', f'Input validation failed: {node_state.error}', node_id=node.id)
'error',
f'Input validation failed: {node_state.error}',
node_id=node.id
)
self._record_execution_step(node, node_state, context) self._record_execution_step(node, node_state, context)
await self._persist_node_execution(node, node_state, context) await self._persist_node_execution(node, node_state, context)
return return
@@ -1160,7 +1069,7 @@ class DebugWorkflowExecutor(WorkflowExecutor):
for attempt in range(max_retries + 1): for attempt in range(max_retries + 1):
if debug_state.is_stopped: if debug_state.is_stopped:
node_state.status = NodeStatus.FAILED node_state.status = NodeStatus.FAILED
node_state.error = "Execution stopped" node_state.error = 'Execution stopped'
node_state.end_time = datetime.now() node_state.end_time = datetime.now()
break break
@@ -1175,16 +1084,14 @@ class DebugWorkflowExecutor(WorkflowExecutor):
'info', 'info',
f'Node completed in {duration_ms}ms', f'Node completed in {duration_ms}ms',
node_id=node.id, 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 break
except Exception as e: except Exception as e:
node_state.retry_count = attempt + 1 node_state.retry_count = attempt + 1
debug_state.add_log( debug_state.add_log(
'warning', 'warning', f'Node execution failed (attempt {attempt + 1}/{max_retries + 1}): {e}', node_id=node.id
f'Node execution failed (attempt {attempt + 1}/{max_retries + 1}): {e}',
node_id=node.id
) )
if attempt < max_retries: if attempt < max_retries:
@@ -1197,7 +1104,7 @@ class DebugWorkflowExecutor(WorkflowExecutor):
'error', 'error',
f'Node failed after {max_retries + 1} attempts: {e}', f'Node failed after {max_retries + 1} attempts: {e}',
node_id=node.id, 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) self._record_execution_step(node, node_state, context)
@@ -1250,9 +1157,7 @@ class DebugWorkflowExecutor(WorkflowExecutor):
} }
def _find_next_executable_node( def _find_next_executable_node(
self, self, workflow: WorkflowDefinition, context: ExecutionContext
workflow: WorkflowDefinition,
context: ExecutionContext
) -> Optional[NodeDefinition]: ) -> Optional[NodeDefinition]:
"""Find the next node that can be executed""" """Find the next node that can be executed"""
edge_map = self._build_edge_map(workflow.edges) edge_map = self._build_edge_map(workflow.edges)
@@ -1261,7 +1166,12 @@ class DebugWorkflowExecutor(WorkflowExecutor):
state = context.node_states.get(node.id) state = context.node_states.get(node.id)
# Skip completed, running, or failed nodes # 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 continue
# Check if this node's inputs are ready # Check if this node's inputs are ready
@@ -1305,13 +1215,9 @@ class DebugWorkflowExecutor(WorkflowExecutor):
try: try:
return str(data)[:1000] # Limit string length return str(data)[:1000] # Limit string length
except Exception: except Exception:
return "<non-serializable>" return '<non-serializable>'
def get_execution_state( def get_execution_state(self, context: ExecutionContext, debug_state: DebugExecutionState) -> dict:
self,
context: ExecutionContext,
debug_state: DebugExecutionState
) -> dict:
"""Get current execution state for API response""" """Get current execution state for API response"""
node_states = {} node_states = {}
for node_id, state in context.node_states.items(): for node_id, state in context.node_states.items():
@@ -1323,7 +1229,8 @@ class DebugWorkflowExecutor(WorkflowExecutor):
'startTime': state.start_time.isoformat() if state.start_time else None, 'startTime': state.start_time.isoformat() if state.start_time else None,
'endTime': state.end_time.isoformat() if state.end_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) '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 { return {
+26 -25
View File
@@ -1,4 +1,5 @@
"""Workflow node base class and decorators""" """Workflow node base class and decorators"""
from __future__ import annotations from __future__ import annotations
import abc import abc
@@ -13,19 +14,21 @@ if TYPE_CHECKING:
class NodePort(pydantic.BaseModel): class NodePort(pydantic.BaseModel):
"""Node port definition""" """Node port definition"""
name: str name: str
type: str = "any" # any, string, number, boolean, object, array type: str = 'any' # any, string, number, boolean, object, array
description: str = "" description: str = ''
required: bool = True required: bool = True
class NodeConfig(pydantic.BaseModel): class NodeConfig(pydantic.BaseModel):
"""Node configuration field definition""" """Node configuration field definition"""
name: str name: str
type: str # string, integer, number, boolean, select, json, secret, etc. type: str # string, integer, number, boolean, select, json, secret, etc.
required: bool = False required: bool = False
default: Any = None default: Any = None
description: str = "" description: str = ''
options: Optional[list[str]] = None # For select type options: Optional[list[str]] = None # For select type
# Validation # Validation
@@ -36,7 +39,7 @@ class NodeConfig(pydantic.BaseModel):
pattern: Optional[str] = None # Regex pattern pattern: Optional[str] = None # Regex pattern
# UI hints # UI hints
placeholder: str = "" placeholder: str = ''
show_if: Optional[dict] = None # Conditional display show_if: Optional[dict] = None # Conditional display
# Pipeline config source (for reusing Pipeline config metadata) # Pipeline config source (for reusing Pipeline config metadata)
@@ -52,11 +55,11 @@ class WorkflowNode(abc.ABC):
"""Base class for all workflow nodes""" """Base class for all workflow nodes"""
# Node metadata # Node metadata
type_name: str = "" type_name: str = ''
name: str = "" name: str = ''
description: str = "" description: str = ''
category: str = "misc" # trigger, process, control, action, integration category: str = 'misc' # trigger, process, control, action, integration
icon: str = "" icon: str = ''
# Port definitions # Port definitions
inputs: list[NodePort] = [] inputs: list[NodePort] = []
@@ -76,11 +79,7 @@ class WorkflowNode(abc.ABC):
self.ap = ap # Reference to the application instance for accessing services self.ap = ap # Reference to the application instance for accessing services
@abc.abstractmethod @abc.abstractmethod
async def execute( async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
self,
inputs: dict[str, Any],
context: ExecutionContext
) -> dict[str, Any]:
""" """
Execute the node logic. Execute the node logic.
@@ -103,7 +102,7 @@ class WorkflowNode(abc.ABC):
errors = [] errors = []
for port in self.inputs: for port in self.inputs:
if port.required and port.name not in 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 return errors
async def validate_config(self) -> list[str]: async def validate_config(self) -> list[str]:
@@ -116,23 +115,23 @@ class WorkflowNode(abc.ABC):
errors = [] errors = []
for cfg in self.config_schema: for cfg in self.config_schema:
if cfg.required and cfg.name not in self.config: 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: elif cfg.name in self.config:
value = self.config[cfg.name] value = self.config[cfg.name]
# Type validation # Type validation
if cfg.type == "integer" and not isinstance(value, int): if cfg.type == 'integer' and not isinstance(value, int):
errors.append(f"Config {cfg.name} must be an integer") errors.append(f'Config {cfg.name} must be an integer')
elif cfg.type == "number" and not isinstance(value, (int, float)): elif cfg.type == 'number' and not isinstance(value, (int, float)):
errors.append(f"Config {cfg.name} must be a number") errors.append(f'Config {cfg.name} must be a number')
elif cfg.type == "boolean" and not isinstance(value, bool): elif cfg.type == 'boolean' and not isinstance(value, bool):
errors.append(f"Config {cfg.name} must be a boolean") errors.append(f'Config {cfg.name} must be a boolean')
# Range validation # Range validation
if cfg.min_value is not None and isinstance(value, (int, float)): if cfg.min_value is not None and isinstance(value, (int, float)):
if value < cfg.min_value: 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 cfg.max_value is not None and isinstance(value, (int, float)):
if value > cfg.max_value: 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 return errors
# Type mapping from backend to frontend DynamicFormItemType # Type mapping from backend to frontend DynamicFormItemType
@@ -202,7 +201,7 @@ class WorkflowNode(abc.ABC):
'label': { 'label': {
'zh_Hans': opt, 'zh_Hans': opt,
'en_US': opt, 'en_US': opt,
} },
} }
for opt in cfg.options for opt in cfg.options
] ]
@@ -264,10 +263,12 @@ def workflow_node(type_name: str) -> Callable[[type[WorkflowNode]], type[Workflo
class LLMCallNode(WorkflowNode): class LLMCallNode(WorkflowNode):
... ...
""" """
def decorator(cls: type[WorkflowNode]) -> type[WorkflowNode]: def decorator(cls: type[WorkflowNode]) -> type[WorkflowNode]:
cls.type_name = type_name cls.type_name = type_name
_pending_registrations.append((type_name, cls)) _pending_registrations.append((type_name, cls))
return cls return cls
return decorator return decorator
+17 -11
View File
@@ -22,15 +22,15 @@ from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
class CallPipelineNode(WorkflowNode): class CallPipelineNode(WorkflowNode):
"""Call pipeline node - invoke an existing pipeline""" """Call pipeline node - invoke an existing pipeline"""
type_name = "call_pipeline" type_name = 'call_pipeline'
category = "action" category = 'action'
icon = "⚙️" icon = '⚙️'
name = "call_pipeline" name = 'call_pipeline'
description = "call_pipeline" description = 'call_pipeline'
name_zh = "调用 Pipeline" name_zh = '调用 Pipeline'
name_en = "Call Pipeline" name_en = 'Call Pipeline'
description_zh = "调用现有的 Pipeline 进行处理" description_zh = '调用现有的 Pipeline 进行处理'
description_en = "Invoke an existing Pipeline for processing" description_en = 'Invoke an existing Pipeline for processing'
inputs: ClassVar[list[NodePort]] = [] inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = [] outputs: ClassVar[list[NodePort]] = []
@@ -66,7 +66,11 @@ class CallPipelineNode(WorkflowNode):
message_event = self._build_message_event(query_text, context) message_event = self._build_message_event(query_text, context)
message_chain = message_event.message_chain 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 launcher_id = context.session_id or context.execution_id
sender_id = ( sender_id = (
context.message_context.sender_id context.message_context.sender_id
@@ -143,7 +147,9 @@ class CallPipelineNode(WorkflowNode):
return platform_events.FriendMessage( return platform_events.FriendMessage(
sender=sender, sender=sender,
message_chain=message_chain, 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,
) )
+42 -21
View File
@@ -17,25 +17,25 @@ from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
class CodeExecutorNode(WorkflowNode): class CodeExecutorNode(WorkflowNode):
"""Code executor node - run Python or JavaScript code""" """Code executor node - run Python or JavaScript code"""
type_name = "code_executor" type_name = 'code_executor'
category = "process" category = 'process'
icon = "💻" icon = '💻'
name = "code_executor" name = 'code_executor'
description = "code_executor" description = 'code_executor'
name_zh = "代码执行" name_zh = '代码执行'
name_en = "Code Executor" name_en = 'Code Executor'
description_zh = "执行自定义代码处理数据" description_zh = '执行自定义代码处理数据'
description_en = "Execute custom code to process data" description_en = 'Execute custom code to process data'
inputs: ClassVar[list[NodePort]] = [] inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = [] outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = [] config_schema: ClassVar[list[NodeConfig]] = []
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]:
code = self.get_config("code", "") code = self.get_config('code', '')
language = self.get_config("language", "python") language = self.get_config('language', 'python')
if language == "python": if language == 'python':
return await self._execute_python(code, inputs, context) return await self._execute_python(code, inputs, context)
else: else:
return await self._execute_javascript(code, inputs, context) return await self._execute_javascript(code, inputs, context)
@@ -52,22 +52,43 @@ class CodeExecutorNode(WorkflowNode):
restricted_globals = { restricted_globals = {
'__builtins__': { '__builtins__': {
'len': len, 'str': str, 'int': int, 'float': float, 'bool': bool, 'len': len,
'list': list, 'dict': dict, 'set': set, 'tuple': tuple, 'str': str,
'range': range, 'enumerate': enumerate, 'zip': zip, 'int': int,
'map': map, 'filter': filter, 'sorted': sorted, 'reversed': reversed, 'float': float,
'sum': sum, 'min': min, 'max': max, 'abs': abs, 'round': round, 'bool': bool,
'print': print, 'isinstance': isinstance, 'type': type, 'list': list,
'hasattr': hasattr, 'getattr': getattr, 'json': json, 're': re, '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} local_vars = {'inputs': inputs, 'output': None}
exec(code, restricted_globals, local_vars) 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: finally:
sys.stdout = old_stdout sys.stdout = old_stdout
async def _execute_javascript(self, code: str, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]: 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': ''}
+37 -36
View File
@@ -16,82 +16,83 @@ from ..safe_eval import safe_eval_with_vars
class ConditionNode(WorkflowNode): class ConditionNode(WorkflowNode):
"""Condition node - branch based on condition""" """Condition node - branch based on condition"""
type_name = "condition" type_name = 'condition'
category = "control" category = 'control'
icon = "🔀" icon = '🔀'
name = "condition" name = 'condition'
description = "condition" description = 'condition'
name_zh = "条件分支" name_zh = '条件分支'
name_en = "Condition" name_en = 'Condition'
description_zh = "根据条件分支工作流" description_zh = '根据条件分支工作流'
description_en = "Branch workflow based on a condition" description_en = 'Branch workflow based on a condition'
inputs: ClassVar[list[NodePort]] = [] inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = [] outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = [] config_schema: ClassVar[list[NodeConfig]] = []
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]:
condition_type = self.get_config("condition_type", "expression") condition_type = self.get_config('condition_type', 'expression')
input_data = inputs.get("input") input_data = inputs.get('input')
result = False result = False
if condition_type == "expression": if condition_type == 'expression':
expression = self.get_config("expression", "false") expression = self.get_config('expression', 'false')
result = await self._evaluate_expression(expression, input_data, context) 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) result = await self._evaluate_comparison(input_data, context)
elif condition_type == "contains": elif condition_type == 'contains':
left = self.get_config("left_value", "") left = self.get_config('left_value', '')
right = self.get_config("right_value", "") right = self.get_config('right_value', '')
result = right in left result = right in left
elif condition_type == "empty": elif condition_type == 'empty':
result = not bool(input_data) result = not bool(input_data)
elif condition_type == "regex": elif condition_type == 'regex':
import re 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))) result = bool(re.match(pattern, str(left)))
if result: if result:
return {"true": input_data, "false": None} return {'true': input_data, 'false': None}
else: 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: async def _evaluate_expression(self, expression: str, data: Any, context: ExecutionContext) -> bool:
try: 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)) return bool(safe_eval_with_vars(expression, local_vars))
except Exception: except Exception:
return False return False
async def _evaluate_comparison(self, data: Any, context: ExecutionContext) -> bool: async def _evaluate_comparison(self, data: Any, context: ExecutionContext) -> bool:
left = self.get_config("left_value", "") left = self.get_config('left_value', '')
right = self.get_config("right_value", "") right = self.get_config('right_value', '')
operator = self.get_config("operator", "==") operator = self.get_config('operator', '==')
try: try:
left_num = float(left) left_num = float(left)
right_num = float(right) right_num = float(right)
if operator == "==": if operator == '==':
return left_num == right_num return left_num == right_num
elif operator == "!=": elif operator == '!=':
return left_num != right_num return left_num != right_num
elif operator == ">": elif operator == '>':
return left_num > right_num return left_num > right_num
elif operator == "<": elif operator == '<':
return left_num < right_num return left_num < right_num
elif operator == ">=": elif operator == '>=':
return left_num >= right_num return left_num >= right_num
elif operator == "<=": elif operator == '<=':
return left_num <= right_num return left_num <= right_num
except ValueError: except ValueError:
if operator == "==": if operator == '==':
return left == right return left == right
elif operator == "!=": elif operator == '!=':
return left != right return left != right
elif operator in (">", "<", ">=", "<="): elif operator in ('>', '<', '>=', '<='):
return False return False
return False return False
+22 -22
View File
@@ -15,35 +15,35 @@ from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
class CozeBotNode(WorkflowNode): class CozeBotNode(WorkflowNode):
"""Coze bot node - call Coze API bot""" """Coze bot node - call Coze API bot"""
type_name = "coze_bot" type_name = 'coze_bot'
category = "integration" category = 'integration'
icon = "MessageSquare" icon = 'MessageSquare'
name = "coze_bot" name = 'coze_bot'
description = "coze_bot" description = 'coze_bot'
name_zh = "Coze Bot" name_zh = 'Coze Bot'
name_en = "Coze Bot" name_en = 'Coze Bot'
description_zh = "调用扣子 Bot" description_zh = '调用扣子 Bot'
description_en = "Call a Coze Bot" description_en = 'Call a Coze Bot'
inputs: ClassVar[list[NodePort]] = [] inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = [] outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = [] config_schema: ClassVar[list[NodeConfig]] = []
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]:
api_key = self.get_config("api_key", "") api_key = self.get_config('api_key', '')
bot_id = self.get_config("bot_id", "") bot_id = self.get_config('bot_id', '')
api_base = self.get_config("api_base", "https://api.coze.cn") api_base = self.get_config('api_base', 'https://api.coze.cn')
query = inputs.get("query", "") query = inputs.get('query', '')
conversation_id = inputs.get("conversation_id") conversation_id = inputs.get('conversation_id')
return { return {
"answer": "", 'answer': '',
"conversation_id": conversation_id, 'conversation_id': conversation_id,
"success": False, 'success': False,
"_debug": { '_debug': {
"api_key": api_key[:8] + "..." if api_key else "", 'api_key': api_key[:8] + '...' if api_key else '',
"bot_id": bot_id, 'bot_id': bot_id,
"api_base": api_base, 'api_base': api_base,
"query": query, 'query': query,
}, },
} }
+12 -12
View File
@@ -15,15 +15,15 @@ from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
class CronTriggerNode(WorkflowNode): class CronTriggerNode(WorkflowNode):
"""Cron trigger node - triggers workflow on schedule""" """Cron trigger node - triggers workflow on schedule"""
type_name = "cron_trigger" type_name = 'cron_trigger'
category = "trigger" category = 'trigger'
icon = "" icon = ''
name = "cron_trigger" name = 'cron_trigger'
description = "cron_trigger" description = 'cron_trigger'
name_zh = "定时触发" name_zh = '定时触发'
name_en = "Scheduled Trigger" name_en = 'Scheduled Trigger'
description_zh = "按定时计划触发工作流" description_zh = '按定时计划触发工作流'
description_en = "Trigger workflow on a scheduled time" description_en = 'Trigger workflow on a scheduled time'
inputs: ClassVar[list[NodePort]] = [] inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = [] outputs: ClassVar[list[NodePort]] = []
@@ -33,7 +33,7 @@ class CronTriggerNode(WorkflowNode):
from datetime import datetime from datetime import datetime
return { return {
"timestamp": datetime.now().isoformat(), 'timestamp': datetime.now().isoformat(),
"schedule": self.get_config("cron", ""), 'schedule': self.get_config('cron', ''),
"context": context.trigger_data, 'context': context.trigger_data,
} }
@@ -16,52 +16,52 @@ from ..safe_eval import safe_eval_with_vars
class DataTransformNode(WorkflowNode): class DataTransformNode(WorkflowNode):
"""Data transform node - transform data using templates or JSONPath""" """Data transform node - transform data using templates or JSONPath"""
type_name = "data_transform" type_name = 'data_transform'
category = "process" category = 'process'
icon = "🔄" icon = '🔄'
name = "data_transform" name = 'data_transform'
description = "data_transform" description = 'data_transform'
name_zh = "数据转换" name_zh = '数据转换'
name_en = "Data Transform" name_en = 'Data Transform'
description_zh = "使用模板或 JSONPath 转换数据" description_zh = '使用模板或 JSONPath 转换数据'
description_en = "Transform data using templates or JSONPath" description_en = 'Transform data using templates or JSONPath'
inputs: ClassVar[list[NodePort]] = [] inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = [] outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = [] config_schema: ClassVar[list[NodeConfig]] = []
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]:
data = inputs.get("data") data = inputs.get('data')
transform_type = self.get_config("transform_type", "template") transform_type = self.get_config('transform_type', 'template')
if transform_type == "template": if transform_type == 'template':
template = self.get_config("template", "") template = self.get_config('template', '')
result = self._apply_template(template, data, context) result = self._apply_template(template, data, context)
elif transform_type == "jsonpath": elif transform_type == 'jsonpath':
expression = self.get_config("expression", "$") expression = self.get_config('expression', '$')
result = self._apply_jsonpath(expression, data) result = self._apply_jsonpath(expression, data)
elif transform_type == "expression": elif transform_type == 'expression':
expression = self.get_config("expression", "") expression = self.get_config('expression', '')
result = self._evaluate_expression(expression, data, context) result = self._evaluate_expression(expression, data, context)
else: else:
result = data result = data
return {"result": result} return {'result': result}
def _apply_template(self, template: str, data: Any, context: ExecutionContext) -> str: def _apply_template(self, template: str, data: Any, context: ExecutionContext) -> str:
result = template result = template
if isinstance(data, dict): if isinstance(data, dict):
for key, value in data.items(): 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(): 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 return result
def _apply_jsonpath(self, expression: str, data: Any) -> Any: def _apply_jsonpath(self, expression: str, data: Any) -> Any:
if expression == "$": if expression == '$':
return data return data
if expression.startswith("$."): if expression.startswith('$.'):
parts = expression[2:].split(".") parts = expression[2:].split('.')
result = data result = data
for part in parts: for part in parts:
if isinstance(result, dict): if isinstance(result, dict):
@@ -74,7 +74,7 @@ class DataTransformNode(WorkflowNode):
return data return data
def _evaluate_expression(self, expression: str, data: Any, context: ExecutionContext) -> Any: 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: try:
return safe_eval_with_vars(expression, local_vars) return safe_eval_with_vars(expression, local_vars)
except Exception: except Exception:
@@ -15,37 +15,37 @@ from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
class DatabaseQueryNode(WorkflowNode): class DatabaseQueryNode(WorkflowNode):
"""Database query node - execute database queries""" """Database query node - execute database queries"""
type_name = "database_query" type_name = 'database_query'
category = "integration" category = 'integration'
icon = "Database" icon = 'Database'
name = "database_query" name = 'database_query'
description = "database_query" description = 'database_query'
name_zh = "数据库查询" name_zh = '数据库查询'
name_en = "Database Query" name_en = 'Database Query'
description_zh = "执行数据库查询" description_zh = '执行数据库查询'
description_en = "Execute database queries" description_en = 'Execute database queries'
inputs: ClassVar[list[NodePort]] = [] inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = [] outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = [] config_schema: ClassVar[list[NodeConfig]] = []
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]:
connection_type = self.get_config("connection_type", "postgresql") connection_type = self.get_config('connection_type', 'postgresql')
query = self.get_config("query", "") query = self.get_config('query', '')
query_type = self.get_config("query_type", "select") query_type = self.get_config('query_type', 'select')
timeout = self.get_config("timeout", 30) timeout = self.get_config('timeout', 30)
parameters = inputs.get("parameters", {}) parameters = inputs.get('parameters', {})
return { return {
"results": [], 'results': [],
"row_count": 0, 'row_count': 0,
"success": False, 'success': False,
"_debug": { '_debug': {
"connection_type": connection_type, 'connection_type': connection_type,
"query": query, 'query': query,
"query_type": query_type, 'query_type': query_type,
"timeout": timeout, 'timeout': timeout,
"parameters": parameters, 'parameters': parameters,
}, },
} }
@@ -15,33 +15,33 @@ from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
class DifyKnowledgeQueryNode(WorkflowNode): class DifyKnowledgeQueryNode(WorkflowNode):
"""Dify knowledge base query node - query Dify knowledge base""" """Dify knowledge base query node - query Dify knowledge base"""
type_name = "dify_knowledge_query" type_name = 'dify_knowledge_query'
category = "integration" category = 'integration'
icon = "BookOpen" icon = 'BookOpen'
name = "dify_knowledge_query" name = 'dify_knowledge_query'
description = "dify_knowledge_query" description = 'dify_knowledge_query'
name_zh = "Dify 知识库查询" name_zh = 'Dify 知识库查询'
name_en = "Dify Knowledge Query" name_en = 'Dify Knowledge Query'
description_zh = "查询 Dify 知识库" description_zh = '查询 Dify 知识库'
description_en = "Query Dify knowledge base" description_en = 'Query Dify knowledge base'
inputs: ClassVar[list[NodePort]] = [] inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = [] outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = [] config_schema: ClassVar[list[NodeConfig]] = []
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]:
base_url = self.get_config("base_url", "https://api.dify.ai/v1") base_url = self.get_config('base_url', 'https://api.dify.ai/v1')
api_key = self.get_config("api_key", "") api_key = self.get_config('api_key', '')
dataset_id = self.get_config("dataset_id", "") dataset_id = self.get_config('dataset_id', '')
query = inputs.get("query", "") query = inputs.get('query', '')
return { return {
"results": [], 'results': [],
"success": False, 'success': False,
"_debug": { '_debug': {
"base_url": base_url, 'base_url': base_url,
"api_key": api_key[:8] + "..." if api_key else "", 'api_key': api_key[:8] + '...' if api_key else '',
"dataset_id": dataset_id, 'dataset_id': dataset_id,
"query": query, 'query': query,
}, },
} }
+22 -22
View File
@@ -15,35 +15,35 @@ from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
class DifyWorkflowNode(WorkflowNode): class DifyWorkflowNode(WorkflowNode):
"""Dify workflow node - call Dify service API""" """Dify workflow node - call Dify service API"""
type_name = "dify_workflow" type_name = 'dify_workflow'
category = "integration" category = 'integration'
icon = "Bot" icon = 'Bot'
name = "dify_workflow" name = 'dify_workflow'
description = "dify_workflow" description = 'dify_workflow'
name_zh = "Dify 工作流" name_zh = 'Dify 工作流'
name_en = "Dify Workflow" name_en = 'Dify Workflow'
description_zh = "调用 Dify 平台工作流" description_zh = '调用 Dify 平台工作流'
description_en = "Call a Dify platform workflow" description_en = 'Call a Dify platform workflow'
inputs: ClassVar[list[NodePort]] = [] inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = [] outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = [] config_schema: ClassVar[list[NodeConfig]] = []
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]:
base_url = self.get_config("base_url", "https://api.dify.ai/v1") base_url = self.get_config('base_url', 'https://api.dify.ai/v1')
api_key = self.get_config("api_key", "") api_key = self.get_config('api_key', '')
app_type = self.get_config("app_type", "chat") app_type = self.get_config('app_type', 'chat')
query = inputs.get("query", "") query = inputs.get('query', '')
conversation_id = inputs.get("conversation_id") conversation_id = inputs.get('conversation_id')
return { return {
"answer": "", 'answer': '',
"conversation_id": conversation_id, 'conversation_id': conversation_id,
"success": False, 'success': False,
"_debug": { '_debug': {
"base_url": base_url, 'base_url': base_url,
"api_key": api_key[:8] + "..." if api_key else "", 'api_key': api_key[:8] + '...' if api_key else '',
"app_type": app_type, 'app_type': app_type,
"query": query, 'query': query,
}, },
} }
+18 -17
View File
@@ -15,31 +15,32 @@ from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
class EndNode(WorkflowNode): class EndNode(WorkflowNode):
"""End node - marks the end of workflow execution""" """End node - marks the end of workflow execution"""
type_name = "end" type_name = 'end'
category = "action" category = 'action'
icon = "🏁" icon = '🏁'
name = "end" name = 'end'
description = "end" description = 'end'
name_zh = "结束" name_zh = '结束'
name_en = "End" name_en = 'End'
description_zh = "结束工作流执行" description_zh = '结束工作流执行'
description_en = "End the workflow execution" description_en = 'End the workflow execution'
inputs: ClassVar[list[NodePort]] = [] inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = [] outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = [] config_schema: ClassVar[list[NodeConfig]] = []
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]:
result = inputs.get("result") result = inputs.get('result')
output_format = self.get_config("output_format", "passthrough") output_format = self.get_config('output_format', 'passthrough')
if output_format == "text": if output_format == 'text':
return {"output": str(result)} return {'output': str(result)}
elif output_format == "json": elif output_format == 'json':
import json import json
try: try:
return {"output": json.dumps(result, ensure_ascii=False)} return {'output': json.dumps(result, ensure_ascii=False)}
except Exception: except Exception:
return {"output": str(result)} return {'output': str(result)}
else: else:
return {"output": result} return {'output': result}
+12 -12
View File
@@ -15,15 +15,15 @@ from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
class EventTriggerNode(WorkflowNode): class EventTriggerNode(WorkflowNode):
"""Event trigger node - triggers workflow on system events""" """Event trigger node - triggers workflow on system events"""
type_name = "event_trigger" type_name = 'event_trigger'
category = "trigger" category = 'trigger'
icon = "📡" icon = '📡'
name = "event_trigger" name = 'event_trigger'
description = "event_trigger" description = 'event_trigger'
name_zh = "事件触发" name_zh = '事件触发'
name_en = "Event Trigger" name_en = 'Event Trigger'
description_zh = "当系统事件发生时触发工作流" description_zh = '当系统事件发生时触发工作流'
description_en = "Trigger workflow when a system event occurs" description_en = 'Trigger workflow when a system event occurs'
inputs: ClassVar[list[NodePort]] = [] inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = [] outputs: ClassVar[list[NodePort]] = []
@@ -35,7 +35,7 @@ class EventTriggerNode(WorkflowNode):
trigger_data = context.trigger_data trigger_data = context.trigger_data
return { return {
"event_type": trigger_data.get("event_type", ""), 'event_type': trigger_data.get('event_type', ''),
"event_data": trigger_data.get("event_data", {}), 'event_data': trigger_data.get('event_data', {}),
"timestamp": trigger_data.get("timestamp", datetime.now().isoformat()), 'timestamp': trigger_data.get('timestamp', datetime.now().isoformat()),
} }
+34 -29
View File
@@ -15,15 +15,15 @@ from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
class HTTPRequestNode(WorkflowNode): class HTTPRequestNode(WorkflowNode):
"""HTTP request node - make HTTP API calls""" """HTTP request node - make HTTP API calls"""
type_name = "http_request" type_name = 'http_request'
category = "process" category = 'process'
icon = "🌐" icon = '🌐'
name = "http_request" name = 'http_request'
description = "http_request" description = 'http_request'
name_zh = "HTTP 请求" name_zh = 'HTTP 请求'
name_en = "HTTP Request" name_en = 'HTTP Request'
description_zh = "向外部 API 发送 HTTP 请求" description_zh = '向外部 API 发送 HTTP 请求'
description_en = "Make HTTP requests to external APIs" description_en = 'Make HTTP requests to external APIs'
inputs: ClassVar[list[NodePort]] = [] inputs: ClassVar[list[NodePort]] = []
outputs: 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]: async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
import aiohttp import aiohttp
url = self.get_config("url", "") url = self.get_config('url', '')
method = self.get_config("method", "GET") method = self.get_config('method', 'GET')
timeout = self.get_config("timeout", 30) timeout = self.get_config('timeout', 30)
content_type = self.get_config("content_type", "application/json") content_type = self.get_config('content_type', 'application/json')
headers = inputs.get("headers", {}) headers = inputs.get('headers', {})
headers["Content-Type"] = content_type headers['Content-Type'] = content_type
auth_type = self.get_config("auth_type", "none") auth_type = self.get_config('auth_type', 'none')
auth_config = self.get_config("auth_config", {}) auth_config = self.get_config('auth_config', {})
if auth_type == "bearer": if auth_type == 'bearer':
headers["Authorization"] = f"Bearer {auth_config.get('token', '')}" headers['Authorization'] = f'Bearer {auth_config.get("token", "")}'
elif auth_type == "api_key": elif auth_type == 'api_key':
header_name = auth_config.get("header", "X-API-Key") header_name = auth_config.get('header', 'X-API-Key')
headers[header_name] = auth_config.get("key", "") headers[header_name] = auth_config.get('key', '')
body = inputs.get("body") body = inputs.get('body')
try: try:
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
async with session.request( async with session.request(
method=method, url=url, method=method,
json=body if content_type == "application/json" else None, url=url,
data=body if content_type != "application/json" else None, json=body if content_type == 'application/json' else None,
data=body if content_type != 'application/json' else None,
headers=headers, headers=headers,
timeout=aiohttp.ClientTimeout(total=timeout) timeout=aiohttp.ClientTimeout(total=timeout),
) as response: ) as response:
try: try:
response_data = await response.json() response_data = await response.json()
except Exception: except Exception:
response_data = await response.text() 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: except Exception as e:
return {"response": None, "status_code": 0, "headers": {}, "error": str(e)} return {'response': None, 'status_code': 0, 'headers': {}, 'error': str(e)}
+31 -28
View File
@@ -12,49 +12,52 @@ from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
class IteratorNode(WorkflowNode): class IteratorNode(WorkflowNode):
"""Iterator node - iterate over array items one by one""" """Iterator node - iterate over array items one by one"""
type_name = "iterator" type_name = 'iterator'
category = "control" category = 'control'
icon = "🔄" icon = '🔄'
name = "iterator" name = 'iterator'
name_zh = "迭代器" name_zh = '迭代器'
name_en = "Iterator" name_en = 'Iterator'
description = "iterator" description = 'iterator'
description_zh = "逐个遍历数组元素" description_zh = '逐个遍历数组元素'
description_en = "Iterate over array elements one by one" description_en = 'Iterate over array elements one by one'
inputs: ClassVar[list[NodePort]] = [ 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]] = [ outputs: ClassVar[list[NodePort]] = [
NodePort(name="item", type="any", description="Current item"), NodePort(name='item', type='any', description='Current item'),
NodePort(name="index", type="number", description="Current index"), NodePort(name='index', type='number', description='Current index'),
NodePort(name="is_first", type="boolean", description="Whether this is the first item"), 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='is_last', type='boolean', description='Whether this is the last item'),
NodePort(name="results", type="array", description="All iteration results"), NodePort(name='results', type='array', description='All iteration results'),
NodePort(name="completed", type="boolean", description="Whether iteration completed"), NodePort(name='completed', type='boolean', description='Whether iteration completed'),
] ]
config_schema: ClassVar[list[NodeConfig]] = [ config_schema: ClassVar[list[NodeConfig]] = [
NodeConfig( NodeConfig(
name="max_iterations", type="integer", required=False, default=1000, name='max_iterations',
description="Maximum iterations (safety limit)", type='integer',
label={"en_US": "Max Iterations", "zh_Hans": "最大迭代次数"}, 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]: 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): if not isinstance(items, list):
items = [items] if items else [] 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] items = items[:max_iterations]
return { return {
"item": items[0] if items else None, 'item': items[0] if items else None,
"index": 0, 'index': 0,
"is_first": True, 'is_first': True,
"is_last": len(items) <= 1, 'is_last': len(items) <= 1,
"results": [], 'results': [],
"completed": len(items) == 0, 'completed': len(items) == 0,
"_items": items, '_items': items,
} }
@@ -15,20 +15,20 @@ from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
class KnowledgeRetrievalNode(WorkflowNode): class KnowledgeRetrievalNode(WorkflowNode):
"""Knowledge retrieval node - search in knowledge base""" """Knowledge retrieval node - search in knowledge base"""
type_name = "knowledge_retrieval" type_name = 'knowledge_retrieval'
category = "process" category = 'process'
icon = "📚" icon = '📚'
name = "knowledge_retrieval" name = 'knowledge_retrieval'
description = "knowledge_retrieval" description = 'knowledge_retrieval'
name_zh = "知识库检索" name_zh = '知识库检索'
name_en = "Knowledge Retrieval" name_en = 'Knowledge Retrieval'
description_zh = "从知识库中检索相关信息" description_zh = '从知识库中检索相关信息'
description_en = "Retrieve relevant information from knowledge bases" description_en = 'Retrieve relevant information from knowledge bases'
inputs: ClassVar[list[NodePort]] = [] inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = [] outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = [] config_schema: ClassVar[list[NodeConfig]] = []
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]:
query = inputs.get("query", "") query = inputs.get('query', '')
return {"documents": [], "citations": [], "context": f"[Knowledge base search for: {query}]"} return {'documents': [], 'citations': [], 'context': f'[Knowledge base search for: {query}]'}
+20 -20
View File
@@ -15,33 +15,33 @@ from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
class LangflowFlowNode(WorkflowNode): class LangflowFlowNode(WorkflowNode):
"""Langflow flow node - call Langflow API""" """Langflow flow node - call Langflow API"""
type_name = "langflow_flow" type_name = 'langflow_flow'
category = "integration" category = 'integration'
icon = "GitBranch" icon = 'GitBranch'
name = "langflow_flow" name = 'langflow_flow'
description = "langflow_flow" description = 'langflow_flow'
name_zh = "Langflow 流程" name_zh = 'Langflow 流程'
name_en = "Langflow Flow" name_en = 'Langflow Flow'
description_zh = "调用 Langflow 流程" description_zh = '调用 Langflow 流程'
description_en = "Call a Langflow flow" description_en = 'Call a Langflow flow'
inputs: ClassVar[list[NodePort]] = [] inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = [] outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = [] config_schema: ClassVar[list[NodeConfig]] = []
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]:
base_url = self.get_config("base_url", "http://localhost:7860") base_url = self.get_config('base_url', 'http://localhost:7860')
api_key = self.get_config("api_key", "") api_key = self.get_config('api_key', '')
flow_id = self.get_config("flow_id", "") flow_id = self.get_config('flow_id', '')
input_value = inputs.get("input_value", "") input_value = inputs.get('input_value', '')
return { return {
"result": None, 'result': None,
"success": False, 'success': False,
"_debug": { '_debug': {
"base_url": base_url, 'base_url': base_url,
"api_key": api_key[:8] + "..." if api_key else "", 'api_key': api_key[:8] + '...' if api_key else '',
"flow_id": flow_id, 'flow_id': flow_id,
"input_value": input_value, 'input_value': input_value,
}, },
} }
+74 -62
View File
@@ -18,108 +18,120 @@ logger = logging.getLogger(__name__)
class LLMCallNode(WorkflowNode): class LLMCallNode(WorkflowNode):
"""LLM call node - invoke large language model""" """LLM call node - invoke large language model"""
type_name = "llm_call" type_name = 'llm_call'
category = "process" category = 'process'
icon = "🤖" icon = '🤖'
name = "llm_call" name = 'llm_call'
name_zh = "LLM 调用" name_zh = 'LLM 调用'
name_en = "LLM Call" name_en = 'LLM Call'
description = "llm_call" description = 'llm_call'
description_zh = "调用大语言模型生成响应" description_zh = '调用大语言模型生成响应'
description_en = "Call a large language model to generate responses" description_en = 'Call a large language model to generate responses'
inputs: ClassVar[list[NodePort]] = [ inputs: ClassVar[list[NodePort]] = [
NodePort(name="input", type="string", description="Input text to send to the model", 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), NodePort(name='context', type='object', description='Additional context data', required=False),
] ]
outputs: ClassVar[list[NodePort]] = [ outputs: ClassVar[list[NodePort]] = [
NodePort(name="response", type="string", description="Model response text"), NodePort(name='response', type='string', description='Model response text'),
NodePort(name="usage", type="object", description="Token usage information"), NodePort(name='usage', type='object', description='Token usage information'),
] ]
config_schema: ClassVar[list[NodeConfig]] = [ config_schema: ClassVar[list[NodeConfig]] = [
NodeConfig( NodeConfig(
name="model", type="llm-model-selector", required=True, name='model',
description="Select the LLM model to use", type='llm-model-selector',
label={"en_US": "Model", "zh_Hans": "模型"}, required=True,
description='Select the LLM model to use',
label={'en_US': 'Model', 'zh_Hans': '模型'},
), ),
NodeConfig( NodeConfig(
name="system_prompt", type="textarea", required=False, default="", name='system_prompt',
description="System prompt to set model behavior", type='textarea',
label={"en_US": "System Prompt", "zh_Hans": "系统提示词"}, required=False,
default='',
description='System prompt to set model behavior',
label={'en_US': 'System Prompt', 'zh_Hans': '系统提示词'},
), ),
NodeConfig( NodeConfig(
name="user_prompt_template", type="textarea", required=True, default="{{input}}", name='user_prompt_template',
description="User prompt template with variable placeholders", type='textarea',
label={"en_US": "User Prompt Template", "zh_Hans": "用户提示词模板"}, required=True,
default='{{input}}',
description='User prompt template with variable placeholders',
label={'en_US': 'User Prompt Template', 'zh_Hans': '用户提示词模板'},
), ),
NodeConfig( NodeConfig(
name="temperature", type="number", required=False, default=0.7, name='temperature',
description="Controls randomness (0.0-2.0)", type='number',
label={"en_US": "Temperature", "zh_Hans": "温度"}, required=False,
min_value=0.0, max_value=2.0, 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( NodeConfig(
name="max_tokens", type="integer", required=False, default=0, name='max_tokens',
description="Max tokens to generate (0 = model default)", type='integer',
label={"en_US": "Max Tokens", "zh_Hans": "最大令牌数"}, 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: def _resolve_template(self, template: str, inputs: dict[str, Any], context: ExecutionContext) -> str:
"""Resolve {{variable}} placeholders in a template string.""" """Resolve {{variable}} placeholders in a template string."""
def replacer(match: re.Match) -> str: def replacer(match: re.Match) -> str:
expr = match.group(1).strip() expr = match.group(1).strip()
# Try inputs first # Try inputs first
if expr in inputs: if expr in inputs:
return str(inputs[expr]) return str(inputs[expr])
# Try context variables # Try context variables
if expr.startswith("variables."): if expr.startswith('variables.'):
var_name = expr[len("variables."):] var_name = expr[len('variables.') :]
return str(context.variables.get(var_name, "")) return str(context.variables.get(var_name, ''))
# Try message context # Try message context
if expr.startswith("message.") and context.message_context: if expr.startswith('message.') and context.message_context:
attr = expr[len("message."):] attr = expr[len('message.') :]
return str(getattr(context.message_context, attr, "")) return str(getattr(context.message_context, attr, ''))
return match.group(0) # leave unresolved 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]: 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: 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: if not self.ap:
raise RuntimeError("Application instance not available — cannot call LLM") raise RuntimeError('Application instance not available — cannot call LLM')
# Resolve prompts # Resolve prompts
system_prompt = self._resolve_template( system_prompt = self._resolve_template(self.get_config('system_prompt', ''), inputs, context)
self.get_config("system_prompt", ""), inputs, context user_prompt = self._resolve_template(self.get_config('user_prompt_template', '{{input}}'), inputs, context)
)
user_prompt = self._resolve_template(
self.get_config("user_prompt_template", "{{input}}"), inputs, context
)
# Build messages # Build messages
messages: list[provider_message.Message] = [] messages: list[provider_message.Message] = []
if system_prompt: if system_prompt:
messages.append(provider_message.Message(role="system", content=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='user', content=user_prompt))
# Get model # Get model
runtime_model = await self.ap.model_mgr.get_model_by_uuid(model_uuid) runtime_model = await self.ap.model_mgr.get_model_by_uuid(model_uuid)
# Build extra args from config # Build extra args from config
extra_args: dict[str, Any] = {} extra_args: dict[str, Any] = {}
temperature = self.get_config("temperature") temperature = self.get_config('temperature')
if temperature is not None: if temperature is not None:
extra_args["temperature"] = float(temperature) extra_args['temperature'] = float(temperature)
max_tokens = self.get_config("max_tokens", 0) max_tokens = self.get_config('max_tokens', 0)
if max_tokens and int(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 # 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( result_message = await runtime_model.provider.invoke_llm(
query=None, query=None,
model=runtime_model, model=runtime_model,
@@ -129,7 +141,7 @@ class LLMCallNode(WorkflowNode):
) )
# Extract response text # Extract response text
response_text = "" response_text = ''
if isinstance(result_message.content, str): if isinstance(result_message.content, str):
response_text = result_message.content response_text = result_message.content
elif isinstance(result_message.content, list): elif isinstance(result_message.content, list):
@@ -141,23 +153,23 @@ class LLMCallNode(WorkflowNode):
response_text += elem response_text += elem
# Extract usage info if available # 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: if hasattr(result_message, 'usage') and result_message.usage:
u = result_message.usage u = result_message.usage
usage = { usage = {
"prompt_tokens": getattr(u, 'prompt_tokens', 0) or 0, 'prompt_tokens': getattr(u, 'prompt_tokens', 0) or 0,
"completion_tokens": getattr(u, 'completion_tokens', 0) or 0, 'completion_tokens': getattr(u, 'completion_tokens', 0) or 0,
"total_tokens": getattr(u, 'total_tokens', 0) or 0, 'total_tokens': getattr(u, 'total_tokens', 0) or 0,
} }
elif hasattr(result_message, 'token_usage') and result_message.token_usage: elif hasattr(result_message, 'token_usage') and result_message.token_usage:
u = result_message.token_usage u = result_message.token_usage
usage = { usage = {
"prompt_tokens": getattr(u, 'prompt_tokens', 0) or 0, 'prompt_tokens': getattr(u, 'prompt_tokens', 0) or 0,
"completion_tokens": getattr(u, 'completion_tokens', 0) or 0, 'completion_tokens': getattr(u, 'completion_tokens', 0) or 0,
"total_tokens": getattr(u, 'total_tokens', 0) or 0, 'total_tokens': getattr(u, 'total_tokens', 0) or 0,
} }
return { return {
"response": response_text, 'response': response_text,
"usage": usage, 'usage': usage,
} }
+34 -28
View File
@@ -12,51 +12,57 @@ from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
class LoopNode(WorkflowNode): class LoopNode(WorkflowNode):
"""Loop node - iterate over items""" """Loop node - iterate over items"""
type_name = "loop" type_name = 'loop'
category = "control" category = 'control'
icon = "🔁" icon = '🔁'
name = "loop" name = 'loop'
name_zh = "循环" name_zh = '循环'
name_en = "Loop" name_en = 'Loop'
description = "loop" description = 'loop'
description_zh = "遍历项目或重复直到满足条件" description_zh = '遍历项目或重复直到满足条件'
description_en = "Iterate over items or repeat until condition" description_en = 'Iterate over items or repeat until condition'
inputs: ClassVar[list[NodePort]] = [ 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]] = [ outputs: ClassVar[list[NodePort]] = [
NodePort(name="item", type="any", description="Current item in iteration"), NodePort(name='item', type='any', description='Current item in iteration'),
NodePort(name="index", type="number", description="Current iteration index"), NodePort(name='index', type='number', description='Current iteration index'),
NodePort(name="results", type="array", description="All iteration results"), NodePort(name='results', type='array', description='All iteration results'),
NodePort(name="completed", type="boolean", description="Whether loop completed"), NodePort(name='completed', type='boolean', description='Whether loop completed'),
] ]
config_schema: ClassVar[list[NodeConfig]] = [ config_schema: ClassVar[list[NodeConfig]] = [
NodeConfig( NodeConfig(
name="loop_type", type="select", required=True, default="foreach", name='loop_type',
description="Type of loop", type='select',
label={"en_US": "Loop Type", "zh_Hans": "循环类型"}, required=True,
options=["foreach", "while", "count"], default='foreach',
description='Type of loop',
label={'en_US': 'Loop Type', 'zh_Hans': '循环类型'},
options=['foreach', 'while', 'count'],
), ),
NodeConfig( NodeConfig(
name="max_iterations", type="integer", required=False, default=100, name='max_iterations',
description="Maximum iterations (safety limit)", type='integer',
label={"en_US": "Max Iterations", "zh_Hans": "最大迭代次数"}, 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]: 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): if not isinstance(items, list):
items = [items] if items else [] 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] items = items[:max_iterations]
return { return {
"item": items[0] if items else None, 'item': items[0] if items else None,
"index": 0, 'index': 0,
"results": [], 'results': [],
"completed": len(items) == 0, 'completed': len(items) == 0,
"_items": items, '_items': items,
} }
+22 -22
View File
@@ -20,20 +20,20 @@ class MCPToolNode(WorkflowNode):
"""MCP tool node - invoke MCP (Model Context Protocol) tools""" """MCP tool node - invoke MCP (Model Context Protocol) tools"""
# Node type for registration # Node type for registration
type_name = "mcp_tool" type_name = 'mcp_tool'
# Category and icon - these are not i18n # Category and icon - these are not i18n
category = "integration" category = 'integration'
icon = "Wrench" icon = 'Wrench'
# Name and description - i18n handled on frontend side # Name and description - i18n handled on frontend side
# Frontend will use node type key to look up translation # Frontend will use node type key to look up translation
name = "mcp_tool" name = 'mcp_tool'
description = "mcp_tool" description = 'mcp_tool'
name_zh = "MCP 工具" name_zh = 'MCP 工具'
name_en = "MCP Tool" name_en = 'MCP Tool'
description_zh = "调用 MCP 工具" description_zh = '调用 MCP 工具'
description_en = "Invoke an MCP (Model Context Protocol) tool" description_en = 'Invoke an MCP (Model Context Protocol) tool'
# Inputs/outputs/config - loaded from YAML at runtime # Inputs/outputs/config - loaded from YAML at runtime
inputs: ClassVar[list[NodePort]] = [] inputs: ClassVar[list[NodePort]] = []
@@ -50,21 +50,21 @@ class MCPToolNode(WorkflowNode):
Returns: Returns:
Dictionary of output values Dictionary of output values
""" """
server_name = self.get_config("server_name", "") server_name = self.get_config('server_name', '')
tool_name = self.get_config("tool_name", "") tool_name = self.get_config('tool_name', '')
arguments_template = self.get_config("arguments_template", "") arguments_template = self.get_config('arguments_template', '')
timeout = self.get_config("timeout", 30) timeout = self.get_config('timeout', 30)
arguments = inputs.get("arguments", arguments_template) arguments = inputs.get('arguments', arguments_template)
return { return {
"result": None, 'result': None,
"success": False, 'success': False,
"error": f"MCP tool '{server_name}/{tool_name}' not implemented yet", 'error': f"MCP tool '{server_name}/{tool_name}' not implemented yet",
"_debug": { '_debug': {
"server_name": server_name, 'server_name': server_name,
"tool_name": tool_name, 'tool_name': tool_name,
"arguments": arguments, 'arguments': arguments,
"timeout": timeout, 'timeout': timeout,
}, },
} }
+36 -40
View File
@@ -17,31 +17,27 @@ class MemoryHelper:
def __init__(self, memory_dict: dict[str, Any]): def __init__(self, memory_dict: dict[str, Any]):
self._data = memory_dict 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""" """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) 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""" """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 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""" """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) 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""" """List all values in the given scope"""
prefix = f"{scope}:" prefix = f'{scope}:'
return { return {k[len(prefix) :]: v for k, v in self._data.items() if k.startswith(prefix)}
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: def append(self, key: str, value: Any, scope: str = 'execution', ttl: int = 0) -> list:
"""Append a value to a list in memory""" """Append a value to a list in memory"""
current = self.get(key, scope=scope, default=[]) current = self.get(key, scope=scope, default=[])
if isinstance(current, list): if isinstance(current, list):
@@ -56,48 +52,48 @@ class MemoryHelper:
class MemoryStoreNode(WorkflowNode): class MemoryStoreNode(WorkflowNode):
"""Memory store node - store and retrieve from workflow memory""" """Memory store node - store and retrieve from workflow memory"""
type_name = "memory_store" type_name = 'memory_store'
category = "integration" category = 'integration'
icon = "HardDrive" icon = 'HardDrive'
name = "memory_store" name = 'memory_store'
description = "memory_store" description = 'memory_store'
name_zh = "记忆存储" name_zh = '记忆存储'
name_en = "Memory Store" name_en = 'Memory Store'
description_zh = "从工作流记忆中存储和检索数据" description_zh = '从工作流记忆中存储和检索数据'
description_en = "Store and retrieve data from workflow memory" description_en = 'Store and retrieve data from workflow memory'
inputs: ClassVar[list[NodePort]] = [] inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = [] outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = [] config_schema: ClassVar[list[NodeConfig]] = []
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]:
operation = self.get_config("operation", "get") operation = self.get_config('operation', 'get')
key = self.get_config("key", "") key = self.get_config('key', '')
scope = self.get_config("scope", "execution") scope = self.get_config('scope', 'execution')
ttl = self.get_config("ttl", 0) ttl = self.get_config('ttl', 0)
value = inputs.get("value") value = inputs.get('value')
# Wrap context.memory dict with MemoryHelper for structured operations # Wrap context.memory dict with MemoryHelper for structured operations
memory = MemoryHelper(context.memory) memory = MemoryHelper(context.memory)
try: try:
if operation == "get": if operation == 'get':
result = memory.get(key, scope=scope) result = memory.get(key, scope=scope)
return {"result": result, "success": True} return {'result': result, 'success': True}
elif operation == "set": elif operation == 'set':
memory.set(key, value, scope=scope, ttl=ttl) memory.set(key, value, scope=scope, ttl=ttl)
return {"result": value, "success": True} return {'result': value, 'success': True}
elif operation == "delete": elif operation == 'delete':
memory.delete(key, scope=scope) memory.delete(key, scope=scope)
return {"result": None, "success": True} return {'result': None, 'success': True}
elif operation == "append": elif operation == 'append':
result = memory.append(key, value, scope=scope, ttl=ttl) result = memory.append(key, value, scope=scope, ttl=ttl)
return {"result": result, "success": True} return {'result': result, 'success': True}
elif operation == "list": elif operation == 'list':
result = memory.list_all(scope=scope) result = memory.list_all(scope=scope)
return {"result": result, "success": True} return {'result': result, 'success': True}
else: 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: except Exception as e:
return {"result": None, "success": False, "error": str(e)} return {'result': None, 'success': False, 'error': str(e)}
+23 -23
View File
@@ -15,51 +15,51 @@ from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
class MergeNode(WorkflowNode): class MergeNode(WorkflowNode):
"""Merge node - combine multiple inputs""" """Merge node - combine multiple inputs"""
type_name = "merge" type_name = 'merge'
category = "control" category = 'control'
icon = "🔗" icon = '🔗'
name = "merge" name = 'merge'
description = "merge" description = 'merge'
name_zh = "合并" name_zh = '合并'
name_en = "Merge" name_en = 'Merge'
description_zh = "将多个分支合并在一起" description_zh = '将多个分支合并在一起'
description_en = "Merge multiple branches back together" description_en = 'Merge multiple branches back together'
inputs: ClassVar[list[NodePort]] = [] inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = [] outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = [] config_schema: ClassVar[list[NodeConfig]] = []
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]:
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] non_null_values = [v for v in values if v is not None]
if strategy == "object": if strategy == 'object':
merged = {} merged = {}
for i, v in enumerate(non_null_values): for i, v in enumerate(non_null_values):
if isinstance(v, dict): if isinstance(v, dict):
merged.update(v) merged.update(v)
else: else:
merged[f"value_{i}"] = v merged[f'value_{i}'] = v
return {"merged": merged, "array": non_null_values} return {'merged': merged, 'array': non_null_values}
elif strategy == "array": elif strategy == 'array':
return {"merged": non_null_values, "array": non_null_values} 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 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): 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): elif all(isinstance(v, list) for v in non_null_values):
merged_list = [] merged_list = []
for v in non_null_values: for v in non_null_values:
merged_list.extend(v) merged_list.extend(v)
return {"merged": merged_list, "array": merged_list} return {'merged': merged_list, 'array': merged_list}
else: 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}
@@ -17,15 +17,15 @@ from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
class MessageTriggerNode(WorkflowNode): class MessageTriggerNode(WorkflowNode):
"""Message trigger node - triggers workflow on message arrival""" """Message trigger node - triggers workflow on message arrival"""
type_name = "message_trigger" type_name = 'message_trigger'
category = "trigger" category = 'trigger'
icon = "💬" icon = '💬'
name = "message_trigger" name = 'message_trigger'
description = "message_trigger" description = 'message_trigger'
name_zh = "消息触发" name_zh = '消息触发'
name_en = "Message Trigger" name_en = 'Message Trigger'
description_zh = "当收到消息时触发工作流" description_zh = '当收到消息时触发工作流'
description_en = "Trigger workflow when a message is received" description_en = 'Trigger workflow when a message is received'
inputs: ClassVar[list[NodePort]] = [] inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = [] outputs: ClassVar[list[NodePort]] = []
@@ -36,21 +36,21 @@ class MessageTriggerNode(WorkflowNode):
if msg_ctx: if msg_ctx:
return { return {
"message": msg_ctx.message_content, 'message': msg_ctx.message_content,
"sender_id": msg_ctx.sender_id, 'sender_id': msg_ctx.sender_id,
"sender_name": msg_ctx.sender_name, 'sender_name': msg_ctx.sender_name,
"platform": msg_ctx.platform, 'platform': msg_ctx.platform,
"conversation_id": msg_ctx.conversation_id, 'conversation_id': msg_ctx.conversation_id,
"is_group": msg_ctx.is_group, 'is_group': msg_ctx.is_group,
"context": msg_ctx.model_dump(), 'context': msg_ctx.model_dump(),
} }
return { return {
"message": context.get_variable("message", ""), 'message': context.get_variable('message', ''),
"sender_id": context.get_variable("sender_id", ""), 'sender_id': context.get_variable('sender_id', ''),
"sender_name": context.get_variable("sender_name", ""), 'sender_name': context.get_variable('sender_name', ''),
"platform": context.get_variable("platform", ""), 'platform': context.get_variable('platform', ''),
"conversation_id": context.get_variable("conversation_id", ""), 'conversation_id': context.get_variable('conversation_id', ''),
"is_group": context.get_variable("is_group", False), 'is_group': context.get_variable('is_group', False),
"context": context.trigger_data, 'context': context.trigger_data,
} }
+20 -20
View File
@@ -15,33 +15,33 @@ from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
class N8nWorkflowNode(WorkflowNode): class N8nWorkflowNode(WorkflowNode):
"""n8n workflow node - call n8n workflow API""" """n8n workflow node - call n8n workflow API"""
type_name = "n8n_workflow" type_name = 'n8n_workflow'
category = "integration" category = 'integration'
icon = "Workflow" icon = 'Workflow'
name = "n8n_workflow" name = 'n8n_workflow'
description = "n8n_workflow" description = 'n8n_workflow'
name_zh = "n8n 工作流" name_zh = 'n8n 工作流'
name_en = "N8n Workflow" name_en = 'N8n Workflow'
description_zh = "通过 webhook 调用 n8n 工作流" description_zh = '通过 webhook 调用 n8n 工作流'
description_en = "Call an n8n workflow via webhook" description_en = 'Call an n8n workflow via webhook'
inputs: ClassVar[list[NodePort]] = [] inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = [] outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = [] config_schema: ClassVar[list[NodeConfig]] = []
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]:
webhook_url = self.get_config("webhook_url", "") webhook_url = self.get_config('webhook_url', '')
auth_type = self.get_config("auth_type", "none") auth_type = self.get_config('auth_type', 'none')
timeout = self.get_config("timeout", 120) timeout = self.get_config('timeout', 120)
payload = inputs.get("payload", {}) payload = inputs.get('payload', {})
return { return {
"result": None, 'result': None,
"success": False, 'success': False,
"_debug": { '_debug': {
"webhook_url": webhook_url, 'webhook_url': webhook_url,
"auth_type": auth_type, 'auth_type': auth_type,
"timeout": timeout, 'timeout': timeout,
"payload": payload, 'payload': payload,
}, },
} }
@@ -15,23 +15,23 @@ from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
class OpeningStatementNode(WorkflowNode): class OpeningStatementNode(WorkflowNode):
"""Opening statement node - provide conversation opener and suggested questions""" """Opening statement node - provide conversation opener and suggested questions"""
type_name = "opening_statement" type_name = 'opening_statement'
category = "action" category = 'action'
icon = "👋" icon = '👋'
name = "opening_statement" name = 'opening_statement'
description = "opening_statement" description = 'opening_statement'
name_zh = "对话开场白" name_zh = '对话开场白'
name_en = "Opening Statement" name_en = 'Opening Statement'
description_zh = "提供对话开场白和建议问题" description_zh = '提供对话开场白和建议问题'
description_en = "Provide conversation opener and suggested questions" description_en = 'Provide conversation opener and suggested questions'
inputs: ClassVar[list[NodePort]] = [] inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = [] outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = [] config_schema: ClassVar[list[NodeConfig]] = []
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]:
statement = self.get_config("statement", "") statement = self.get_config('statement', '')
suggestions = self.get_config("suggested_questions", []) suggestions = self.get_config('suggested_questions', [])
show = self.get_config("show_suggestions", True) 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 []}
+26 -20
View File
@@ -12,38 +12,44 @@ from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
class ParallelNode(WorkflowNode): class ParallelNode(WorkflowNode):
"""Parallel node - execute multiple branches simultaneously""" """Parallel node - execute multiple branches simultaneously"""
type_name = "parallel" type_name = 'parallel'
category = "control" category = 'control'
icon = "" icon = ''
name = "parallel" name = 'parallel'
name_zh = "并行执行" name_zh = '并行执行'
name_en = "Parallel" name_en = 'Parallel'
description = "parallel" description = 'parallel'
description_zh = "并行执行多个分支" description_zh = '并行执行多个分支'
description_en = "Execute multiple branches in parallel" description_en = 'Execute multiple branches in parallel'
inputs: ClassVar[list[NodePort]] = [ 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]] = [ outputs: ClassVar[list[NodePort]] = [
NodePort(name="results", type="object", description="Combined results from all branches"), NodePort(name='results', type='object', description='Combined results from all branches'),
NodePort(name="errors", type="array", description="Errors from branches (if any)"), NodePort(name='errors', type='array', description='Errors from branches (if any)'),
] ]
config_schema: ClassVar[list[NodeConfig]] = [ config_schema: ClassVar[list[NodeConfig]] = [
NodeConfig( NodeConfig(
name="wait_all", type="boolean", required=False, default=True, name='wait_all',
description="Wait for all branches to complete", type='boolean',
label={"en_US": "Wait for All", "zh_Hans": "等待全部完成"}, required=False,
default=True,
description='Wait for all branches to complete',
label={'en_US': 'Wait for All', 'zh_Hans': '等待全部完成'},
), ),
NodeConfig( NodeConfig(
name="fail_fast", type="boolean", required=False, default=False, name='fail_fast',
description="Stop all branches if any fails", type='boolean',
label={"en_US": "Fail Fast", "zh_Hans": "快速失败"}, 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]: async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
return { return {
"results": {}, 'results': {},
"errors": [], 'errors': [],
} }
@@ -15,25 +15,25 @@ from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
class ParameterExtractorNode(WorkflowNode): class ParameterExtractorNode(WorkflowNode):
"""Parameter extractor node - extract structured parameters from text""" """Parameter extractor node - extract structured parameters from text"""
type_name = "parameter_extractor" type_name = 'parameter_extractor'
category = "process" category = 'process'
icon: str = "📤" icon: str = '📤'
name = "parameter_extractor" name = 'parameter_extractor'
description = "parameter_extractor" description = 'parameter_extractor'
name_zh = "参数提取器" name_zh = '参数提取器'
name_en = "Parameter Extractor" name_en = 'Parameter Extractor'
description_zh = "使用 AI 从文本中提取结构化参数" description_zh = '使用 AI 从文本中提取结构化参数'
description_en = "Extract structured parameters from text using AI" description_en = 'Extract structured parameters from text using AI'
inputs: ClassVar[list[NodePort]] = [] inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = [] outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = [] config_schema: ClassVar[list[NodeConfig]] = []
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]:
param_defs = self.get_config("parameters", []) param_defs = self.get_config('parameters', [])
extracted = {} extracted = {}
for param in param_defs: 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}
@@ -15,28 +15,28 @@ from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
class QuestionClassifierNode(WorkflowNode): class QuestionClassifierNode(WorkflowNode):
"""Question classifier node - classify user questions into categories""" """Question classifier node - classify user questions into categories"""
type_name = "question_classifier" type_name = 'question_classifier'
category = "process" category = 'process'
icon = "🏷️" icon = '🏷️'
name = "question_classifier" name = 'question_classifier'
description = "question_classifier" description = 'question_classifier'
name_zh = "问题分类器" name_zh = '问题分类器'
name_en = "Question Classifier" name_en = 'Question Classifier'
description_zh = "使用 AI 将问题分类到预定义类别" description_zh = '使用 AI 将问题分类到预定义类别'
description_en = "Classify questions into predefined categories using AI" description_en = 'Classify questions into predefined categories using AI'
inputs: ClassVar[list[NodePort]] = [] inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = [] outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = [] config_schema: ClassVar[list[NodeConfig]] = []
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]:
categories = self.get_config("categories", []) categories = self.get_config('categories', [])
if categories: if categories:
return { return {
"category": categories[0].get("name", "unknown"), 'category': categories[0].get('name', 'unknown'),
"confidence": 0.8, 'confidence': 0.8,
"all_scores": {cat.get("name"): 0.1 for cat in categories}, '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': {}}
@@ -15,39 +15,39 @@ from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
class RedisOperationNode(WorkflowNode): class RedisOperationNode(WorkflowNode):
"""Redis operation node - perform Redis cache operations""" """Redis operation node - perform Redis cache operations"""
type_name = "redis_operation" type_name = 'redis_operation'
category = "integration" category = 'integration'
icon = "Server" icon = 'Server'
name = "redis_operation" name = 'redis_operation'
description = "redis_operation" description = 'redis_operation'
name_zh = "Redis 操作" name_zh = 'Redis 操作'
name_en = "Redis Operation" name_en = 'Redis Operation'
description_zh = "执行 Redis 缓存操作" description_zh = '执行 Redis 缓存操作'
description_en = "Perform Redis cache operations" description_en = 'Perform Redis cache operations'
inputs: ClassVar[list[NodePort]] = [] inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = [] outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = [] config_schema: ClassVar[list[NodeConfig]] = []
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]:
connection_url = self.get_config("connection_url", "redis://localhost:6379") connection_url = self.get_config('connection_url', 'redis://localhost:6379')
operation = self.get_config("operation", "get") operation = self.get_config('operation', 'get')
key_template = self.get_config("key_template", "") key_template = self.get_config('key_template', '')
hash_field = self.get_config("hash_field", "") hash_field = self.get_config('hash_field', '')
ttl = self.get_config("ttl", 0) ttl = self.get_config('ttl', 0)
key = inputs.get("key", key_template) key = inputs.get('key', key_template)
value = inputs.get("value") value = inputs.get('value')
return { return {
"result": None, 'result': None,
"success": False, 'success': False,
"_debug": { '_debug': {
"connection_url": connection_url, 'connection_url': connection_url,
"operation": operation, 'operation': operation,
"key": key, 'key': key,
"hash_field": hash_field, 'hash_field': hash_field,
"ttl": ttl, 'ttl': ttl,
"value": value, 'value': value,
}, },
} }
+26 -25
View File
@@ -18,44 +18,44 @@ logger = logging.getLogger(__name__)
class ReplyMessageNode(WorkflowNode): class ReplyMessageNode(WorkflowNode):
"""Reply message node - reply to the triggering message""" """Reply message node - reply to the triggering message"""
type_name = "reply_message" type_name = 'reply_message'
category = "action" category = 'action'
icon = "↩️" icon = '↩️'
name = "reply_message" name = 'reply_message'
description = "reply_message" description = 'reply_message'
name_zh = "回复消息" name_zh = '回复消息'
name_en = "Reply Message" name_en = 'Reply Message'
description_zh = "回复触发工作流的消息" description_zh = '回复触发工作流的消息'
description_en = "Reply to the message that triggered the workflow" description_en = 'Reply to the message that triggered the workflow'
inputs: ClassVar[list[NodePort]] = [] inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = [] outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = [] config_schema: ClassVar[list[NodeConfig]] = []
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]:
message = inputs.get("message") message = inputs.get('message')
if message in (None, ""): if message in (None, ''):
message = inputs.get("input") message = inputs.get('input')
if message in (None, ""): if message in (None, ''):
message = inputs.get("response") message = inputs.get('response')
if message in (None, ""): if message in (None, ''):
message = inputs.get("content") message = inputs.get('content')
if message in (None, "") and context.message_context: if message in (None, '') and context.message_context:
message = context.message_context.message_content message = context.message_context.message_content
if message is None: if message is None:
message = "" message = ''
template = self.get_config("message_template") template = self.get_config('message_template')
if template: if template:
message = template message = template
for key, value in inputs.items(): 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(): 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( logger.info(
"ReplyMessageNode resolved message", 'ReplyMessageNode resolved message',
extra={ extra={
'node_id': self.node_id, 'node_id': self.node_id,
'execution_id': context.execution_id, 'execution_id': context.execution_id,
@@ -68,7 +68,7 @@ class ReplyMessageNode(WorkflowNode):
if not str(message).strip(): if not str(message).strip():
logger.warning( logger.warning(
"ReplyMessageNode has empty message after resolution", 'ReplyMessageNode has empty message after resolution',
extra={ extra={
'node_id': self.node_id, 'node_id': self.node_id,
'execution_id': context.execution_id, 'execution_id': context.execution_id,
@@ -79,6 +79,7 @@ class ReplyMessageNode(WorkflowNode):
# 实际发送消息 # 实际发送消息
if self.ap: if self.ap:
from langbot_plugin.api.entities.builtin.platform.message import MessageChain, Plain from langbot_plugin.api.entities.builtin.platform.message import MessageChain, Plain
message_chain = MessageChain([Plain(text=str(message))]) message_chain = MessageChain([Plain(text=str(message))])
await self.ap.platform_mgr.websocket_proxy_bot.adapter.send_message( await self.ap.platform_mgr.websocket_proxy_bot.adapter.send_message(
target_type='person', target_type='person',
@@ -87,11 +88,11 @@ class ReplyMessageNode(WorkflowNode):
) )
else: else:
logger.warning( logger.warning(
"ReplyMessageNode missing application instance", 'ReplyMessageNode missing application instance',
extra={ extra={
'node_id': self.node_id, 'node_id': self.node_id,
'execution_id': context.execution_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}'}
+10 -10
View File
@@ -15,19 +15,19 @@ from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
class SendMessageNode(WorkflowNode): class SendMessageNode(WorkflowNode):
"""Send message node - send message to a target""" """Send message node - send message to a target"""
type_name = "send_message" type_name = 'send_message'
category = "action" category = 'action'
icon = "📤" icon = '📤'
name = "send_message" name = 'send_message'
description = "send_message" description = 'send_message'
name_zh = "发送消息" name_zh = '发送消息'
name_en = "Send Message" name_en = 'Send Message'
description_zh = "向聊天或用户发送消息" description_zh = '向聊天或用户发送消息'
description_en = "Send a message to a chat or user" description_en = 'Send a message to a chat or user'
inputs: ClassVar[list[NodePort]] = [] inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = [] outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = [] config_schema: ClassVar[list[NodeConfig]] = []
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]:
return {"status": "sent", "message_id": f"msg_{context.execution_id}"} return {'status': 'sent', 'message_id': f'msg_{context.execution_id}'}
+20 -20
View File
@@ -15,50 +15,50 @@ from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
class SetVariableNode(WorkflowNode): class SetVariableNode(WorkflowNode):
"""Set variable node - set workflow or conversation variable""" """Set variable node - set workflow or conversation variable"""
type_name = "set_variable" type_name = 'set_variable'
category = "action" category = 'action'
icon = "📝" icon = '📝'
name = "set_variable" name = 'set_variable'
description = "set_variable" description = 'set_variable'
name_zh = "设置变量" name_zh = '设置变量'
name_en = "Set Variable" name_en = 'Set Variable'
description_zh = "设置上下文变量值" description_zh = '设置上下文变量值'
description_en = "Set a context variable value" description_en = 'Set a context variable value'
inputs: ClassVar[list[NodePort]] = [] inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = [] outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = [] config_schema: ClassVar[list[NodeConfig]] = []
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]:
value = inputs.get("value") value = inputs.get('value')
name = self.get_config("variable_name", "") name = self.get_config('variable_name', '')
scope = self.get_config("variable_scope", "workflow") scope = self.get_config('variable_scope', 'workflow')
operation = self.get_config("operation", "set") operation = self.get_config('operation', 'set')
if scope == "conversation": if scope == 'conversation':
current = context.get_conversation_variable(name) current = context.get_conversation_variable(name)
else: else:
current = context.get_variable(name) current = context.get_variable(name)
if operation == "set": if operation == 'set':
final_value = value final_value = value
elif operation == "append": elif operation == 'append':
if isinstance(current, list): if isinstance(current, list):
final_value = current + [value] final_value = current + [value]
elif isinstance(current, str): elif isinstance(current, str):
final_value = current + str(value) final_value = current + str(value)
else: else:
final_value = [current, value] if current else [value] 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) 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) final_value = (current or 0) - (value if isinstance(value, (int, float)) else 1)
else: else:
final_value = value final_value = value
if scope == "conversation": if scope == 'conversation':
context.set_conversation_variable(name, final_value) context.set_conversation_variable(name, final_value)
else: else:
context.set_variable(name, final_value) context.set_variable(name, final_value)
return {"value": final_value} return {'value': final_value}
+16 -16
View File
@@ -15,31 +15,31 @@ from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
class StoreDataNode(WorkflowNode): class StoreDataNode(WorkflowNode):
"""Store data node - save data to storage""" """Store data node - save data to storage"""
type_name = "store_data" type_name = 'store_data'
category = "action" category = 'action'
icon = "💾" icon = '💾'
name = "store_data" name = 'store_data'
description = "store_data" description = 'store_data'
name_zh = "存储数据" name_zh = '存储数据'
name_en = "Store Data" name_en = 'Store Data'
description_zh = "将数据存储到持久化存储" description_zh = '将数据存储到持久化存储'
description_en = "Store data to persistent storage" description_en = 'Store data to persistent storage'
inputs: ClassVar[list[NodePort]] = [] inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = [] outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = [] config_schema: ClassVar[list[NodeConfig]] = []
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]:
key = inputs.get("key", "") key = inputs.get('key', '')
value = inputs.get("value") value = inputs.get('value')
storage_type = self.get_config("storage_type", "session") storage_type = self.get_config('storage_type', 'session')
prefix = self.get_config("key_prefix", "") 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) context.set_conversation_variable(full_key, value)
else: else:
context.set_variable(full_key, value) context.set_variable(full_key, value)
return {"status": "stored"} return {'status': 'stored'}
+20 -20
View File
@@ -15,42 +15,42 @@ from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
class SwitchNode(WorkflowNode): class SwitchNode(WorkflowNode):
"""Switch node - multi-way branch based on value""" """Switch node - multi-way branch based on value"""
type_name = "switch" type_name = 'switch'
category = "control" category = 'control'
icon = "🔃" icon = '🔃'
name = "switch" name = 'switch'
description = "switch" description = 'switch'
name_zh = "多路分支" name_zh = '多路分支'
name_en = "Switch" name_en = 'Switch'
description_zh = "根据多个条件分支工作流" description_zh = '根据多个条件分支工作流'
description_en = "Branch workflow based on multiple cases" description_en = 'Branch workflow based on multiple cases'
inputs: ClassVar[list[NodePort]] = [] inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = [] outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = [] config_schema: ClassVar[list[NodeConfig]] = []
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]:
expression = self.get_config("expression", "") expression = self.get_config('expression', '')
cases = self.get_config("cases", []) cases = self.get_config('cases', [])
input_data = inputs.get("input") input_data = inputs.get('input')
value = await self._evaluate_expression(expression, input_data, context) value = await self._evaluate_expression(expression, input_data, context)
for case in cases: for case in cases:
if str(case.get("value")) == str(value): if str(case.get('value')) == str(value):
return {"matched_case": input_data, "default": None, "_matched_output": case.get("output")} 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: async def _evaluate_expression(self, expression: str, data: Any, context: ExecutionContext) -> Any:
if not expression: if not expression:
return data return data
if expression.startswith("{{") and expression.endswith("}}"): if expression.startswith('{{') and expression.endswith('}}'):
var_path = expression[2:-2].strip() var_path = expression[2:-2].strip()
parts = var_path.split(".") parts = var_path.split('.')
if parts[0] == "input": if parts[0] == 'input':
result = data result = data
for part in parts[1:]: for part in parts[1:]:
if isinstance(result, dict): if isinstance(result, dict):
@@ -58,7 +58,7 @@ class SwitchNode(WorkflowNode):
else: else:
return None return None
return result return result
elif parts[0] == "variables": elif parts[0] == 'variables':
return context.variables.get(".".join(parts[1:])) return context.variables.get('.'.join(parts[1:]))
return expression return expression
@@ -15,37 +15,37 @@ from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
class VariableAggregatorNode(WorkflowNode): class VariableAggregatorNode(WorkflowNode):
"""Variable aggregator node - aggregate variables from multiple branches""" """Variable aggregator node - aggregate variables from multiple branches"""
type_name = "variable_aggregator" type_name = 'variable_aggregator'
category = "control" category = 'control'
icon = "📊" icon = '📊'
name = "variable_aggregator" name = 'variable_aggregator'
description = "variable_aggregator" description = 'variable_aggregator'
name_zh = "变量聚合器" name_zh = '变量聚合器'
name_en = "Variable Aggregator" name_en = 'Variable Aggregator'
description_zh = "聚合多个分支的变量输出" description_zh = '聚合多个分支的变量输出'
description_en = "Aggregate variable outputs from multiple branches" description_en = 'Aggregate variable outputs from multiple branches'
inputs: ClassVar[list[NodePort]] = [] inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = [] outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = [] config_schema: ClassVar[list[NodeConfig]] = []
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]:
variables = inputs.get("variables", {}) variables = inputs.get('variables', {})
mode = self.get_config("aggregation_mode", "merge") mode = self.get_config('aggregation_mode', 'merge')
aggregated = {} aggregated = {}
if mode == "merge": if mode == 'merge':
if isinstance(variables, dict): if isinstance(variables, dict):
aggregated.update(variables) aggregated.update(variables)
elif mode == "override": elif mode == 'override':
if isinstance(variables, dict): if isinstance(variables, dict):
aggregated = variables.copy() aggregated = variables.copy()
elif mode == "append": elif mode == 'append':
for key, value in (variables if isinstance(variables, dict) else {}).items(): for key, value in (variables if isinstance(variables, dict) else {}).items():
if key in aggregated and isinstance(aggregated[key], list): if key in aggregated and isinstance(aggregated[key], list):
aggregated[key].append(value) aggregated[key].append(value)
else: else:
aggregated[key] = [value] aggregated[key] = [value]
return {"aggregated": aggregated} return {'aggregated': aggregated}
+14 -14
View File
@@ -15,15 +15,15 @@ from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
class WaitNode(WorkflowNode): class WaitNode(WorkflowNode):
"""Wait node - pause execution for a duration""" """Wait node - pause execution for a duration"""
type_name = "wait" type_name = 'wait'
category = "control" category = 'control'
icon = "" icon = ''
name = "wait" name = 'wait'
description = "wait" description = 'wait'
name_zh = "等待" name_zh = '等待'
name_en = "Wait" name_en = 'Wait'
description_zh = "暂停工作流执行指定时间" description_zh = '暂停工作流执行指定时间'
description_en = "Pause workflow execution for a specified duration" description_en = 'Pause workflow execution for a specified duration'
inputs: ClassVar[list[NodePort]] = [] inputs: ClassVar[list[NodePort]] = []
outputs: 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]: async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
import asyncio import asyncio
duration = self.get_config("duration", 1) duration = self.get_config('duration', 1)
duration_type = self.get_config("duration_type", "seconds") duration_type = self.get_config('duration_type', 'seconds')
if duration_type == "minutes": if duration_type == 'minutes':
duration *= 60 duration *= 60
elif duration_type == "hours": elif duration_type == 'hours':
duration *= 3600 duration *= 3600
await asyncio.sleep(duration) await asyncio.sleep(duration)
return {"output": inputs.get("input")} return {'output': inputs.get('input')}
@@ -15,15 +15,15 @@ from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
class WebhookTriggerNode(WorkflowNode): class WebhookTriggerNode(WorkflowNode):
"""Webhook trigger node - triggers workflow via HTTP request""" """Webhook trigger node - triggers workflow via HTTP request"""
type_name = "webhook_trigger" type_name = 'webhook_trigger'
category = "trigger" category = 'trigger'
icon = "🌐" icon = '🌐'
name = "webhook_trigger" name = 'webhook_trigger'
description = "webhook_trigger" description = 'webhook_trigger'
name_zh = "Webhook 触发" name_zh = 'Webhook 触发'
name_en = "Webhook Trigger" name_en = 'Webhook Trigger'
description_zh = "通过 HTTP 请求触发工作流" description_zh = '通过 HTTP 请求触发工作流'
description_en = "Trigger workflow via HTTP webhook" description_en = 'Trigger workflow via HTTP webhook'
inputs: ClassVar[list[NodePort]] = [] inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = [] outputs: ClassVar[list[NodePort]] = []
@@ -33,8 +33,8 @@ class WebhookTriggerNode(WorkflowNode):
trigger_data = context.trigger_data trigger_data = context.trigger_data
return { return {
"body": trigger_data.get("body", {}), 'body': trigger_data.get('body', {}),
"headers": trigger_data.get("headers", {}), 'headers': trigger_data.get('headers', {}),
"query": trigger_data.get("query", {}), 'query': trigger_data.get('query', {}),
"method": trigger_data.get("method", "POST"), 'method': trigger_data.get('method', 'POST'),
} }
+7 -12
View File
@@ -1,4 +1,5 @@
"""Node type registry""" """Node type registry"""
from __future__ import annotations from __future__ import annotations
from typing import Any, Optional from typing import Any, Optional
@@ -84,7 +85,9 @@ class NodeTypeRegistry:
return None 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.""" """Create a node instance. Supports both 'category.type_name' and short 'type_name' formats."""
node_class = self.get(node_type) node_class = self.get(node_type)
if node_class: if node_class:
@@ -98,10 +101,7 @@ class NodeTypeRegistry:
Returns: Returns:
List of node schemas List of node schemas
""" """
return [ return [node_class.to_schema() for node_class in self._nodes.values()]
node_class.to_schema()
for node_class in self._nodes.values()
]
def list_by_category(self, category: str) -> list[dict[str, Any]]: def list_by_category(self, category: str) -> list[dict[str, Any]]:
""" """
@@ -116,9 +116,7 @@ class NodeTypeRegistry:
if category not in self._categories: if category not in self._categories:
return [] return []
return [ return [
self._nodes[node_type].to_schema() self._nodes[node_type].to_schema() for node_type in self._categories[category] if node_type in self._nodes
for node_type in self._categories[category]
if node_type in self._nodes
] ]
def get_categories(self) -> dict[str, list[dict[str, Any]]]: def get_categories(self) -> dict[str, list[dict[str, Any]]]:
@@ -128,10 +126,7 @@ class NodeTypeRegistry:
Returns: Returns:
Dictionary mapping category names to lists of node schemas Dictionary mapping category names to lists of node schemas
""" """
return { return {category: self.list_by_category(category) for category in self._categories.keys()}
category: self.list_by_category(category)
for category in self._categories.keys()
}
def has_type(self, node_type: str) -> bool: def has_type(self, node_type: str) -> bool:
"""Check if a node type is registered. Supports both formats.""" """Check if a node type is registered. Supports both formats."""
+8 -12
View File
@@ -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 allowed variable names so that expressions like ``input == "hello"`` or
``data.x > 3`` work without resorting to :func:`eval`. ``data.x > 3`` work without resorting to :func:`eval`.
""" """
from __future__ import annotations from __future__ import annotations
import ast 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] return {'None': None, 'True': True, 'False': False}[node.id]
if node.id in variables: if node.id in variables:
return variables[node.id] 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) # Attribute access: obj.attr (only on allowed variables)
if isinstance(node, ast.Attribute): 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): if isinstance(node, ast.UnaryOp):
op_fn = _SAFE_OPS.get(type(node.op)) op_fn = _SAFE_OPS.get(type(node.op))
if op_fn is None: 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)) return op_fn(_eval_node(node.operand, variables))
# Binary operators # Binary operators
if isinstance(node, ast.BinOp): if isinstance(node, ast.BinOp):
op_fn = _SAFE_OPS.get(type(node.op)) op_fn = _SAFE_OPS.get(type(node.op))
if op_fn is None: 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)) return op_fn(_eval_node(node.left, variables), _eval_node(node.right, variables))
# Comparisons (chained) # 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): for op, comparator in zip(node.ops, node.comparators):
op_fn = _SAFE_OPS.get(type(op)) op_fn = _SAFE_OPS.get(type(op))
if op_fn is None: 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) right = _eval_node(comparator, variables)
if not op_fn(left, right): if not op_fn(left, right):
return False return False
@@ -132,9 +133,7 @@ def _eval_node(node: ast.AST, variables: dict[str, Any]) -> Any:
# Ternary # Ternary
if isinstance(node, ast.IfExp): if isinstance(node, ast.IfExp):
return ( return (
_eval_node(node.body, variables) _eval_node(node.body, variables) if _eval_node(node.test, variables) else _eval_node(node.orelse, variables)
if _eval_node(node.test, variables)
else _eval_node(node.orelse, variables)
) )
# Tuples / Lists (e.g. ``x in [1, 2, 3]``) # 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}``) # Dict literals (e.g. ``{"a": 1}``)
if isinstance(node, ast.Dict): if isinstance(node, ast.Dict):
return { return {_eval_node(k, variables): _eval_node(v, variables) for k, v in zip(node.keys, node.values)}
_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__}')