Compare commits

...

26 Commits

Author SHA1 Message Date
Typer_Body
192b69b0fb fix 2026-06-02 02:29:35 +08:00
Typer_Body
543fbd8ca0 new 2026-06-02 02:08:07 +08:00
Typer_Body
804448b6cd yaml 2026-05-27 00:24:07 +08:00
Typer_Body
5d4e40459f shit 2026-05-26 02:28:01 +08:00
Typer_Body
b5c43cc113 new nofe、、 2026-05-23 03:21:31 +08:00
Typer_Body
8ebfcd963a new node 2026-05-23 02:58:17 +08:00
Typer_Body
127198675e end3 2026-05-23 01:31:42 +08:00
Typer_Body
44fb188994 end1 2026-05-23 01:00:10 +08:00
Typer_Body
265385a563 end 2026-05-23 00:51:24 +08:00
Typer_Body
253cc6cbea shit 2026-05-22 02:07:48 +08:00
Typer_Body
f99d3022e8 Merge master into feat/workflow: resolve conflicts by keeping workflow branch changes 2026-05-21 00:51:09 +08:00
Typer_Body
5c5614667a change 2026-05-20 02:49:44 +08:00
Typer_Body
313d553271 bezier 2026-05-18 02:00:31 +08:00
Typer_Body
bb7db53447 backend 2026-05-18 01:47:13 +08:00
Typer_Body
27c0d344bf unified standard 2026-05-16 00:09:57 +08:00
Typer_Body
c088dc114f clean the shit of ai 2026-05-15 02:11:21 +08:00
Typer_Body
37c74b0622 update icon i18n 2026-05-15 01:05:29 +08:00
Typer_Body
c9f7911efe update 2026-05-08 01:27:45 +08:00
Typer_Body
75fdfe6806 ruff 2026-05-08 00:56:27 +08:00
Typer_Body
eb9f38b102 针对已被移除的路由规则功能的。根据 botmgr.py:74-100 中的注释,路由规则已被移除,Bot 现在直接绑定到 Pipeline 或 Workflow。 2026-05-08 00:53:35 +08:00
Typer_Body
fc40d3c949 ruff 2026-05-07 23:33:54 +08:00
Typer_Body
d176a448e0 Merge remote-tracking branch 'origin/master' into feat/workflow 2026-05-07 00:57:56 +08:00
Typer_Body
ada4c30f85 1111 2026-05-06 01:03:34 +08:00
Typer_Body
32c9eaff45 还是修不好 2026-05-05 16:16:33 +08:00
Typer_Body
9706ee2d53 没修好 2026-05-05 16:16:04 +08:00
Typer_Body
e7c9bc69d3 后端没修完版 2026-05-05 15:08:04 +08:00
169 changed files with 37527 additions and 956 deletions

163
compare_nodes.py Normal file
View File

@@ -0,0 +1,163 @@
#!/usr/bin/env python3
"""Compare YAML node definitions with frontend node-configs."""
import yaml
import os
import re
import json
# 1. Parse YAML files
yaml_dir = 'src/langbot/templates/metadata/nodes'
yaml_nodes = {}
for filename in sorted(os.listdir(yaml_dir)):
if filename.endswith('.yaml'):
filepath = os.path.join(yaml_dir, filename)
with open(filepath, 'r') as f:
data = yaml.safe_load(f)
node_name = data.get('name', filename.replace('.yaml', ''))
yaml_nodes[node_name] = {
'category': data.get('category', ''),
'inputs': [i['name'] for i in data.get('inputs', [])],
'outputs': [o['name'] for o in data.get('outputs', [])],
'config': [c['name'] for c in data.get('config', [])]
}
# 2. Parse frontend node-configs TypeScript files
node_configs_dir = 'web/src/app/home/workflows/components/workflow-editor/node-configs'
frontend_nodes = {}
def parse_ts_file(filepath):
"""Parse a TypeScript file to extract node configurations."""
with open(filepath, 'r') as f:
content = f.read()
# Find all node type definitions
# Pattern: nodeType: 'xxx'
node_type_pattern = r"nodeType:\s*'([^']+)'"
node_types = re.findall(node_type_pattern, content)
# For each node type, extract inputs, outputs, and config
for node_type in node_types:
# Find the config object for this node type
# Look for the section between this nodeType and the next one or end of object
pattern = rf"nodeType:\s*'({re.escape(node_type)})'.*?(?=nodeType:|export\s+(const|function)|$)"
match = re.search(pattern, content, re.DOTALL)
if match:
section = match.group(0)
# Extract inputs
inputs = re.findall(r"createInput\('([^']+)'", section)
# Extract outputs
outputs = re.findall(r"createOutput\('([^']+)'", section)
# Extract config names
config_names = re.findall(r"name:\s*'([^']+)'", section)
# Remove duplicates while preserving order
seen = set()
unique_config = []
for c in config_names:
if c not in seen:
seen.add(c)
unique_config.append(c)
frontend_nodes[node_type] = {
'inputs': inputs,
'outputs': outputs,
'config': unique_config
}
# Parse all config files
for filename in os.listdir(node_configs_dir):
if filename.endswith('.ts') and filename != 'types.ts' and filename != 'index.ts':
filepath = os.path.join(node_configs_dir, filename)
parse_ts_file(filepath)
# 3. Compare and report differences
print("=" * 80)
print("WORKFLOW NODE COMPARISON REPORT: YAML vs Frontend")
print("=" * 80)
all_node_types = sorted(set(list(yaml_nodes.keys()) + list(frontend_nodes.keys())))
discrepancies = []
for node_type in all_node_types:
yaml_def = yaml_nodes.get(node_type)
frontend_def = frontend_nodes.get(node_type)
node_discrepancies = []
if not yaml_def:
print(f"\n⚠️ {node_type}: ONLY in frontend (not in YAML)")
continue
if not frontend_def:
print(f"\n⚠️ {node_type}: ONLY in YAML (not in frontend)")
continue
# Compare inputs
yaml_inputs = set(yaml_def['inputs'])
frontend_inputs = set(frontend_def['inputs'])
if yaml_inputs != frontend_inputs:
only_yaml = yaml_inputs - frontend_inputs
only_frontend = frontend_inputs - yaml_inputs
node_discrepancies.append({
'type': 'inputs',
'only_yaml': list(only_yaml),
'only_frontend': list(only_frontend)
})
# Compare outputs
yaml_outputs = set(yaml_def['outputs'])
frontend_outputs = set(frontend_def['outputs'])
if yaml_outputs != frontend_outputs:
only_yaml = yaml_outputs - frontend_outputs
only_frontend = frontend_outputs - yaml_outputs
node_discrepancies.append({
'type': 'outputs',
'only_yaml': list(only_yaml),
'only_frontend': list(only_frontend)
})
# Compare config
yaml_config = set(yaml_def['config'])
frontend_config = set(frontend_def['config'])
if yaml_config != frontend_config:
only_yaml = yaml_config - frontend_config
only_frontend = frontend_config - yaml_config
node_discrepancies.append({
'type': 'config',
'only_yaml': list(only_yaml),
'only_frontend': list(only_frontend)
})
if node_discrepancies:
print(f"\n{node_type} ({yaml_def['category']}): HAS DISCREPANCIES")
for d in node_discrepancies:
print(f" {d['type']}:")
if d['only_yaml']:
print(f" Only in YAML: {d['only_yaml']}")
if d['only_frontend']:
print(f" Only in Frontend: {d['only_frontend']}")
discrepancies.append((node_type, node_discrepancies))
else:
print(f"\n{node_type} ({yaml_def['category']}): OK")
print(f"\n{'=' * 80}")
print(f"SUMMARY: {len(discrepancies)} nodes with discrepancies out of {len(all_node_types)} total")
print(f"{'=' * 80}")
# Output as JSON for further processing
output = {
'yaml_nodes': {k: v for k, v in yaml_nodes.items()},
'frontend_nodes': {k: v for k, v in frontend_nodes.items()},
'discrepancies': {k: v for k, v in discrepancies}
}
with open('node_comparison.json', 'w') as f:
json.dump(output, f, indent=2)
print(f"\nDetailed comparison saved to node_comparison.json")

View File

@@ -0,0 +1,713 @@
# Workflow 系统开发者文档
本文档面向 LangBot 开发者,详细介绍 Workflow 系统的技术架构、核心组件和扩展方法。
## 目录
- [系统架构概述](#系统架构概述)
- [目录结构](#目录结构)
- [核心组件](#核心组件)
- [后端模块](#后端模块)
- [前端组件](#前端组件)
- [数据库表结构](#数据库表结构)
- [API 接口文档](#api-接口文档)
- [如何添加新节点类型](#如何添加新节点类型)
- [调试功能实现](#调试功能实现)
---
## 系统架构概述
Workflow 系统采用前后端分离架构,主要包含以下层次:
```
┌─────────────────────────────────────────────────────────────┐
│ 前端层 (React) │
│ ┌─────────────┬──────────────┬──────────────┬───────────┐ │
│ │ 可视化编辑器 │ 节点面板 │ 属性面板 │ 调试器 │ │
│ │ ReactFlow │ NodePalette │ PropertyPanel│ Debugger │ │
│ └─────────────┴──────────────┴──────────────┴───────────┘ │
├─────────────────────────────────────────────────────────────┤
│ API 层 (Quart) │
│ ┌─────────────┬──────────────┬──────────────────────────┐ │
│ │ Workflow API│ Debug API │ Node Types API │ │
│ └─────────────┴──────────────┴──────────────────────────┘ │
├─────────────────────────────────────────────────────────────┤
│ 核心引擎层 (Python) │
│ ┌─────────────┬──────────────┬──────────────┬───────────┐ │
│ │ Executor │ Registry │ Node │ Entities │ │
│ │ 执行引擎 │ 节点注册表 │ 节点基类 │ 数据结构 │ │
│ └─────────────┴──────────────┴──────────────┴───────────┘ │
├─────────────────────────────────────────────────────────────┤
│ 存储层 (SQLAlchemy) │
│ ┌─────────────┬──────────────┬──────────────────────────┐ │
│ │ Workflow │ Executions │ Triggers │ │
│ └─────────────┴──────────────┴──────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
---
## 目录结构
### 后端代码结构
```
LangBot/src/langbot/pkg/
├── workflow/ # Workflow 核心模块
│ ├── __init__.py # 模块初始化,导出公共接口
│ ├── entities.py # 数据实体定义
│ ├── executor.py # 执行引擎
│ ├── node.py # 节点基类和装饰器
│ ├── registry.py # 节点类型注册表
│ └── nodes/ # 内置节点实现
│ ├── __init__.py # 注册所有内置节点
│ ├── trigger.py # 触发节点
│ ├── process.py # 处理节点
│ ├── control.py # 控制节点
│ └── action.py # 动作节点
├── entity/persistence/
│ └── workflow.py # 数据库模型
├── api/http/
│ ├── controller/groups/workflows/
│ │ └── workflows.py # API 路由控制器
│ └── service/
│ └── workflow.py # 业务逻辑服务
└── persistence/migrations/
└── dbm026_workflow_tables.py # 数据库迁移
```
### 前端代码结构
```
LangBot/web/src/app/home/workflows/
├── page.tsx # Workflow 列表页
├── WorkflowDetailContent.tsx # 详情页内容
├── store/
│ └── useWorkflowStore.ts # Zustand 状态管理
└── components/
├── workflow-editor/ # 可视化编辑器
│ ├── index.ts # 导出
│ ├── WorkflowEditorComponent.tsx # 主编辑器组件
│ ├── WorkflowNodeComponent.tsx # 自定义节点组件
│ ├── NodePalette.tsx # 节点面板
│ ├── PropertyPanel.tsx # 属性面板
│ └── node-configs/ # 节点配置元数据
│ ├── types.ts # 配置类型定义
│ ├── trigger-configs.ts
│ ├── ai-configs.ts
│ ├── process-configs.ts
│ ├── control-configs.ts
│ ├── action-configs.ts
│ ├── integration-configs.ts
│ └── index.ts # 配置汇总
├── workflow-debugger/ # 调试器组件
│ ├── index.ts
│ └── WorkflowDebugger.tsx
├── workflow-form/ # 表单组件
│ └── WorkflowFormComponent.tsx
└── workflow-executions/ # 执行历史组件
└── WorkflowExecutionsTab.tsx
```
---
## 核心组件
### 后端模块
#### 1. 执行引擎 (WorkflowExecutor)
位置:[`executor.py`](../../src/langbot/pkg/workflow/executor.py)
执行引擎负责工作流的实际执行,包括:
- **拓扑排序**:确定节点执行顺序
- **节点执行**:调用各节点的 execute 方法
- **控制流处理**:处理条件分支、循环、并行执行
- **错误处理**:支持重试机制
```python
class WorkflowExecutor:
async def execute(
self,
workflow: WorkflowDefinition,
context: ExecutionContext,
start_node_id: Optional[str] = None
) -> ExecutionContext:
"""执行工作流"""
# 1. 构建执行图
# 2. 初始化节点状态
# 3. 找到起始节点
# 4. 按拓扑顺序执行
```
**调试执行器 (DebugWorkflowExecutor)**
继承自 WorkflowExecutor增加了调试支持
- 断点支持
- 单步执行
- 暂停/继续
- 实时日志
```python
class DebugWorkflowExecutor(WorkflowExecutor):
async def execute_debug(
self,
workflow: WorkflowDefinition,
context: ExecutionContext,
debug_state: DebugExecutionState,
) -> ExecutionContext:
"""调试模式执行"""
```
#### 2. 节点注册表 (NodeTypeRegistry)
位置:[`registry.py`](../../src/langbot/pkg/workflow/registry.py)
单例模式管理所有节点类型:
```python
class NodeTypeRegistry:
_instance: Optional['NodeTypeRegistry'] = None
def register(self, node_type: str, node_class: type[WorkflowNode]):
"""注册节点类型"""
def create_instance(self, node_type: str, node_id: str, config: dict) -> WorkflowNode:
"""创建节点实例"""
def list_all(self) -> list[dict]:
"""获取所有节点类型的 Schema"""
```
#### 3. 节点基类 (WorkflowNode)
位置:[`node.py`](../../src/langbot/pkg/workflow/node.py)
所有节点必须继承此基类:
```python
class WorkflowNode(abc.ABC):
# 节点元数据
type_name: str = ""
name: str = ""
description: str = ""
category: str = "misc"
icon: str = ""
# 端口定义
inputs: list[NodePort] = []
outputs: list[NodePort] = []
# 配置 Schema
config_schema: list[NodeConfig] = []
@abc.abstractmethod
async def execute(
self,
inputs: dict[str, Any],
context: ExecutionContext
) -> dict[str, Any]:
"""执行节点逻辑"""
pass
```
#### 4. 数据实体 (entities.py)
主要数据结构:
```python
class WorkflowDefinition:
"""工作流定义"""
uuid: str
name: str
nodes: list[NodeDefinition]
edges: list[EdgeDefinition]
settings: WorkflowSettings
class ExecutionContext:
"""执行上下文"""
execution_id: str
workflow_id: str
status: ExecutionStatus
variables: dict
node_states: dict[str, NodeState]
history: list[ExecutionStep]
```
### 前端组件
#### 1. WorkflowEditorComponent
主编辑器组件,基于 React Flow 实现:
- **画布交互**:拖拽、缩放、平移
- **节点连接**:自动验证端口类型
- **撤销/重做**:基于历史记录栈
- **复制/粘贴**:支持多选复制
关键功能:
```tsx
function WorkflowEditorInner() {
const { nodes, edges, onNodesChange, onEdgesChange, onConnect } = useWorkflowStore();
// 拖放添加节点
const onDrop = useCallback((event: React.DragEvent) => {
const type = event.dataTransfer.getData('application/reactflow');
const position = screenToFlowPosition({ x: event.clientX, y: event.clientY });
addNode(type, position);
}, []);
// 复制粘贴
const handleCopy = useCallback(() => { ... }, []);
const handlePaste = useCallback(() => { ... }, []);
}
```
#### 2. NodePalette
节点面板组件,展示可用节点类型:
```tsx
function NodePalette() {
// 按类别组织节点
const categories = [
{ id: 'trigger', name: '触发节点', icon: Zap },
{ id: 'ai', name: 'AI 节点', icon: Brain },
{ id: 'process', name: '处理节点', icon: Cpu },
{ id: 'control', name: '控制节点', icon: GitBranch },
{ id: 'action', name: '动作节点', icon: Send },
{ id: 'integration', name: '集成节点', icon: Plug },
];
// 拖拽开始
const onDragStart = (event: React.DragEvent, nodeType: string) => {
event.dataTransfer.setData('application/reactflow', nodeType);
};
}
```
#### 3. PropertyPanel
属性面板组件,动态渲染节点配置表单:
```tsx
function PropertyPanel() {
const { selectedNodeId, nodes, updateNodeData } = useWorkflowStore();
// 根据节点类型获取配置元数据
const selectedNode = nodes.find(n => n.id === selectedNodeId);
const nodeConfig = getNodeConfig(selectedNode?.data?.nodeType);
// 动态渲染配置字段
return (
<div>
{nodeConfig?.fields.map(field => (
<ConfigField key={field.name} field={field} />
))}
</div>
);
}
```
#### 4. WorkflowDebugger
调试器组件,支持实时调试:
```tsx
function WorkflowDebugger({ workflowUuid, workflow }) {
const [debugState, setDebugState] = useState<DebugState>('idle');
const [executionId, setExecutionId] = useState<string>('');
const [logs, setLogs] = useState<ExecutionLog[]>([]);
// 启动调试
const startDebug = async () => {
const result = await backendClient.post(
`/api/v1/workflows/${workflowUuid}/debug/start`,
{ context, variables, breakpoints }
);
setExecutionId(result.execution_id);
};
// 轮询状态
useEffect(() => {
if (debugState === 'running') {
const interval = setInterval(fetchState, 500);
return () => clearInterval(interval);
}
}, [debugState]);
}
```
#### 5. useWorkflowStore
Zustand 状态管理:
```typescript
interface WorkflowState {
nodes: WorkflowNode[];
edges: WorkflowEdge[];
selectedNodeId: string | null;
history: HistoryEntry[];
historyIndex: number;
isDirty: boolean;
// Actions
addNode: (type: string, position: XYPosition) => void;
updateNodeData: (nodeId: string, data: Partial<NodeData>) => void;
deleteNode: (nodeId: string) => void;
undo: () => void;
redo: () => void;
}
export const useWorkflowStore = create<WorkflowState>((set, get) => ({
// ... state and actions
}));
```
---
## 数据库表结构
### workflows 表
```sql
CREATE TABLE workflows (
uuid VARCHAR(255) PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT,
emoji VARCHAR(10) DEFAULT '🔄',
version INTEGER DEFAULT 1,
is_enabled BOOLEAN DEFAULT TRUE,
definition JSON NOT NULL, -- 节点和边定义
global_config JSON DEFAULT '{}', -- 全局配置
extensions_preferences JSON, -- 插件和 MCP 配置
created_at TIMESTAMP,
updated_at TIMESTAMP
);
```
### workflow_versions 表
```sql
CREATE TABLE workflow_versions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workflow_uuid VARCHAR(255) NOT NULL,
version INTEGER NOT NULL,
definition JSON NOT NULL,
global_config JSON DEFAULT '{}',
created_at TIMESTAMP,
created_by VARCHAR(255),
UNIQUE(workflow_uuid, version)
);
```
### workflow_executions 表
```sql
CREATE TABLE workflow_executions (
uuid VARCHAR(255) PRIMARY KEY,
workflow_uuid VARCHAR(255) NOT NULL,
workflow_version INTEGER NOT NULL,
status VARCHAR(20) NOT NULL, -- pending/running/completed/failed/cancelled
trigger_type VARCHAR(50),
trigger_data JSON,
variables JSON,
start_time TIMESTAMP,
end_time TIMESTAMP,
error TEXT,
created_at TIMESTAMP
);
```
### workflow_node_executions 表
```sql
CREATE TABLE workflow_node_executions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
execution_uuid VARCHAR(255) NOT NULL,
node_id VARCHAR(100) NOT NULL,
node_type VARCHAR(50) NOT NULL,
status VARCHAR(20) NOT NULL,
inputs JSON,
outputs JSON,
start_time TIMESTAMP,
end_time TIMESTAMP,
error TEXT,
retry_count INTEGER DEFAULT 0
);
```
### workflow_triggers 表
```sql
CREATE TABLE workflow_triggers (
uuid VARCHAR(255) PRIMARY KEY,
workflow_uuid VARCHAR(255) NOT NULL,
type VARCHAR(50) NOT NULL, -- message/cron/event/webhook
config JSON NOT NULL,
is_enabled BOOLEAN DEFAULT TRUE,
priority INTEGER DEFAULT 0,
created_at TIMESTAMP,
updated_at TIMESTAMP
);
```
---
## API 接口文档
### Workflow CRUD
| 方法 | 路径 | 描述 |
|-----|------|------|
| GET | `/api/v1/workflows` | 获取工作流列表 |
| POST | `/api/v1/workflows` | 创建工作流 |
| GET | `/api/v1/workflows/:uuid` | 获取单个工作流 |
| PUT | `/api/v1/workflows/:uuid` | 更新工作流 |
| DELETE | `/api/v1/workflows/:uuid` | 删除工作流 |
| POST | `/api/v1/workflows/:uuid/copy` | 复制工作流 |
### 执行相关
| 方法 | 路径 | 描述 |
|-----|------|------|
| POST | `/api/v1/workflows/:uuid/execute` | 手动执行工作流 |
| GET | `/api/v1/workflows/:uuid/executions` | 获取执行记录 |
### 版本管理
| 方法 | 路径 | 描述 |
|-----|------|------|
| GET | `/api/v1/workflows/:uuid/versions` | 获取版本列表 |
| POST | `/api/v1/workflows/:uuid/rollback/:version` | 回滚到指定版本 |
### 调试 API
| 方法 | 路径 | 描述 |
|-----|------|------|
| POST | `/api/v1/workflows/:uuid/debug/start` | 启动调试 |
| POST | `/api/v1/workflows/:uuid/debug/:exec_id/pause` | 暂停执行 |
| POST | `/api/v1/workflows/:uuid/debug/:exec_id/resume` | 继续执行 |
| POST | `/api/v1/workflows/:uuid/debug/:exec_id/stop` | 停止执行 |
| POST | `/api/v1/workflows/:uuid/debug/:exec_id/step` | 单步执行 |
| GET | `/api/v1/workflows/:uuid/debug/:exec_id/state` | 获取调试状态 |
### 节点类型
| 方法 | 路径 | 描述 |
|-----|------|------|
| GET | `/api/v1/workflows/_/node-types` | 获取所有节点类型 |
| GET | `/api/v1/workflows/_/node-types/categories` | 按类别获取节点类型 |
---
## 如何添加新节点类型
### 步骤 1创建节点类
`LangBot/src/langbot/pkg/workflow/nodes/` 下创建或修改文件:
```python
from ..node import WorkflowNode, NodePort, NodeConfig, workflow_node
from ..entities import ExecutionContext
@workflow_node('my_custom_node')
class MyCustomNode(WorkflowNode):
"""自定义节点"""
# 元数据
type_name = 'my_custom_node'
name = '我的自定义节点'
description = '这是一个自定义节点'
category = 'process' # trigger/process/control/action/integration
icon = '🔧'
# 输入端口
inputs = [
NodePort(name='input', type='string', description='输入数据', required=True),
]
# 输出端口
outputs = [
NodePort(name='output', type='string', description='输出数据'),
]
# 配置字段
config_schema = [
NodeConfig(
name='option',
type='select',
required=True,
options=['选项A', '选项B'],
description='选择一个选项'
),
NodeConfig(
name='value',
type='string',
required=False,
default='默认值',
description='配置值'
),
]
async def execute(
self,
inputs: dict[str, Any],
context: ExecutionContext
) -> dict[str, Any]:
"""执行节点逻辑"""
input_data = inputs.get('input', '')
option = self.get_config('option')
value = self.get_config('value', '')
# 处理逻辑
result = f"处理: {input_data} with {option} and {value}"
return {'output': result}
```
### 步骤 2注册节点
`LangBot/src/langbot/pkg/workflow/nodes/__init__.py` 中导入:
```python
from .process import (
CodeExecutorNode,
HttpRequestNode,
DataTransformNode,
MyCustomNode, # 添加新节点
)
```
### 步骤 3添加前端配置
`LangBot/web/src/app/home/workflows/components/workflow-editor/node-configs/` 目录下添加配置:
```typescript
// process-configs.ts
export const processNodeConfigs: NodeConfigMap = {
// ... 其他配置
my_custom_node: {
type: 'my_custom_node',
label: 'workflows.nodes.myCustomNode',
description: 'workflows.nodes.myCustomNodeDesc',
icon: 'Wrench',
category: 'process',
fields: [
{
name: 'option',
type: 'select',
label: 'workflows.fields.option',
required: true,
options: [
{ value: '选项A', label: '选项 A' },
{ value: '选项B', label: '选项 B' },
],
},
{
name: 'value',
type: 'string',
label: 'workflows.fields.value',
required: false,
defaultValue: '默认值',
},
],
},
};
```
### 步骤 4添加国际化
`LangBot/web/src/i18n/locales/` 中添加翻译:
```typescript
// zh-Hans.ts
workflows: {
nodes: {
myCustomNode: '我的自定义节点',
myCustomNodeDesc: '这是一个自定义节点',
},
fields: {
option: '选项',
value: '值',
},
}
```
---
## 调试功能实现
### 后端调试状态管理
```python
class DebugExecutionState:
"""调试执行状态"""
def __init__(self, execution_id: str, breakpoints: list[str] = None):
self.execution_id = execution_id
self.status: str = 'running'
self.is_paused: bool = False
self.is_stopped: bool = False
self.breakpoints: set[str] = set(breakpoints or [])
self.logs: list[ExecutionLog] = []
self._pause_event = asyncio.Event()
def pause(self):
"""暂停执行"""
self.is_paused = True
self._pause_event.clear()
def resume(self):
"""继续执行"""
self.is_paused = False
self._pause_event.set()
async def wait_if_paused(self):
"""如果暂停则等待"""
if self.is_paused:
await self._pause_event.wait()
```
### 前端调试流程
1. **设置断点**:点击节点设置断点
2. **启动调试**:调用 `/debug/start` 启动调试执行
3. **轮询状态**:定期调用 `/debug/:id/state` 获取状态
4. **控制执行**:调用 pause/resume/step/stop 控制执行
5. **查看日志**:实时显示执行日志和节点状态
```typescript
// 调试状态轮询
const fetchDebugState = async () => {
const state = await backendClient.get(
`/api/v1/workflows/${workflowUuid}/debug/${executionId}/state`
);
// 更新节点状态
setNodeStates(state.node_states);
// 追加新日志
if (state.new_logs.length > 0) {
setLogs(prev => [...prev, ...state.new_logs]);
}
// 检查完成状态
if (state.status === 'completed' || state.status === 'error') {
setDebugState('idle');
}
};
```
---
## 扩展阅读
- [Workflow 功能设计文档](../../../plans/langbot-workflow-design.md)
- [用户使用指南](../user-guide/workflow-guide.md)
- [API 认证文档](../API_KEY_AUTH.md)

View File

@@ -0,0 +1,425 @@
# Workflow 用户指南
本文档帮助您了解和使用 LangBot 的 Workflow工作流功能通过可视化方式构建自动化的对话处理流程。
## 目录
- [功能介绍](#功能介绍)
- [快速入门](#快速入门)
- [节点类型说明](#节点类型说明)
- [编辑器使用指南](#编辑器使用指南)
- [调试功能](#调试功能)
- [常见问题解答](#常见问题解答)
---
## 功能介绍
### 什么是 Workflow
Workflow工作流是 LangBot 提供的可视化自动化编排系统。通过拖拽节点、连接边的方式,您可以:
- 📝 **构建复杂的对话流程**:使用条件分支、循环等控制节点
- 🤖 **调用 AI 能力**:集成 LLM、知识库检索、参数提取
- 🔗 **连接外部服务**:集成 Dify、n8n、Coze 等平台
-**自动化任务执行**消息触发、定时触发、Webhook 触发
### Workflow vs Pipeline
| 对比项 | Pipeline | Workflow |
|-------|----------|----------|
| 配置方式 | 表单配置 | 可视化拖拽 |
| 流程控制 | 线性执行 | 支持分支、循环、并行 |
| 适用场景 | 简单对话 | 复杂流程 |
| 学习曲线 | 低 | 中等 |
---
## 快速入门
### 第一步:创建 Workflow
1. 在侧边栏点击 **Workflow** 进入工作流列表
2. 点击右上角 **创建工作流** 按钮
3. 填写基本信息:
- **名称**:给工作流起一个描述性的名字
- **描述**:可选,说明工作流的用途
- **图标**:选择一个 emoji 作为标识
### 第二步:添加节点
进入编辑器后,左侧是节点面板,中间是画布区域,右侧是属性面板。
1. **添加触发节点**:从左侧面板拖拽一个"消息触发"节点到画布
2. **添加 AI 节点**:拖拽一个"LLM 调用"节点
3. **添加回复节点**:拖拽一个"回复消息"节点
### 第三步:连接节点
1. 将鼠标悬停在触发节点的输出端口(右侧小圆点)
2. 按住鼠标拖拽到 LLM 节点的输入端口(左侧小圆点)
3. 同样方式连接 LLM 节点和回复节点
```
[消息触发] ──▶ [LLM 调用] ──▶ [回复消息]
```
### 第四步:配置节点
点击 LLM 调用节点,在右侧属性面板配置:
- **运行方式**:选择"本地 Agent"
- **系统提示词**:描述 AI 的角色和行为
- **模型**:选择要使用的 LLM 模型
点击回复消息节点配置:
- **消息内容**:设置为 `{{nodes.llm_call.outputs.response}}`(引用 LLM 输出)
### 第五步:保存并绑定
1. 点击工具栏的 **保存** 按钮
2. 返回 Bot 配置页面
3. 在 Bot 的绑定设置中选择 **Workflow**,然后选择刚创建的工作流
恭喜!您已经创建了第一个 Workflow。
---
## 节点类型说明
### 触发节点 (Trigger)
触发节点是工作流的入口,定义何时启动执行。
| 节点 | 说明 | 输出 |
|-----|------|------|
| 消息触发 | 收到消息时触发 | message, sender_id, platform |
| 定时触发 | 按 Cron 表达式定时触发 | timestamp |
| Webhook 触发 | 收到 HTTP 请求时触发 | request_body, headers |
| 事件触发 | 系统事件触发 | event_type, event_data |
**消息触发配置示例**
```yaml
触发条件:
- 关键词匹配: ["帮助", "help"]
- 平台: ["wechat", "qq"]
```
### AI 节点
AI 节点用于调用各种 AI 能力。
| 节点 | 说明 | 典型用途 |
|-----|------|---------|
| LLM 调用 | 调用大语言模型 | 生成回复、理解意图 |
| 问题分类器 | 对用户问题分类 | 路由到不同处理分支 |
| 参数提取器 | 从文本提取结构化数据 | 提取订单号、日期等 |
| 知识库检索 | 查询知识库 | RAG 增强回复 |
**LLM 调用配置示例**
```yaml
运行方式: 本地 Agent
模型: gpt-4
系统提示词: |
你是一个友好的客服助手。
请根据用户的问题提供帮助。
温度: 0.7
最大 Token 数: 2000
```
### 处理节点 (Process)
处理节点用于数据处理和外部调用。
| 节点 | 说明 | 典型用途 |
|-----|------|---------|
| 代码执行 | 执行 Python/JavaScript 代码 | 数据处理、格式转换 |
| HTTP 请求 | 发送 HTTP 请求 | 调用外部 API |
| 数据转换 | JSON/模板转换 | 数据格式化 |
**HTTP 请求配置示例**
```yaml
URL: https://api.example.com/data
方法: POST
请求头:
Content-Type: application/json
Authorization: Bearer {{variables.api_key}}
请求体: |
{"query": "{{message.content}}"}
```
### 控制节点 (Control)
控制节点用于流程控制。
| 节点 | 说明 | 用途 |
|-----|------|------|
| 条件分支 | 二选一分支 | if-else 逻辑 |
| 多路分支 | 多选一分支 | switch-case 逻辑 |
| 循环 | 遍历数组 | 批量处理 |
| 并行 | 同时执行多分支 | 并发处理 |
| 等待 | 暂停执行 | 延时处理 |
| 合并 | 合并多个分支 | 汇总结果 |
**条件分支配置示例**
```yaml
条件表达式: "{{nodes.classifier.outputs.category}}" == "complaint"
真分支: 投诉处理
假分支: 普通咨询
```
### 动作节点 (Action)
动作节点执行具体操作。
| 节点 | 说明 | 用途 |
|-----|------|------|
| 发送消息 | 主动发送消息 | 通知、推送 |
| 回复消息 | 回复当前消息 | 对话回复 |
| 存储数据 | 保存数据到存储 | 持久化 |
| 调用 Pipeline | 调用现有 Pipeline | 复用现有流程 |
**回复消息配置示例**
```yaml
消息内容: |
感谢您的咨询!
{{nodes.llm_call.outputs.response}}
如有其他问题,随时联系我。
```
### 集成节点 (Integration)
集成节点连接外部平台。
| 节点 | 说明 | 平台 |
|-----|------|------|
| Dify 工作流 | 调用 Dify 应用 | Dify |
| Dify 知识库 | 查询 Dify 知识库 | Dify |
| n8n 工作流 | 调用 n8n 流程 | n8n |
| Langflow | 调用 Langflow 流程 | Langflow |
| Coze Bot | 调用扣子 Bot | Coze |
**Dify 工作流配置示例**
```yaml
API 地址: https://api.dify.ai/v1
API Key: sk-xxxxx
应用类型: workflow
同步对话历史: true
```
---
## 编辑器使用指南
### 画布操作
| 操作 | 方式 |
|-----|------|
| 平移画布 | 按住鼠标中键/空格+左键 拖拽 |
| 缩放画布 | 鼠标滚轮 / 工具栏按钮 |
| 框选多个节点 | 按住 Shift + 拖拽框选 |
| 适应视图 | 点击工具栏"适应"按钮 |
### 节点操作
| 操作 | 方式 |
|-----|------|
| 添加节点 | 从左侧面板拖拽到画布 |
| 移动节点 | 点击节点拖拽 |
| 删除节点 | 选中后按 Delete / 点击工具栏删除 |
| 复制节点 | 选中后 Ctrl+C / 工具栏复制 |
| 粘贴节点 | Ctrl+V / 工具栏粘贴 |
### 连接操作
| 操作 | 方式 |
|-----|------|
| 创建连接 | 从输出端口拖拽到输入端口 |
| 删除连接 | 点击连接线后按 Delete |
| 选中连接 | 点击连接线 |
### 快捷键
| 快捷键 | 功能 |
|-------|------|
| Ctrl + Z | 撤销 |
| Ctrl + Shift + Z | 重做 |
| Ctrl + C | 复制 |
| Ctrl + V | 粘贴 |
| Delete | 删除选中 |
| Ctrl + S | 保存 |
### 工具栏功能
```
[撤销] [重做] | [放大] [缩小] [适应] | [复制] [粘贴] [删除] | [保存] [调试]
```
---
## 调试功能
### 启动调试
1. 点击工具栏的 **调试** 按钮
2. 在调试面板中配置初始数据:
- **输入消息**:模拟用户发送的消息
- **会话 ID**:可选,用于测试会话变量
- **变量**:设置初始变量值
3. 点击 **开始调试** 按钮
### 调试控制
| 按钮 | 功能 |
|-----|------|
| ▶️ 开始/继续 | 开始或继续执行 |
| ⏸️ 暂停 | 暂停执行 |
| ⏹️ 停止 | 停止执行 |
| ⏭️ 单步 | 执行下一个节点 |
### 断点
- **设置断点**:点击节点上的断点图标
- **断点触发**:执行到断点时自动暂停
- **查看状态**:在暂停时查看节点的输入输出
### 执行日志
调试面板下方显示实时日志:
```
[INFO] 2024-01-15 10:30:00 - Starting debug execution
[INFO] 2024-01-15 10:30:00 - Executing node: message_trigger
[DEBUG] 2024-01-15 10:30:00 - Node inputs: {"message": "你好"}
[INFO] 2024-01-15 10:30:01 - Node completed in 50ms
[INFO] 2024-01-15 10:30:01 - Executing node: llm_call
...
```
### 节点状态颜色
| 颜色 | 状态 |
|-----|------|
| 灰色 | 待执行 |
| 蓝色 | 执行中 |
| 绿色 | 已完成 |
| 红色 | 失败 |
| 黄色 | 已跳过 |
---
## 常见问题解答
### Q1如何在节点间传递数据
使用表达式语法引用其他节点的输出:
```
{{nodes.节点ID.outputs.输出名称}}
```
例如:
- `{{nodes.llm_call.outputs.response}}` - 引用 LLM 节点的响应
- `{{nodes.http_request.outputs.body}}` - 引用 HTTP 请求的响应体
### Q2如何使用变量
Workflow 支持三种变量类型:
1. **工作流变量**`{{variables.变量名}}`
2. **会话变量**`{{conversation_variables.变量名}}`
3. **消息上下文**`{{message.content}}``{{message.sender_id}}`
### Q3条件分支如何写条件表达式
支持以下运算符:
- 比较:`==`, `!=`, `>`, `<`, `>=`, `<=`
- 逻辑:`and`, `or`, `not`
- 包含:`in`
示例:
```python
# 字符串比较
"{{nodes.classifier.outputs.intent}}" == "purchase"
# 数值比较
{{nodes.extractor.outputs.amount}} > 1000
# 包含检查
"退款" in "{{message.content}}"
```
### Q4如何处理错误
1. **节点级重试**:在节点配置中设置重试次数
2. **全局错误处理**:在 Workflow 设置中配置错误处理策略
3. **条件分支**:使用条件节点检查上一节点的状态
### Q5如何查看执行历史
1. 进入 Workflow 详情页
2. 点击 **执行历史** 标签
3. 查看每次执行的状态、耗时、输入输出
### Q6Workflow 可以被多个 Bot 使用吗?
是的。一个 Workflow 可以被多个 Bot 绑定使用,但每个 Bot 只能绑定一个处理单元Pipeline 或 Workflow
### Q7如何复制现有的 Workflow
在 Workflow 列表页,点击工作流卡片右上角的菜单,选择"复制"即可创建副本。
### Q8支持版本回滚吗
支持。每次保存都会创建新版本。在 Workflow 详情页可以查看版本历史并回滚到指定版本。
---
## 最佳实践
### 1. 合理命名
- 为节点和 Workflow 使用描述性名称
- 使用统一的命名规范
### 2. 模块化设计
- 将复杂流程拆分为多个小 Workflow
- 使用"调用 Pipeline"节点复用现有流程
### 3. 错误处理
- 为关键节点设置重试机制
- 使用条件分支处理异常情况
- 添加日志记录便于排查问题
### 4. 测试先行
- 使用调试功能充分测试
- 准备多种测试场景
- 检查边界情况
### 5. 性能优化
- 避免不必要的节点
- 使用并行节点提高效率
- 合理设置超时时间
---
## 更多资源
- [开发者文档](../development/workflow-system.md)
- [设计文档](../../../plans/langbot-workflow-design.md)
- [API 文档](../service-api-openapi.json)

1468
node_comparison.json Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -70,7 +70,7 @@ dependencies = [
"chromadb>=1.0.0,<2.0.0",
"qdrant-client (>=1.15.1,<2.0.0)",
"pyseekdb==1.1.0.post3",
"langbot-plugin==0.3.11",
"langbot-plugin @ file:///home/typer/Desktop/langbot-plugin-sdk",
"asyncpg>=0.30.0",
"line-bot-sdk>=3.19.0",
"matrix-nio>=0.25.2",

View File

@@ -0,0 +1,5 @@
# Workflow router group
from .workflows import WorkflowsRouterGroup, ExecutionsRouterGroup
from .websocket_chat import WorkflowWebSocketChatRouterGroup
__all__ = ['WorkflowsRouterGroup', 'ExecutionsRouterGroup', 'WorkflowWebSocketChatRouterGroup']

View File

@@ -0,0 +1,260 @@
"""Workflow WebSocket聊天路由 - 支持工作流调试的双向实时通信"""
import asyncio
import datetime
import json
import logging
import quart
from ... import group
from ......platform.sources.websocket_manager import ws_connection_manager
logger = logging.getLogger(__name__)
@group.group_class('workflow_websocket_chat', '/api/v1/workflows/<workflow_uuid>/ws')
class WorkflowWebSocketChatRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
@self.quart_app.websocket(self.path + '/connect')
async def workflow_websocket_connect(workflow_uuid: str):
"""
建立工作流WebSocket连接
URL参数:
- workflow_uuid: 工作流UUID
- session_type: 会话类型 (person/group)
"""
try:
session_type = quart.websocket.args.get('session_type', 'person')
logger.info(
'Workflow WebSocket connect request received',
extra={
'workflow_uuid': workflow_uuid,
'session_type': session_type,
'path': quart.websocket.path,
'query_string': quart.websocket.query_string.decode('utf-8', errors='ignore'),
'remote_addr': getattr(quart.websocket, 'remote_addr', None),
'user_agent': quart.websocket.headers.get('User-Agent', ''),
'host': quart.websocket.headers.get('Host', ''),
'origin': quart.websocket.headers.get('Origin', ''),
},
)
if session_type not in ['person', 'group']:
await quart.websocket.send(
json.dumps({'type': 'error', 'message': 'session_type must be person or group'})
)
return
websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter
if not websocket_adapter:
logger.warning(
'Workflow WebSocket adapter missing',
extra={
'workflow_uuid': workflow_uuid,
'session_type': session_type,
},
)
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'WebSocket adapter not found'}))
return
connection = await ws_connection_manager.add_connection(
websocket=quart.websocket._get_current_object(),
pipeline_uuid=workflow_uuid,
session_type=session_type,
metadata={'user_agent': quart.websocket.headers.get('User-Agent', ''), 'is_workflow': True},
)
await quart.websocket.send(
json.dumps(
{
'type': 'connected',
'connection_id': connection.connection_id,
'workflow_uuid': workflow_uuid,
'session_type': session_type,
'timestamp': connection.created_at.isoformat(),
}
)
)
logger.debug(
f'Workflow WebSocket connection established: {connection.connection_id} '
f'(workflow={workflow_uuid}, session_type={session_type})'
)
receive_task = asyncio.create_task(self._handle_receive(connection, websocket_adapter))
send_task = asyncio.create_task(self._handle_send(connection))
try:
await asyncio.gather(receive_task, send_task)
except Exception as e:
logger.error(f'Workflow WebSocket task execution error: {e}')
finally:
await ws_connection_manager.remove_connection(connection.connection_id)
logger.debug(f'Workflow WebSocket connection cleaned: {connection.connection_id}')
except Exception as e:
logger.error(
'Workflow WebSocket connection error',
exc_info=True,
extra={
'workflow_uuid': workflow_uuid,
'session_type': quart.websocket.args.get('session_type', 'person'),
'path': quart.websocket.path,
'query_string': quart.websocket.query_string.decode('utf-8', errors='ignore'),
'remote_addr': getattr(quart.websocket, 'remote_addr', None),
},
)
try:
await quart.websocket.send(json.dumps({'type': 'error', 'message': str(e)}))
except Exception as send_error:
logger.debug(
'Failed to send error message to workflow websocket client',
exc_info=True,
extra={
'workflow_uuid': workflow_uuid,
'send_error': str(send_error),
},
)
@self.route('/messages/<session_type>', methods=['GET'])
async def get_messages(workflow_uuid: str, session_type: str) -> str:
"""获取工作流消息历史"""
try:
if session_type not in ['person', 'group']:
return self.http_status(400, -1, 'session_type must be person or group')
websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter
if not websocket_adapter:
return self.http_status(404, -1, 'WebSocket adapter not found')
messages = websocket_adapter.get_websocket_messages(workflow_uuid, session_type)
return self.success(data={'messages': messages})
except Exception as e:
return self.http_status(500, -1, f'Internal server error: {str(e)}')
@self.route('/reset/<session_type>', methods=['POST'])
async def reset_session(workflow_uuid: str, session_type: str) -> str:
"""重置工作流会话"""
try:
if session_type not in ['person', 'group']:
return self.http_status(400, -1, 'session_type must be person or group')
websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter
if not websocket_adapter:
return self.http_status(404, -1, 'WebSocket adapter not found')
websocket_adapter.reset_session(workflow_uuid, session_type)
return self.success(data={'message': 'Session reset successfully'})
except Exception as e:
return self.http_status(500, -1, f'Internal server error: {str(e)}')
@self.route('/connections', methods=['GET'])
async def get_connections(workflow_uuid: str) -> str:
"""获取当前工作流连接统计"""
try:
stats = ws_connection_manager.get_stats()
connections = await ws_connection_manager.get_connections_by_pipeline(workflow_uuid)
return self.success(
data={
'stats': stats,
'connections': [
{
'connection_id': conn.connection_id,
'session_type': conn.session_type,
'created_at': conn.created_at.isoformat(),
'last_active': conn.last_active.isoformat(),
'is_active': conn.is_active,
}
for conn in connections
],
}
)
except Exception as e:
return self.http_status(500, -1, f'Internal server error: {str(e)}')
@self.route('/broadcast', methods=['POST'])
async def broadcast_message(workflow_uuid: str) -> str:
"""向所有工作流连接广播消息"""
try:
data = await quart.request.get_json()
message = data.get('message')
if not message:
return self.http_status(400, -1, 'message is required')
broadcast_data = {
'type': 'broadcast',
'message': message,
'timestamp': datetime.datetime.now().isoformat(),
}
await ws_connection_manager.broadcast_to_pipeline(workflow_uuid, broadcast_data)
return self.success(data={'message': 'Broadcast sent successfully'})
except Exception as e:
return self.http_status(500, -1, f'Internal server error: {str(e)}')
async def _handle_receive(self, connection, websocket_adapter):
"""处理接收消息的任务"""
try:
while connection.is_active:
message = await quart.websocket.receive()
await ws_connection_manager.update_activity(connection.connection_id)
try:
data = json.loads(message)
message_type = data.get('type', 'message')
if message_type == 'ping':
await connection.send_queue.put(
{'type': 'pong', 'timestamp': datetime.datetime.now().isoformat()}
)
elif message_type == 'message':
logger.debug(f'收到工作流消息: {data} from {connection.connection_id}')
await websocket_adapter.handle_websocket_message(connection, data)
elif message_type == 'disconnect':
logger.debug(f'Client disconnected: {connection.connection_id}')
break
else:
logger.warning(f'Unknown message type: {message_type}')
except json.JSONDecodeError:
logger.error(f'Invalid JSON message: {message}')
await connection.send_queue.put({'type': 'error', 'message': 'Invalid JSON format'})
except Exception as e:
logger.error(f'Receive message error: {e}', exc_info=True)
finally:
connection.is_active = False
async def _handle_send(self, connection):
"""处理发送消息的任务"""
try:
while connection.is_active:
try:
message = await asyncio.wait_for(connection.send_queue.get(), timeout=1.0)
await quart.websocket.send(json.dumps(message))
except asyncio.TimeoutError:
continue
except Exception as e:
logger.error(f'Send message error: {e}', exc_info=True)
finally:
connection.is_active = False

View File

@@ -0,0 +1,482 @@
from __future__ import annotations
import quart
from ... import group
from ....service.workflow import WorkflowExecutionFailedError
@group.group_class('workflows', '/api/v1/workflows')
class WorkflowsRouterGroup(group.RouterGroup):
"""Workflow API router group"""
async def initialize(self) -> None:
# Workflow CRUD
@self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _() -> str:
if quart.request.method == 'GET':
sort_by = quart.request.args.get('sort_by', 'created_at')
sort_order = quart.request.args.get('sort_order', 'DESC')
enabled_only = quart.request.args.get('enabled_only', 'false').lower() == 'true'
return self.success(
data={'workflows': await self.ap.workflow_service.get_workflows(sort_by, sort_order, enabled_only)}
)
elif quart.request.method == 'POST':
json_data = await quart.request.json
workflow_uuid = await self.ap.workflow_service.create_workflow(json_data)
return self.success(data={'uuid': workflow_uuid})
# Get node types (available nodes for the editor)
@self.route('/_/node-types', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _() -> str:
return self.success(
data={
'node_types': await self.ap.workflow_service.get_node_types(),
'categories': await self.ap.workflow_service.get_node_types_by_category_meta(),
}
)
# Get node types by category
@self.route('/_/node-types/categories', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _() -> str:
return self.success(data={'categories': await self.ap.workflow_service.get_node_types_by_category()})
# Single workflow operations
@self.route(
'/<workflow_uuid>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY
)
async def _(workflow_uuid: str) -> str:
if quart.request.method == 'GET':
workflow = await self.ap.workflow_service.get_workflow(workflow_uuid)
if workflow is None:
return self.http_status(404, -1, 'workflow not found')
return self.success(data={'workflow': workflow})
elif quart.request.method == 'PUT':
json_data = await quart.request.json
try:
await self.ap.workflow_service.update_workflow(workflow_uuid, json_data)
return self.success()
except ValueError as e:
return self.http_status(404, -1, str(e))
elif quart.request.method == 'DELETE':
await self.ap.workflow_service.delete_workflow(workflow_uuid)
return self.success()
# Publish workflow (enable)
@self.route('/<workflow_uuid>/publish', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _(workflow_uuid: str) -> str:
try:
await self.ap.workflow_service.publish_workflow(workflow_uuid)
return self.success()
except ValueError as e:
return self.http_status(404, -1, str(e))
# Unpublish workflow (disable)
@self.route('/<workflow_uuid>/unpublish', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _(workflow_uuid: str) -> str:
try:
await self.ap.workflow_service.unpublish_workflow(workflow_uuid)
return self.success()
except ValueError as e:
return self.http_status(404, -1, str(e))
# Copy workflow
@self.route('/<workflow_uuid>/copy', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _(workflow_uuid: str) -> str:
try:
new_uuid = await self.ap.workflow_service.copy_workflow(workflow_uuid)
return self.success(data={'uuid': new_uuid})
except ValueError as e:
return self.http_status(404, -1, str(e))
# Execute workflow manually
@self.route('/<workflow_uuid>/execute', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _(workflow_uuid: str) -> str:
json_data = await quart.request.json or {}
trigger_data = json_data.get('trigger_data', {})
session_id = json_data.get('session_id')
user_id = json_data.get('user_id')
bot_id = json_data.get('bot_id')
try:
execution_id = await self.ap.workflow_service.execute_workflow(
workflow_uuid,
trigger_type='manual',
trigger_data=trigger_data,
session_id=session_id,
user_id=user_id,
bot_id=bot_id,
)
return self.success(data={'execution_id': execution_id})
except ValueError as e:
return self.http_status(404, -1, str(e))
except WorkflowExecutionFailedError as e:
return self.http_status(500, -1, e.message)
# Get workflow executions
@self.route('/<workflow_uuid>/executions', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _(workflow_uuid: str) -> str:
limit = int(quart.request.args.get('limit', 50))
offset = int(quart.request.args.get('offset', 0))
executions = await self.ap.workflow_service.get_executions(
workflow_uuid=workflow_uuid, limit=limit, offset=offset
)
return self.success(data=executions)
@self.route(
'/<workflow_uuid>/executions/<execution_uuid>',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
)
async def _(workflow_uuid: str, execution_uuid: str) -> str:
execution = await self.ap.workflow_service.get_execution(execution_uuid)
if execution is None:
return self.http_status(404, -1, 'execution not found')
if execution.get('workflow_uuid') != workflow_uuid:
return self.http_status(404, -1, 'execution not found in workflow')
return self.success(data={'execution': execution})
# Get workflow versions
@self.route('/<workflow_uuid>/versions', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _(workflow_uuid: str) -> str:
versions = await self.ap.workflow_service.get_versions(workflow_uuid)
return self.success(data={'versions': versions})
# Rollback to a specific version
@self.route(
'/<workflow_uuid>/rollback/<int:version>', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY
)
async def _(workflow_uuid: str, version: int) -> str:
try:
await self.ap.workflow_service.rollback_to_version(workflow_uuid, version)
return self.success()
except ValueError as e:
return self.http_status(404, -1, str(e))
# Workflow extensions (plugins and MCP servers)
@self.route(
'/<workflow_uuid>/extensions', methods=['GET', 'PUT'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY
)
async def _(workflow_uuid: str) -> str:
if quart.request.method == 'GET':
workflow = await self.ap.workflow_service.get_workflow(workflow_uuid)
if workflow is None:
return self.http_status(404, -1, 'workflow not found')
# Get available plugins and MCP servers
pipeline_component_kinds = ['Command', 'EventListener', 'Tool']
plugins = await self.ap.plugin_connector.list_plugins(component_kinds=pipeline_component_kinds)
mcp_servers = await self.ap.mcp_service.get_mcp_servers(contain_runtime_info=True)
extensions_prefs = workflow.get('extensions_preferences', {})
return self.success(
data={
'enable_all_plugins': extensions_prefs.get('enable_all_plugins', True),
'enable_all_mcp_servers': extensions_prefs.get('enable_all_mcp_servers', True),
'bound_plugins': extensions_prefs.get('plugins', []),
'available_plugins': plugins,
'bound_mcp_servers': extensions_prefs.get('mcp_servers', []),
'available_mcp_servers': mcp_servers,
}
)
elif quart.request.method == 'PUT':
json_data = await quart.request.json
enable_all_plugins = json_data.get('enable_all_plugins', True)
enable_all_mcp_servers = json_data.get('enable_all_mcp_servers', True)
bound_plugins = json_data.get('bound_plugins', [])
bound_mcp_servers = json_data.get('bound_mcp_servers', [])
try:
await self.ap.workflow_service.update_workflow_extensions(
workflow_uuid, bound_plugins, bound_mcp_servers, enable_all_plugins, enable_all_mcp_servers
)
return self.success()
except ValueError as e:
return self.http_status(404, -1, str(e))
# Debug API - Start debug execution
@self.route('/<workflow_uuid>/debug/start', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _(workflow_uuid: str) -> str:
json_data = await quart.request.json or {}
context = json_data.get('context', {})
variables = json_data.get('variables', {})
breakpoints = json_data.get('breakpoints', [])
try:
execution_id = await self.ap.workflow_service.start_debug_execution(
workflow_uuid, context=context, variables=variables, breakpoints=breakpoints
)
return self.success(data={'execution_id': execution_id})
except ValueError as e:
return self.http_status(404, -1, str(e))
# Debug API - Pause execution
@self.route(
'/<workflow_uuid>/debug/<execution_uuid>/pause',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
)
async def _(workflow_uuid: str, execution_uuid: str) -> str:
try:
await self.ap.workflow_service.pause_debug_execution(workflow_uuid, execution_uuid)
return self.success()
except ValueError as e:
return self.http_status(404, -1, str(e))
# Debug API - Resume execution
@self.route(
'/<workflow_uuid>/debug/<execution_uuid>/resume',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
)
async def _(workflow_uuid: str, execution_uuid: str) -> str:
try:
await self.ap.workflow_service.resume_debug_execution(workflow_uuid, execution_uuid)
return self.success()
except ValueError as e:
return self.http_status(404, -1, str(e))
# Debug API - Step execution
@self.route(
'/<workflow_uuid>/debug/<execution_uuid>/step',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
)
async def _(workflow_uuid: str, execution_uuid: str) -> str:
try:
result = await self.ap.workflow_service.step_debug_execution(workflow_uuid, execution_uuid)
return self.success(data=result)
except ValueError as e:
return self.http_status(404, -1, str(e))
# Debug API - Stop execution
@self.route(
'/<workflow_uuid>/debug/<execution_uuid>/stop',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
)
async def _(workflow_uuid: str, execution_uuid: str) -> str:
try:
await self.ap.workflow_service.stop_debug_execution(workflow_uuid, execution_uuid)
return self.success()
except ValueError as e:
return self.http_status(404, -1, str(e))
# Debug API - Get debug state
@self.route(
'/<workflow_uuid>/debug/<execution_uuid>/state',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
)
async def _(workflow_uuid: str, execution_uuid: str) -> str:
try:
state = await self.ap.workflow_service.get_debug_state(workflow_uuid, execution_uuid)
return self.success(data=state)
except ValueError as e:
return self.http_status(404, -1, str(e))
# Get execution logs
@self.route(
'/<workflow_uuid>/executions/<execution_uuid>/logs',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
)
async def _(workflow_uuid: str, execution_uuid: str) -> str:
limit = int(quart.request.args.get('limit', 100))
offset = int(quart.request.args.get('offset', 0))
try:
result = await self.ap.workflow_service.get_execution_logs(workflow_uuid, execution_uuid, limit, offset)
return self.success(data=result)
except ValueError as e:
return self.http_status(404, -1, str(e))
# Rerun execution
@self.route(
'/<workflow_uuid>/executions/<execution_uuid>/rerun',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
)
async def _(workflow_uuid: str, execution_uuid: str) -> str:
try:
new_execution_id = await self.ap.workflow_service.rerun_execution(workflow_uuid, execution_uuid)
return self.success(data={'execution_uuid': new_execution_id})
except ValueError as e:
return self.http_status(404, -1, str(e))
# Get workflow statistics
@self.route('/<workflow_uuid>/stats', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _(workflow_uuid: str) -> str:
try:
stats = await self.ap.workflow_service.get_workflow_stats(workflow_uuid)
return self.success(data=stats)
except ValueError as e:
return self.http_status(404, -1, str(e))
# LLM Node Performance Test Endpoint
# Tests each step of LLM node execution with detailed timing
@self.route('/_/test/llm-node', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _() -> str:
"""Test LLM node performance with detailed step-by-step timing.
Request body:
{
"model_uuid": "uuid-of-model",
"system_prompt": "optional system prompt",
"user_prompt": "test message",
"temperature": 0.7,
"max_tokens": 100
}
Response includes timing for each step:
- model_fetch: Time to get model from model_mgr
- prompt_build: Time to build messages
- llm_call: Time for actual LLM invocation
- total: Total time
- usage: Token usage information
"""
import time
json_data = await quart.request.json
if not json_data:
return self.http_status(400, -1, 'Request body is required')
model_uuid = json_data.get('model_uuid', '')
if not model_uuid:
return self.http_status(400, -1, 'model_uuid is required')
user_prompt = json_data.get('user_prompt', 'test')
system_prompt = json_data.get('system_prompt', '')
temperature = json_data.get('temperature')
max_tokens = json_data.get('max_tokens', 0)
timings = {}
errors = []
# Step 1: Model fetch
t_start = time.perf_counter()
try:
runtime_model = await self.ap.model_mgr.get_model_by_uuid(model_uuid)
timings['model_fetch_ms'] = round((time.perf_counter() - t_start) * 1000, 2)
timings['model_found'] = True
timings['model_name'] = runtime_model.model_entity.name if runtime_model else None
except Exception as e:
timings['model_fetch_ms'] = round((time.perf_counter() - t_start) * 1000, 2)
timings['model_found'] = False
errors.append(f'Model fetch failed: {str(e)}')
return self.http_status(400, -1, {
'error': errors[0],
'timings': timings,
})
# Step 2: Build messages
t_start = time.perf_counter()
import langbot_plugin.api.entities.builtin.provider.message as provider_message
messages = []
if system_prompt:
messages.append(provider_message.Message(role='system', content=system_prompt))
messages.append(provider_message.Message(role='user', content=user_prompt))
timings['prompt_build_ms'] = round((time.perf_counter() - t_start) * 1000, 2)
# Step 3: Build extra args
extra_args = {}
if temperature is not None:
extra_args['temperature'] = float(temperature)
if max_tokens and int(max_tokens) > 0:
extra_args['max_tokens'] = int(max_tokens)
# Step 4: LLM call
t_start = time.perf_counter()
try:
result_message = await runtime_model.provider.invoke_llm(
query=None,
model=runtime_model,
messages=messages,
funcs=None,
extra_args=extra_args,
)
timings['llm_call_ms'] = round((time.perf_counter() - t_start) * 1000, 2)
timings['llm_call_success'] = True
# Extract response text
response_text = ''
if isinstance(result_message.content, str):
response_text = result_message.content
elif isinstance(result_message.content, list):
for elem in result_message.content:
if hasattr(elem, 'text') and elem.text:
response_text += elem.text
elif isinstance(elem, str):
response_text += elem
timings['response_length'] = len(response_text)
timings['response_preview'] = response_text[:200]
# Extract usage
usage = {'prompt_tokens': 0, 'completion_tokens': 0, 'total_tokens': 0}
if hasattr(result_message, 'usage') and result_message.usage:
u = result_message.usage
usage = {
'prompt_tokens': getattr(u, 'prompt_tokens', 0) or 0,
'completion_tokens': getattr(u, 'completion_tokens', 0) or 0,
'total_tokens': getattr(u, 'total_tokens', 0) or 0,
}
timings['usage'] = usage
except Exception as e:
timings['llm_call_ms'] = round((time.perf_counter() - t_start) * 1000, 2)
timings['llm_call_success'] = False
errors.append(f'LLM call failed: {str(e)}')
# Calculate total
timings['total_ms'] = round(sum([
timings.get('model_fetch_ms', 0),
timings.get('prompt_build_ms', 0),
timings.get('llm_call_ms', 0),
]), 2)
# Add breakdown percentage
if timings['total_ms'] > 0:
timings['breakdown'] = {
'model_fetch_pct': round(timings.get('model_fetch_ms', 0) / timings['total_ms'] * 100, 1),
'prompt_build_pct': round(timings.get('prompt_build_ms', 0) / timings['total_ms'] * 100, 1),
'llm_call_pct': round(timings.get('llm_call_ms', 0) / timings['total_ms'] * 100, 1),
}
if errors:
timings['errors'] = errors
return self.success(data={'test_result': timings})
@group.group_class('executions', '/api/v1/executions')
class ExecutionsRouterGroup(group.RouterGroup):
"""Workflow execution API router group"""
async def initialize(self) -> None:
# Get all executions (across all workflows)
@self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _() -> str:
limit = int(quart.request.args.get('limit', 50))
offset = int(quart.request.args.get('offset', 0))
status = quart.request.args.get('status')
executions = await self.ap.workflow_service.get_executions(limit=limit, offset=offset, status=status)
return self.success(data=executions)
# Get single execution
@self.route('/<execution_uuid>', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _(execution_uuid: str) -> str:
execution = await self.ap.workflow_service.get_execution(execution_uuid)
if execution is None:
return self.http_status(404, -1, 'execution not found')
return self.success(data={'execution': execution})
# Cancel execution
@self.route('/<execution_uuid>/cancel', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _(execution_uuid: str) -> str:
try:
await self.ap.workflow_service.cancel_execution(execution_uuid)
return self.success()
except ValueError as e:
return self.http_status(404, -1, str(e))
except RuntimeError as e:
return self.http_status(400, -1, str(e))

View File

@@ -17,6 +17,7 @@ from .groups import platform as groups_platform
from .groups import pipelines as groups_pipelines
from .groups import knowledge as groups_knowledge
from .groups import resources as groups_resources
from .groups import workflows as groups_workflows
importutil.import_modules_in_pkg(groups)
importutil.import_modules_in_pkg(groups_provider)
@@ -24,6 +25,7 @@ importutil.import_modules_in_pkg(groups_platform)
importutil.import_modules_in_pkg(groups_pipelines)
importutil.import_modules_in_pkg(groups_knowledge)
importutil.import_modules_in_pkg(groups_resources)
importutil.import_modules_in_pkg(groups_workflows)
class HTTPController:

View File

@@ -99,16 +99,23 @@ class BotService:
# TODO: 检查配置信息格式
bot_data['uuid'] = str(uuid.uuid4())
# bind the most recently updated pipeline if any exist
# Set default binding_type if not provided
if 'binding_type' not in bot_data:
bot_data['binding_type'] = 'pipeline'
# checkout the default pipeline (for backward compatibility)
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_pipeline.LegacyPipeline)
.order_by(persistence_pipeline.LegacyPipeline.updated_at.desc())
.limit(1)
sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
persistence_pipeline.LegacyPipeline.is_default == True
)
)
pipeline = result.first()
if pipeline is not None:
bot_data['use_pipeline_uuid'] = pipeline.uuid
bot_data['use_pipeline_name'] = pipeline.name
# Also set binding_uuid for new unified binding model
if 'binding_uuid' not in bot_data:
bot_data['binding_uuid'] = pipeline.uuid
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_bot.Bot).values(bot_data))
@@ -120,26 +127,38 @@ class BotService:
async def update_bot(self, bot_uuid: str, bot_data: dict) -> None:
"""Update bot"""
update_data = bot_data.copy()
if 'uuid' in bot_data:
del bot_data['uuid']
if 'uuid' in update_data:
del update_data['uuid']
# Handle binding_type and binding_uuid for the new unified binding model
# If binding_type is explicitly set to 'workflow', skip pipeline validation
binding_type = bot_data.get('binding_type')
# set use_pipeline_name
if 'use_pipeline_uuid' in update_data:
# set use_pipeline_name (for backward compatibility with 'pipeline' binding_type)
if 'use_pipeline_uuid' in bot_data:
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
persistence_pipeline.LegacyPipeline.uuid == update_data['use_pipeline_uuid']
persistence_pipeline.LegacyPipeline.uuid == bot_data['use_pipeline_uuid']
)
)
pipeline = result.first()
if pipeline is not None:
update_data['use_pipeline_name'] = pipeline.name
bot_data['use_pipeline_name'] = pipeline.name
# Also sync to binding_uuid if binding_type is 'pipeline' or not set
if binding_type is None or binding_type == 'pipeline':
bot_data['binding_uuid'] = bot_data['use_pipeline_uuid']
bot_data['binding_type'] = 'pipeline'
else:
raise Exception('Pipeline not found')
# If binding_uuid is set directly (for workflow), sync use_pipeline_uuid for backward compatibility
if 'binding_uuid' in bot_data and binding_type == 'workflow':
# For workflow binding, we don't sync to use_pipeline_uuid
# but we ensure binding_type is correctly set
bot_data['binding_type'] = 'workflow'
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_bot.Bot).values(update_data).where(persistence_bot.Bot.uuid == bot_uuid)
sqlalchemy.update(persistence_bot.Bot).values(bot_data).where(persistence_bot.Bot.uuid == bot_uuid)
)
await self.ap.platform_mgr.remove_bot(bot_uuid)

View File

@@ -73,6 +73,20 @@ class PipelineService:
return self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)
async def get_pipeline_by_name(self, pipeline_name: str) -> dict | None:
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
persistence_pipeline.LegacyPipeline.name == pipeline_name
)
)
pipeline = result.first()
if pipeline is None:
return None
return self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)
async def create_pipeline(self, pipeline_data: dict, default: bool = False) -> str:
from ....utils import paths as path_utils

File diff suppressed because it is too large Load Diff

View File

@@ -31,6 +31,7 @@ from ..api.http.service import mcp as mcp_service
from ..api.http.service import apikey as apikey_service
from ..api.http.service import webhook as webhook_service
from ..api.http.service import monitoring as monitoring_service
from ..api.http.service import workflow as workflow_service
from ..api.http.service import maintenance as maintenance_service
from ..discover import engine as discover_engine
@@ -150,6 +151,8 @@ class Application:
webhook_service: webhook_service.WebhookService = None
workflow_service: workflow_service.WorkflowService = None
telemetry: telemetry_module.TelemetryManager = None
survey: survey_module.SurveyManager = None
@@ -237,6 +240,22 @@ class Application:
scopes=[core_entities.LifecycleControlScope.APPLICATION],
)
async def workflow_execution_cleanup_loop():
check_interval_seconds = 60
while True:
try:
cancelled = await self.workflow_service.cleanup_stale_executions()
if cancelled > 0:
self.logger.info(f'Workflow execution auto-cleanup: cancelled {cancelled} stale executions')
except Exception as e:
self.logger.warning(f'Workflow execution auto-cleanup error: {e}')
await asyncio.sleep(check_interval_seconds)
self.task_mgr.create_task(
workflow_execution_cleanup_loop(),
name='workflow-execution-cleanup',
scopes=[core_entities.LifecycleControlScope.APPLICATION],
)
# Start storage/log maintenance task if enabled
storage_cleanup_cfg = self.instance_config.data.get('storage', {}).get('cleanup', {})
if storage_cleanup_cfg.get('enabled', True) and self.maintenance_service is not None:

View File

@@ -28,6 +28,7 @@ from ...api.http.service import mcp as mcp_service
from ...api.http.service import apikey as apikey_service
from ...api.http.service import webhook as webhook_service
from ...api.http.service import monitoring as monitoring_service
from ...api.http.service import workflow as workflow_service
from ...api.http.service import maintenance as maintenance_service
from ...discover import engine as discover_engine
from ...storage import mgr as storagemgr
@@ -86,6 +87,9 @@ class BuildAppStage(stage.BootingStage):
webhook_service_inst = webhook_service.WebhookService(ap)
ap.webhook_service = webhook_service_inst
workflow_service_inst = workflow_service.WorkflowService(ap)
ap.workflow_service = workflow_service_inst
proxy_mgr = proxy.ProxyManager(ap)
await proxy_mgr.initialize()
ap.proxy_mgr = proxy_mgr

View File

@@ -221,3 +221,34 @@ class LoadConfigStage(stage.BootingStage):
ap.pipeline_config_meta_safety = await load_resource_yaml_template_data('metadata/pipeline/safety.yaml')
ap.pipeline_config_meta_ai = await load_resource_yaml_template_data('metadata/pipeline/ai.yaml')
ap.pipeline_config_meta_output = await load_resource_yaml_template_data('metadata/pipeline/output.yaml')
# Load workflow node metadata from YAML files. YAML is the source of
# truth for workflow editor metadata; Python classes provide execution
# logic and are bound through the registry.
from langbot.pkg.workflow.metadata import NodeMetadataLoader
from langbot.pkg.workflow.registry import NodeTypeRegistry
workflow_metadata_loader = NodeMetadataLoader()
workflow_node_count = await workflow_metadata_loader.load_core_metadata()
ap.workflow_node_configs = workflow_metadata_loader.get_all_metadata()
ap.workflow_node_metadata_loader = workflow_metadata_loader
workflow_registry = NodeTypeRegistry.instance()
for node_config in ap.workflow_node_configs.values():
workflow_registry.register_metadata(node_config, source=node_config.get('_source', 'core'))
# Auto-discover and register workflow nodes using discovery engine
if hasattr(ap, 'discover') and ap.discover is not None:
workflow_registry.discover_nodes(ap.discover)
workflow_load_errors = workflow_metadata_loader.get_load_errors()
if workflow_load_errors:
print(f'Workflow node metadata load errors: {len(workflow_load_errors)}')
for error in workflow_load_errors:
print(f" - {error.get('file')}: {error.get('error')}")
print(
f'Loaded {workflow_node_count} workflow node metadata files; '
f'registered {workflow_registry.metadata_count()} metadata definitions, '
f'{workflow_registry.count()} node types'
)

View File

@@ -304,3 +304,65 @@ class ComponentDiscoveryEngine:
if component.kind == kind:
result.append(component)
return result
def discover_workflow_nodes(self, nodes_dir: str) -> typing.List[typing.Type]:
"""Discover workflow node classes from a directory of Python modules.
Scans all .py files in the given directory, imports them, and collects
classes that are subclasses of WorkflowNode.
Args:
nodes_dir: Directory path like 'pkg/workflow/nodes/'
Returns:
List of WorkflowNode subclasses found
"""
from langbot.pkg.workflow.node import WorkflowNode
node_classes: typing.List[typing.Type[WorkflowNode]] = []
# Normalize path
if nodes_dir.endswith('/'):
nodes_dir = nodes_dir[:-1]
# Import the nodes package to trigger all module imports
module_path = nodes_dir.replace('/', '.').replace('\\', '.')
package_path = module_path
try:
# Import the package __init__ to trigger submodule imports
importlib.import_module(f'langbot.{package_path}')
except ImportError:
self.ap.logger.warning(f'Failed to import workflow nodes package: langbot.{package_path}')
# Since workflow/__init__.py is empty, explicitly import all .py files in the nodes directory
import os
# engine.py is in langbot/pkg/discover/, nodes are in langbot/pkg/workflow/nodes/
nodes_abs_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'workflow', 'nodes'))
if os.path.isdir(nodes_abs_path):
for filename in os.listdir(nodes_abs_path):
if filename.endswith('.py') and not filename.startswith('_'):
module_name = filename[:-3]
try:
importlib.import_module(f'langbot.{package_path}.{module_name}')
except ImportError as e:
self.ap.logger.warning(f'Failed to import workflow node module: {module_name}: {e}')
# Now collect all WorkflowNode subclasses from sys.modules
import sys
prefix = f'langbot.{package_path}.'
for mod_name, mod in sys.modules.items():
if mod_name.startswith(prefix) and mod is not None:
for attr_name in dir(mod):
attr = getattr(mod, attr_name)
if (
isinstance(attr, type)
and issubclass(attr, WorkflowNode)
and attr is not WorkflowNode
and hasattr(attr, 'type_name')
and attr.type_name
):
if attr not in node_classes:
node_classes.append(attr)
return node_classes

View File

@@ -17,6 +17,13 @@ class Bot(Base):
use_pipeline_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
use_pipeline_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
pipeline_routing_rules = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, server_default='[]')
# New unified binding fields
# binding_type: 'pipeline' or 'workflow'
binding_type = sqlalchemy.Column(sqlalchemy.String(32), nullable=False, server_default='pipeline')
# binding_uuid: UUID of the bound Pipeline or Workflow
binding_uuid = sqlalchemy.Column(sqlalchemy.String(64), nullable=True)
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
updated_at = sqlalchemy.Column(
sqlalchemy.DateTime,

View File

@@ -0,0 +1,126 @@
"""Workflow persistence entities"""
import sqlalchemy
from .base import Base
class Workflow(Base):
"""Workflow definition"""
__tablename__ = 'workflows'
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
description = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
emoji = sqlalchemy.Column(sqlalchemy.String(10), nullable=True, default='🔄')
version = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, default=1)
is_enabled = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=True)
# Workflow definition stored as JSON
# Contains: nodes, edges, variables, settings
definition = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
# Global config (inherited from Pipeline capabilities)
# Contains: safety, output configs
global_config = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
# Extensions preferences (same as Pipeline)
extensions_preferences = sqlalchemy.Column(
sqlalchemy.JSON,
nullable=False,
default={'enable_all_plugins': True, 'enable_all_mcp_servers': True, 'plugins': [], 'mcp_servers': []},
)
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
updated_at = sqlalchemy.Column(
sqlalchemy.DateTime,
nullable=False,
server_default=sqlalchemy.func.now(),
onupdate=sqlalchemy.func.now(),
)
class WorkflowVersion(Base):
"""Workflow version history"""
__tablename__ = 'workflow_versions'
id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True)
workflow_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
version = sqlalchemy.Column(sqlalchemy.Integer, nullable=False)
definition = sqlalchemy.Column(sqlalchemy.JSON, nullable=False)
global_config = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
created_by = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
__table_args__ = (sqlalchemy.UniqueConstraint('workflow_uuid', 'version', name='uq_workflow_version'),)
class WorkflowTrigger(Base):
"""Workflow trigger configuration"""
__tablename__ = 'workflow_triggers'
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
workflow_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
type = sqlalchemy.Column(sqlalchemy.String(50), nullable=False) # message, cron, event, webhook
config = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
is_enabled = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=True)
priority = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, default=0)
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
updated_at = sqlalchemy.Column(
sqlalchemy.DateTime,
nullable=False,
server_default=sqlalchemy.func.now(),
onupdate=sqlalchemy.func.now(),
)
class WorkflowExecution(Base):
"""Workflow execution record"""
__tablename__ = 'workflow_executions'
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
workflow_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
workflow_version = sqlalchemy.Column(sqlalchemy.Integer, nullable=False)
status = sqlalchemy.Column(sqlalchemy.String(20), nullable=False) # pending, running, completed, failed, cancelled
trigger_type = sqlalchemy.Column(sqlalchemy.String(50), nullable=True)
trigger_data = sqlalchemy.Column(sqlalchemy.JSON, nullable=True)
variables = sqlalchemy.Column(sqlalchemy.JSON, nullable=True)
start_time = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
end_time = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
error = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
class WorkflowNodeExecution(Base):
"""Workflow node execution record"""
__tablename__ = 'workflow_node_executions'
id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True)
execution_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
node_id = sqlalchemy.Column(sqlalchemy.String(100), nullable=False)
node_type = sqlalchemy.Column(sqlalchemy.String(50), nullable=False)
status = sqlalchemy.Column(sqlalchemy.String(20), nullable=False) # pending, running, completed, failed, skipped
inputs = sqlalchemy.Column(sqlalchemy.JSON, nullable=True)
outputs = sqlalchemy.Column(sqlalchemy.JSON, nullable=True)
start_time = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
end_time = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
error = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
retry_count = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, default=0)
class ScheduledJob(Base):
"""Scheduled job for cron triggers"""
__tablename__ = 'workflow_scheduled_jobs'
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
trigger_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
cron_expression = sqlalchemy.Column(sqlalchemy.String(100), nullable=True)
next_run_time = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
last_run_time = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
is_enabled = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=True)

View File

@@ -0,0 +1,158 @@
"""Add workflow tables and update bot binding fields"""
import sqlalchemy
from .. import migration
@migration.migration_class(26)
class DBMigrateWorkflowTables(migration.DBMigration):
"""Add workflow tables and update bot binding fields"""
async def upgrade(self):
# Create workflows table
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text("""
CREATE TABLE IF NOT EXISTS workflows (
uuid VARCHAR(255) PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT,
emoji VARCHAR(10) DEFAULT '🔄',
version INTEGER NOT NULL DEFAULT 1,
is_enabled BOOLEAN NOT NULL DEFAULT 1,
definition JSON NOT NULL DEFAULT '{}',
global_config JSON NOT NULL DEFAULT '{}',
extensions_preferences JSON NOT NULL DEFAULT '{"enable_all_plugins": true, "enable_all_mcp_servers": true, "plugins": [], "mcp_servers": []}',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
)
# Create workflow_versions table
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text("""
CREATE TABLE IF NOT EXISTS workflow_versions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workflow_uuid VARCHAR(255) NOT NULL,
version INTEGER NOT NULL,
definition JSON NOT NULL,
global_config JSON NOT NULL DEFAULT '{}',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by VARCHAR(255),
UNIQUE(workflow_uuid, version)
)
""")
)
# Create workflow_triggers table
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text("""
CREATE TABLE IF NOT EXISTS workflow_triggers (
uuid VARCHAR(255) PRIMARY KEY,
workflow_uuid VARCHAR(255) NOT NULL,
type VARCHAR(50) NOT NULL,
config JSON NOT NULL DEFAULT '{}',
is_enabled BOOLEAN NOT NULL DEFAULT 1,
priority INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
)
# Create workflow_executions table
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text("""
CREATE TABLE IF NOT EXISTS workflow_executions (
uuid VARCHAR(255) PRIMARY KEY,
workflow_uuid VARCHAR(255) NOT NULL,
workflow_version INTEGER NOT NULL,
status VARCHAR(20) NOT NULL,
trigger_type VARCHAR(50),
trigger_data JSON,
variables JSON,
start_time TIMESTAMP,
end_time TIMESTAMP,
error TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
)
# Create workflow_node_executions table
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text("""
CREATE TABLE IF NOT EXISTS workflow_node_executions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
execution_uuid VARCHAR(255) NOT NULL,
node_id VARCHAR(100) NOT NULL,
node_type VARCHAR(50) NOT NULL,
status VARCHAR(20) NOT NULL,
inputs JSON,
outputs JSON,
start_time TIMESTAMP,
end_time TIMESTAMP,
error TEXT,
retry_count INTEGER NOT NULL DEFAULT 0
)
""")
)
# Create workflow_scheduled_jobs table
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text("""
CREATE TABLE IF NOT EXISTS workflow_scheduled_jobs (
uuid VARCHAR(255) PRIMARY KEY,
trigger_uuid VARCHAR(255) NOT NULL,
cron_expression VARCHAR(100),
next_run_time TIMESTAMP,
last_run_time TIMESTAMP,
is_enabled BOOLEAN NOT NULL DEFAULT 1
)
""")
)
# Create indexes
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('CREATE INDEX IF NOT EXISTS idx_workflow_versions_uuid ON workflow_versions(workflow_uuid)')
)
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('CREATE INDEX IF NOT EXISTS idx_workflow_triggers_uuid ON workflow_triggers(workflow_uuid)')
)
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(
'CREATE INDEX IF NOT EXISTS idx_workflow_executions_uuid ON workflow_executions(workflow_uuid)'
)
)
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(
'CREATE INDEX IF NOT EXISTS idx_workflow_node_executions_uuid ON workflow_node_executions(execution_uuid)'
)
)
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(
'CREATE INDEX IF NOT EXISTS idx_workflow_scheduled_jobs_trigger ON workflow_scheduled_jobs(trigger_uuid)'
)
)
# Update bots table: add binding_type column (default to 'pipeline' for backward compatibility)
# Check if column exists first (SQLite doesn't support IF NOT EXISTS for columns)
try:
await self.ap.persistence_mgr.execute_async(sqlalchemy.text('SELECT binding_type FROM bots LIMIT 1'))
except Exception:
# Column doesn't exist, add it
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text("ALTER TABLE bots ADD COLUMN binding_type VARCHAR(20) NOT NULL DEFAULT 'pipeline'")
)
async def downgrade(self):
# Drop tables in reverse order
await self.ap.persistence_mgr.execute_async(sqlalchemy.text('DROP TABLE IF EXISTS workflow_scheduled_jobs'))
await self.ap.persistence_mgr.execute_async(sqlalchemy.text('DROP TABLE IF EXISTS workflow_node_executions'))
await self.ap.persistence_mgr.execute_async(sqlalchemy.text('DROP TABLE IF EXISTS workflow_executions'))
await self.ap.persistence_mgr.execute_async(sqlalchemy.text('DROP TABLE IF EXISTS workflow_triggers'))
await self.ap.persistence_mgr.execute_async(sqlalchemy.text('DROP TABLE IF EXISTS workflow_versions'))
await self.ap.persistence_mgr.execute_async(sqlalchemy.text('DROP TABLE IF EXISTS workflows'))
# 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

View File

@@ -0,0 +1,49 @@
"""Add binding_uuid field to bots table and migrate data"""
import sqlalchemy
from .. import migration
@migration.migration_class(27)
class DBMigrateBotBindingFields(migration.DBMigration):
"""Add binding_uuid field to bots table and migrate existing data"""
async def upgrade(self):
# Add binding_uuid column to bots table
# Check if column exists first (SQLite doesn't support IF NOT EXISTS for columns)
try:
await self.ap.persistence_mgr.execute_async(sqlalchemy.text('SELECT binding_uuid FROM bots LIMIT 1'))
except Exception:
# Column doesn't exist, add it
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('ALTER TABLE bots ADD COLUMN binding_uuid VARCHAR(64)')
)
# Migrate existing data: copy use_pipeline_uuid to binding_uuid for records
# that have a pipeline bound and binding_uuid is not set yet
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text("""
UPDATE bots
SET binding_uuid = use_pipeline_uuid
WHERE use_pipeline_uuid IS NOT NULL
AND use_pipeline_uuid != ''
AND (binding_uuid IS NULL OR binding_uuid = '')
""")
)
# Ensure binding_type is 'pipeline' for records that were migrated
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text("""
UPDATE bots
SET binding_type = 'pipeline'
WHERE binding_uuid IS NOT NULL
AND binding_uuid != ''
AND (binding_type IS NULL OR binding_type = '')
""")
)
async def downgrade(self):
# SQLite doesn't support DROP COLUMN directly
# This would need a table recreation in SQLite, so we'll skip it in downgrade
# The column will remain but won't be used
pass

View File

@@ -13,7 +13,7 @@ import langbot_plugin.api.entities.builtin.platform.message as platform_message
import langbot_plugin.api.entities.builtin.platform.events as platform_events
import langbot_plugin.api.entities.events as events
from ..utils import importutil
from .config_coercion import coerce_pipeline_config
from .config import coerce_pipeline_config
import langbot_plugin.api.entities.builtin.provider.session as provider_session
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
@@ -284,9 +284,9 @@ class RuntimePipeline:
# Record query start and store message_id
message_id = ''
try:
from . import monitoring_helper
from . import monitor
message_id = await monitoring_helper.MonitoringHelper.record_query_start(
message_id = await monitor.MonitoringHelper.record_query_start(
ap=self.ap,
query=query,
bot_id=query.bot_uuid or 'unknown',
@@ -338,7 +338,7 @@ class RuntimePipeline:
# Record query success only if no error occurred during processing
if not query.variables.get('_monitoring_has_error', False):
try:
await monitoring_helper.MonitoringHelper.record_query_success(
await monitor.MonitoringHelper.record_query_success(
ap=self.ap,
message_id=message_id,
query=query,
@@ -348,7 +348,7 @@ class RuntimePipeline:
# Record bot response message
try:
await monitoring_helper.MonitoringHelper.record_query_response(
await monitor.MonitoringHelper.record_query_response(
ap=self.ap,
query=query,
bot_id=query.bot_uuid or 'unknown',
@@ -367,9 +367,9 @@ class RuntimePipeline:
# Record query error
try:
from . import monitoring_helper
from . import monitor
await monitoring_helper.MonitoringHelper.record_query_error(
await monitor.MonitoringHelper.record_query_error(
ap=self.ap,
query=query,
bot_id=query.bot_uuid or 'unknown',
@@ -384,7 +384,8 @@ class RuntimePipeline:
finally:
self.ap.logger.debug(f'Query {query.query_id} processed')
del self.ap.query_pool.cached_queries[query.query_id]
# Use pop with default to avoid KeyError if query was never cached
self.ap.query_pool.cached_queries.pop(query.query_id, None)
class PipelineManager:

View File

@@ -2,7 +2,6 @@ from __future__ import annotations
import asyncio
import json
import re
import traceback
import sqlalchemy
@@ -54,29 +53,24 @@ class RuntimeBot:
self.task_context = taskmgr.TaskContext()
self.logger = logger
@staticmethod
def _match_operator(actual: str, operator: str, expected: str) -> bool:
"""Evaluate a single operator condition."""
if operator == 'eq':
return actual == expected
elif operator == 'neq':
return actual != expected
elif operator == 'contains':
return expected in actual
elif operator == 'not_contains':
return expected not in actual
elif operator == 'starts_with':
return actual.startswith(expected)
elif operator == 'regex':
try:
return bool(re.search(expected, actual))
except re.error:
return False
return False
PIPELINE_DISCARD = '__discard__'
PIPELINE_DISCARD_DISPLAY_NAME = 'Discarded'
def get_binding_info(self) -> tuple[str, str | None]:
"""Get the binding type and UUID for this bot.
Returns:
tuple: (binding_type, binding_uuid) where binding_type is 'pipeline' or 'workflow'
"""
binding_type = getattr(self.bot_entity, 'binding_type', 'pipeline') or 'pipeline'
binding_uuid = getattr(self.bot_entity, 'binding_uuid', None)
# Fallback to use_pipeline_uuid for backward compatibility
if not binding_uuid and binding_type == 'pipeline':
binding_uuid = self.bot_entity.use_pipeline_uuid
return binding_type, binding_uuid
def resolve_pipeline_uuid(
self,
launcher_type: str,
@@ -84,56 +78,26 @@ class RuntimeBot:
message_text: str,
message_element_types: list[str] | None = None,
) -> tuple[str | None, bool]:
"""Resolve pipeline UUID based on routing rules.
"""Resolve pipeline UUID for message processing.
Rules are evaluated in order; first match wins.
Falls back to use_pipeline_uuid if no rule matches.
Rule types:
- launcher_type: session type ("person" / "group")
- launcher_id: session / group id
- message_content: message text content
- message_has_element: message contains element of given type
(Image, Voice, File, Forward, Face, At, AtAll, Quote)
Operators: eq (has), neq (doesn't have)
Operators: eq, neq, contains, not_contains, starts_with, regex
When pipeline_uuid is ``__discard__``, the message should be
silently dropped by the caller.
NOTE: Routing rules have been removed. Bot now directly binds to a
Pipeline or Workflow. This method is kept for backward compatibility
but only returns the direct binding.
Returns:
tuple: (pipeline_uuid, routed_by_rule) - routed_by_rule is True
when a routing rule matched, False when falling back to default.
tuple: (pipeline_uuid, routed_by_rule) - routed_by_rule is always False
as routing rules are no longer used.
"""
rules = self.bot_entity.pipeline_routing_rules or []
element_type_set = set(message_element_types or [])
binding_type, binding_uuid = self.get_binding_info()
for rule in rules:
rule_type = rule.get('type')
operator = rule.get('operator', 'eq')
rule_value = rule.get('value', '')
target_uuid = rule.get('pipeline_uuid')
if not rule_type or not target_uuid:
continue
# If bound to workflow, return None for pipeline_uuid
# The caller should check binding_type and handle accordingly
if binding_type == 'workflow':
# For workflow binding, we still need to return something
# The actual workflow handling should be done by the caller
return None, False
if rule_type == 'launcher_type':
if self._match_operator(launcher_type, operator, rule_value):
return target_uuid, True
elif rule_type == 'launcher_id':
if self._match_operator(str(launcher_id), operator, str(rule_value)):
return target_uuid, True
elif rule_type == 'message_content':
if self._match_operator(message_text, operator, rule_value):
return target_uuid, True
elif rule_type == 'message_has_element':
has_element = rule_value in element_type_set
if operator == 'eq' and has_element:
return target_uuid, True
elif operator == 'neq' and not has_element:
return target_uuid, True
return self.bot_entity.use_pipeline_uuid, False
return binding_uuid, False
async def _record_discarded_message(
self,

View File

@@ -373,6 +373,7 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
"""
pipeline_uuid = connection.pipeline_uuid
session_type = connection.session_type
is_workflow = bool(connection.metadata.get('is_workflow'))
# 获取stream参数默认为True
self.stream_enabled = message_data.get('stream', True)
@@ -414,6 +415,60 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
session_type=session_type,
)
if is_workflow:
# 设置 pipeline_uuid以便工作流节点发送消息时能正确广播
self.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid = pipeline_uuid
message_content = str(message_chain)
message_context = {
'message_id': str(message_id),
'message_content': message_content,
'sender_id': f'websocket_{connection.connection_id}',
'sender_name': 'User',
'platform': 'websocket',
'conversation_id': connection.connection_id,
'is_group': session_type == 'group',
'group_id': 'websocketgroup' if session_type == 'group' else None,
'mentions': [],
'reply_to': None,
'raw_message': {
'message': message_chain_obj,
'connection_id': connection.connection_id,
'session_type': session_type,
},
}
trigger_data = {
'message': message_content,
'message_chain': message_chain_obj,
'session_type': session_type,
'connection_id': connection.connection_id,
'message_context': message_context,
}
try:
from ...api.http.service.workflow import WorkflowExecutionFailedError
# Log workflow execution start (matching pipeline logging)
session_id = f'{session_type}_{connection.connection_id}'
logger.info(f'Processing request from {session_id} (0): {message_content}')
execution_id = await self.ap.workflow_service.execute_workflow(
pipeline_uuid,
trigger_type='message',
trigger_data=trigger_data,
session_id=session_id,
user_id=message_context['sender_id'],
bot_id=self.ap.platform_mgr.websocket_proxy_bot.bot_entity.uuid,
)
# Removed success broadcast - only show error on failure
except WorkflowExecutionFailedError as e:
await connection.send_queue.put({'type': 'error', 'message': e.message})
except Exception as e:
logger.error(f'Workflow websocket execution error: {e}', exc_info=True)
await connection.send_queue.put({'type': 'error', 'message': str(e)})
return
# 添加消息源
message_chain.insert(0, platform_message.Source(id=message_id, time=datetime.now().timestamp()))

View File

@@ -84,7 +84,7 @@ class RuntimeProvider:
# Import monitoring helper
try:
from ...pipeline import monitoring_helper
from ...pipeline import monitor
# Get monitoring metadata from query variables
if query.variables:
@@ -96,7 +96,7 @@ class RuntimeProvider:
pipeline_name = 'Unknown'
message_id = None
await monitoring_helper.MonitoringHelper.record_llm_call(
await monitor.MonitoringHelper.record_llm_call(
ap=self.requester.ap,
query=query,
bot_id=query.bot_uuid or 'unknown',
@@ -154,7 +154,7 @@ class RuntimeProvider:
# Import monitoring helper
try:
from ...pipeline import monitoring_helper
from ...pipeline import monitor
# Get monitoring metadata from query variables
if query.variables:
@@ -166,7 +166,7 @@ class RuntimeProvider:
pipeline_name = 'Unknown'
message_id = None
await monitoring_helper.MonitoringHelper.record_llm_call(
await monitor.MonitoringHelper.record_llm_call(
ap=self.requester.ap,
query=query,
bot_id=query.bot_uuid or 'unknown',

View File

View File

@@ -0,0 +1,204 @@
"""Workflow-Pipeline通信适配器
这个模块提供了Workflow和Pipeline之间的通信适配使用SDK标准的MessageEnvelope格式。
"""
from __future__ import annotations
import logging
from typing import Any, Optional
logger = logging.getLogger(__name__)
class _WorkflowPipelineCaptureAdapter:
"""Workflow-Pipeline通信适配器
用于在Workflow节点和Pipeline之间进行标准化的消息传递。
支持MessageEnvelope格式的双向转换。
"""
def __init__(self, context: Any):
"""初始化适配器
Args:
context: ExecutionContext - Workflow执行上下文
"""
self.context = context
self.responses: list[dict[str, Any]] = []
self.bot_account_id: Optional[str] = None
self._logger = logging.getLogger(__name__)
async def call_pipeline_with_envelope(
self,
envelope: Any,
pipeline_executor: Any
) -> Any:
"""使用MessageEnvelope调用Pipeline
Args:
envelope: MessageEnvelope - 标准消息信封
pipeline_executor: Pipeline执行器实例
Returns:
MessageEnvelope - 执行结果信封
"""
try:
# 动态导入以避免循环依赖
from langbot_plugin_sdk.workflow import envelope_to_query, query_to_envelope
# 1. 转换为Query
query = envelope_to_query(envelope)
# 2. 调用Pipeline
result_query = await pipeline_executor.execute(query)
# 3. 转换回Envelope
result_envelope = query_to_envelope(result_query, envelope)
self._logger.debug(
f'Pipeline execution completed for workflow {envelope.workflow_id}',
extra={
'workflow_id': envelope.workflow_id,
'execution_id': envelope.execution_id,
'node_id': envelope.node_id,
}
)
return result_envelope
except Exception as e:
self._logger.error(
f'Pipeline execution failed: {e}',
exc_info=True,
extra={
'workflow_id': envelope.workflow_id,
'execution_id': envelope.execution_id,
'node_id': envelope.node_id,
}
)
raise
def validate_envelope(self, envelope: Any) -> bool:
"""验证MessageEnvelope的有效性
Args:
envelope: MessageEnvelope - 要验证的消息信封
Returns:
bool - 验证是否通过
"""
required_fields = [
'message_id',
'workflow_id',
'node_id',
'execution_id',
'payload',
'launcher_type',
]
for field in required_fields:
if not hasattr(envelope, field):
self._logger.warning(
f'MessageEnvelope missing required field: {field}'
)
return False
return True
def get_responses(self) -> list[dict[str, Any]]:
"""获取所有响应
Returns:
list - 响应列表
"""
return self.responses.copy()
def add_response(self, response: dict[str, Any]) -> None:
"""添加响应
Args:
response: dict - 响应数据
"""
self.responses.append(response)
def get_last_text_response(self) -> str:
"""获取最后一个文本响应
Returns:
str - 最后一个响应的文本内容
"""
if not self.responses:
return ''
last_response = self.responses[-1]
return str(last_response.get('content', '') or '')
def clear_responses(self) -> None:
"""清空所有响应"""
self.responses.clear()
class WorkflowPipelineCompatibilityLayer:
"""Workflow-Pipeline兼容性层
提供向后兼容性支持旧的Pipeline Query格式和新的MessageEnvelope格式。
"""
def __init__(self):
"""初始化兼容性层"""
self._logger = logging.getLogger(__name__)
def is_workflow_context(self, query: Any) -> bool:
"""检查Query是否包含Workflow上下文
Args:
query: Query - Pipeline Query对象
Returns:
bool - 是否来自Workflow
"""
if hasattr(query, 'is_from_workflow'):
return query.is_from_workflow()
if hasattr(query, 'get_workflow_context'):
context = query.get_workflow_context()
return bool(context and context.get('workflow_id'))
return False
def get_workflow_id(self, query: Any) -> Optional[str]:
"""从Query获取Workflow ID
Args:
query: Query - Pipeline Query对象
Returns:
str - Workflow ID如果不存在则返回None
"""
if hasattr(query, 'get_workflow_id'):
return query.get_workflow_id()
if hasattr(query, 'get_workflow_context'):
context = query.get_workflow_context()
return context.get('workflow_id') if context else None
return None
def get_execution_id(self, query: Any) -> Optional[str]:
"""从Query获取执行ID
Args:
query: Query - Pipeline Query对象
Returns:
str - 执行ID如果不存在则返回None
"""
if hasattr(query, 'get_execution_id'):
return query.get_execution_id()
if hasattr(query, 'get_workflow_context'):
context = query.get_workflow_context()
return context.get('execution_id') if context else None
return None

View File

@@ -0,0 +1,509 @@
"""Workflow debug execution support.
This module provides debugging capabilities for workflow execution, including:
- ExecutionLog: Structured log entries for execution tracking
- DebugExecutionState: State management for debug sessions (pause, resume, breakpoints)
- DebugWorkflowExecutor: Extended executor with step-by-step debugging support
"""
from __future__ import annotations
import asyncio
import logging
import traceback
import uuid
from datetime import datetime
from typing import Any, Optional, TYPE_CHECKING
from .entities import (
WorkflowDefinition,
NodeDefinition,
EdgeDefinition,
ExecutionContext,
ExecutionStatus,
NodeState,
NodeStatus,
)
from .executor import WorkflowExecutor
if TYPE_CHECKING:
from ..core import app
logger = logging.getLogger(__name__)
class ExecutionLog:
"""Execution log entry"""
def __init__(self, level: str, message: str, node_id: Optional[str] = None, data: Optional[dict] = None):
self.id = str(uuid.uuid4())
self.timestamp = datetime.now().isoformat()
self.level = level
self.message = message
self.node_id = node_id
self.data = data or {}
def to_dict(self) -> dict:
return {
'id': self.id,
'timestamp': self.timestamp,
'level': self.level,
'message': self.message,
'node_id': self.node_id,
'data': self.data,
}
class DebugExecutionState:
"""State for a debug execution"""
def __init__(self, execution_id: str, breakpoints: list[str] = None):
self.execution_id = execution_id
self.status: str = 'running'
self.is_paused: bool = False
self.is_stopped: bool = False
self.current_node_id: Optional[str] = None
self.breakpoints: set[str] = set(breakpoints or [])
self.logs: list[ExecutionLog] = []
self.pending_logs: list[ExecutionLog] = []
self._pause_event = asyncio.Event()
self._pause_event.set() # Initially not paused
self._stop_event = asyncio.Event()
def add_log(self, level: str, message: str, node_id: str = None, data: dict = None):
"""Add a log entry"""
log = ExecutionLog(level, message, node_id, data)
self.logs.append(log)
self.pending_logs.append(log)
logger.log(
getattr(logging, level.upper(), logging.INFO),
f'[Workflow Debug] {message}',
extra={'node_id': node_id, 'data': data},
)
def get_pending_logs(self) -> list[dict]:
"""Get and clear pending logs"""
logs = [log.to_dict() for log in self.pending_logs]
self.pending_logs = []
return logs
def pause(self):
"""Pause execution"""
self.is_paused = True
self._pause_event.clear()
self.add_log('info', 'Execution paused')
def resume(self):
"""Resume execution"""
self.is_paused = False
self._pause_event.set()
self.add_log('info', 'Execution resumed')
def stop(self):
"""Stop execution"""
self.is_stopped = True
self.status = 'cancelled'
self._stop_event.set()
self._pause_event.set() # Release any pause
self.add_log('info', 'Execution stopped')
async def wait_if_paused(self):
"""Wait if execution is paused"""
if self.is_paused:
self.add_log('info', 'Waiting for resume...')
await self._pause_event.wait()
def check_breakpoint(self, node_id: str) -> bool:
"""Check if there's a breakpoint at the given node"""
return node_id in self.breakpoints
class DebugWorkflowExecutor(WorkflowExecutor):
"""
Debug-enabled workflow executor with step-by-step execution support.
Extends WorkflowExecutor with debugging capabilities.
"""
# Class-level storage for active debug sessions
_debug_states: dict[str, DebugExecutionState] = {}
def __init__(self, ap: Optional['app.Application'] = None):
super().__init__(ap)
@classmethod
def get_debug_state(cls, execution_id: str) -> Optional[DebugExecutionState]:
"""Get debug state for an execution"""
return cls._debug_states.get(execution_id)
@classmethod
def create_debug_state(cls, execution_id: str, breakpoints: list[str] = None) -> DebugExecutionState:
"""Create a new debug state"""
state = DebugExecutionState(execution_id, breakpoints)
cls._debug_states[execution_id] = state
return state
@classmethod
def remove_debug_state(cls, execution_id: str):
"""Remove debug state for an execution"""
cls._debug_states.pop(execution_id, None)
async def execute_debug(
self,
workflow: WorkflowDefinition,
context: ExecutionContext,
debug_state: DebugExecutionState,
) -> ExecutionContext:
"""
Execute a workflow in debug mode.
Args:
workflow: Workflow definition
context: Execution context
debug_state: Debug execution state
Returns:
Updated execution context
"""
context.status = ExecutionStatus.RUNNING
context.start_time = datetime.now()
debug_state.add_log('info', f'Starting debug execution for workflow: {workflow.name}')
try:
# Build execution graph
node_map = {node.id: node for node in workflow.nodes}
edge_map = self._build_edge_map(workflow.edges)
self._edges = workflow.edges
# Initialize node states
for node in workflow.nodes:
if node.id not in context.node_states:
context.node_states[node.id] = NodeState(node_id=node.id)
# Find start node(s)
start_nodes = self._find_start_nodes(workflow.nodes, workflow.edges)
if not start_nodes:
raise ValueError('No start nodes found in workflow')
debug_state.add_log('info', f'Found {len(start_nodes)} start node(s)')
# Execute from start nodes
for start_node in start_nodes:
if debug_state.is_stopped:
break
await self._execute_debug_from_node(
start_node, node_map, edge_map, context, debug_state, workflow.settings.max_retries
)
# Set final status
if debug_state.is_stopped:
context.status = ExecutionStatus.CANCELLED
debug_state.status = 'cancelled'
else:
all_completed = all(
state.status in (NodeStatus.COMPLETED, NodeStatus.SKIPPED) for state in context.node_states.values()
)
if all_completed:
context.status = ExecutionStatus.COMPLETED
debug_state.status = 'completed'
debug_state.add_log('info', 'Workflow execution completed successfully')
else:
has_failed = any(state.status == NodeStatus.FAILED for state in context.node_states.values())
if has_failed:
context.status = ExecutionStatus.FAILED
debug_state.status = 'error'
except Exception as e:
context.status = ExecutionStatus.FAILED
context.error = str(e)
debug_state.status = 'error'
debug_state.add_log('error', f'Workflow execution failed: {e}', data={'traceback': traceback.format_exc()})
logger.error(f'Debug workflow execution failed: {e}\n{traceback.format_exc()}')
finally:
context.end_time = datetime.now()
return context
async def _execute_debug_from_node(
self,
node: NodeDefinition,
node_map: dict[str, NodeDefinition],
edge_map: dict[str, list[EdgeDefinition]],
context: ExecutionContext,
debug_state: DebugExecutionState,
max_retries: int = 3,
):
"""Execute workflow from a node with debug support"""
# Check if stopped
if debug_state.is_stopped:
return
# Wait if paused
await debug_state.wait_if_paused()
# Check if should skip
if await self._should_skip_node(node, context):
if context.node_states[node.id].status == NodeStatus.SKIPPED:
debug_state.add_log('info', f'Skipping node: {node.id}', node_id=node.id)
return
# Check breakpoint
if debug_state.check_breakpoint(node.id):
debug_state.add_log('info', f'Hit breakpoint at node: {node.id}', node_id=node.id)
debug_state.pause()
await debug_state.wait_if_paused()
# Update current node
debug_state.current_node_id = node.id
debug_state.add_log('info', f'Executing node: {node.id} ({node.type})', node_id=node.id)
# Execute node
await self._execute_debug_node(node, context, debug_state, max_retries)
# Check if stopped or failed
if debug_state.is_stopped:
return
if context.node_states[node.id].status == NodeStatus.FAILED:
return
# Get outgoing edges
outgoing_edges = edge_map.get(node.id, [])
# Execute next nodes
for edge in outgoing_edges:
if debug_state.is_stopped:
break
target_node = node_map.get(edge.target_node)
if not target_node:
continue
# Check edge condition
if edge.condition:
condition_met = await self._evaluate_condition(edge.condition, context)
if not condition_met:
debug_state.add_log('debug', f'Edge condition not met: {edge.condition}', node_id=node.id)
continue
# Check if all inputs are ready
if await self._inputs_ready(target_node, edge_map, context):
await self._execute_debug_from_node(target_node, node_map, edge_map, context, debug_state, max_retries)
async def _execute_debug_node(
self, node: NodeDefinition, context: ExecutionContext, debug_state: DebugExecutionState, max_retries: int = 3
):
"""Execute a single node with debug logging"""
node_state = context.node_states[node.id]
node_state.status = NodeStatus.RUNNING
node_state.start_time = datetime.now()
# Get node instance (pass ap for access to services)
node_instance = self.registry.create_instance(node.type, node.id, node.config, ap=self.ap)
if not node_instance:
node_state.status = NodeStatus.FAILED
node_state.error = f'Unknown node type: {node.type}'
node_state.end_time = datetime.now()
debug_state.add_log('error', f'Unknown node type: {node.type}', node_id=node.id)
self._record_execution_step(node, node_state, context)
await self._persist_node_execution(node, node_state, context)
return
# Resolve inputs
inputs = await self._resolve_inputs(node, context)
node_state.inputs = inputs
debug_state.add_log(
'debug', 'Node inputs resolved', node_id=node.id, data={'inputs': self._safe_serialize(inputs)}
)
# Validate inputs
validation_errors = await node_instance.validate_inputs(inputs)
if validation_errors:
node_state.status = NodeStatus.FAILED
node_state.error = '; '.join(validation_errors)
node_state.end_time = datetime.now()
debug_state.add_log('error', f'Input validation failed: {node_state.error}', node_id=node.id)
self._record_execution_step(node, node_state, context)
await self._persist_node_execution(node, node_state, context)
return
# Execute with retries
for attempt in range(max_retries + 1):
if debug_state.is_stopped:
node_state.status = NodeStatus.FAILED
node_state.error = 'Execution stopped'
node_state.end_time = datetime.now()
break
try:
outputs = await node_instance.execute(inputs, context)
node_state.outputs = outputs
node_state.status = NodeStatus.COMPLETED
node_state.end_time = datetime.now()
duration_ms = int((node_state.end_time - node_state.start_time).total_seconds() * 1000)
debug_state.add_log(
'info',
f'Node completed in {duration_ms}ms',
node_id=node.id,
data={'outputs': self._safe_serialize(outputs), 'duration_ms': duration_ms},
)
break
except Exception as e:
node_state.retry_count = attempt + 1
debug_state.add_log(
'warning', f'Node execution failed (attempt {attempt + 1}/{max_retries + 1}): {e}', node_id=node.id
)
if attempt < max_retries:
await asyncio.sleep(1)
else:
node_state.status = NodeStatus.FAILED
node_state.error = str(e)
node_state.end_time = datetime.now()
debug_state.add_log(
'error',
f'Node failed after {max_retries + 1} attempts: {e}',
node_id=node.id,
data={'error': str(e), 'traceback': traceback.format_exc()},
)
self._record_execution_step(node, node_state, context)
await self._persist_node_execution(node, node_state, context)
async def step_execute(
self,
workflow: WorkflowDefinition,
context: ExecutionContext,
debug_state: DebugExecutionState,
) -> dict:
"""
Execute one step (one node) in debug mode.
Returns:
Dict with node_id, node_state, and completed status
"""
# Find next node to execute
next_node = self._find_next_executable_node(workflow, context)
if not next_node:
debug_state.status = 'completed'
return {'completed': True}
# Execute single node
debug_state.current_node_id = next_node.id
await self._execute_debug_node(next_node, context, debug_state, workflow.settings.max_retries)
node_state = context.node_states.get(next_node.id)
# Check if workflow is complete
all_done = all(
state.status in (NodeStatus.COMPLETED, NodeStatus.SKIPPED, NodeStatus.FAILED)
for state in context.node_states.values()
)
if all_done:
debug_state.status = 'completed'
context.status = ExecutionStatus.COMPLETED
return {
'node_id': next_node.id,
'node_state': {
'status': node_state.status.value if node_state else 'unknown',
'inputs': self._safe_serialize(node_state.inputs) if node_state else {},
'outputs': self._safe_serialize(node_state.outputs) if node_state else {},
'error': node_state.error if node_state else None,
},
'completed': all_done,
}
def _find_next_executable_node(
self, workflow: WorkflowDefinition, context: ExecutionContext
) -> Optional[NodeDefinition]:
"""Find the next node that can be executed"""
edge_map = self._build_edge_map(workflow.edges)
for node in workflow.nodes:
state = context.node_states.get(node.id)
# Skip completed, running, or failed nodes
if state and state.status in (
NodeStatus.COMPLETED,
NodeStatus.RUNNING,
NodeStatus.FAILED,
NodeStatus.SKIPPED,
):
continue
# Check if this node's inputs are ready
incoming_nodes = set()
for source_id, edges in edge_map.items():
for edge in edges:
if edge.target_node == node.id:
incoming_nodes.add(source_id)
# If no incoming nodes, it's a start node
if not incoming_nodes:
return node
# Check if all incoming nodes are done
all_incoming_done = True
for source_id in incoming_nodes:
source_state = context.node_states.get(source_id)
if not source_state or source_state.status not in (NodeStatus.COMPLETED, NodeStatus.SKIPPED):
all_incoming_done = False
break
if all_incoming_done:
return node
return None
def _safe_serialize(self, data: Any) -> Any:
"""Safely serialize data for logging"""
if data is None:
return None
if isinstance(data, (str, int, float, bool)):
return data
if isinstance(data, (list, tuple)):
return [self._safe_serialize(item) for item in data[:100]] # Limit list size
if isinstance(data, dict):
result = {}
for key, value in list(data.items())[:50]: # Limit dict size
result[str(key)] = self._safe_serialize(value)
return result
# For complex objects, try to convert to string
try:
return str(data)[:1000] # Limit string length
except Exception:
return '<non-serializable>'
def get_execution_state(self, context: ExecutionContext, debug_state: DebugExecutionState) -> dict:
"""Get current execution state for API response"""
node_states = {}
for node_id, state in context.node_states.items():
node_states[node_id] = {
'status': state.status.value,
'inputs': self._safe_serialize(state.inputs),
'outputs': self._safe_serialize(state.outputs),
'error': state.error,
'startTime': state.start_time.isoformat() if state.start_time else None,
'endTime': state.end_time.isoformat() if state.end_time else None,
'duration': int((state.end_time - state.start_time).total_seconds() * 1000)
if state.start_time and state.end_time
else None,
}
return {
'status': debug_state.status,
'current_node_id': debug_state.current_node_id,
'node_states': node_states,
'new_logs': debug_state.get_pending_logs(),
'error': context.error,
}

View File

@@ -0,0 +1,155 @@
"""Workflow entities and data models
This module defines workflow entities using SDK standard entities where available,
and local-specific entities for LangBot_copy-specific functionality.
"""
from __future__ import annotations
from datetime import datetime
from typing import Any, Optional
import pydantic
# Import SDK entities for standard workflow protocol types
from langbot_plugin.api.entities.builtin.workflow.entities import (
ExecutionContext,
ExecutionStep,
MessageContext,
NodeDefinition,
NodeState,
PortDefinition,
)
from langbot_plugin.api.entities.builtin.workflow.enums import (
ExecutionStatus,
NodeStatus,
)
class Position(pydantic.BaseModel):
"""Node position on canvas"""
x: float = 0
y: float = 0
class EdgeDefinition(pydantic.BaseModel):
"""Workflow edge definition (connection between nodes)"""
id: str
source_node: str
source_port: str = 'output'
target_node: str
target_port: str = 'input'
condition: Optional[str] = None # Optional condition expression
class TriggerDefinition(pydantic.BaseModel):
"""Workflow trigger definition"""
id: str
type: str # message, cron, event, webhook
config: dict[str, Any] = {}
enabled: bool = True
class WorkflowSettings(pydantic.BaseModel):
"""Workflow settings"""
# Execution settings
max_execution_time: int = 300 # seconds
max_retries: int = 3
retry_delay: int = 5 # seconds
# Error handling
error_handling: str = 'stop' # stop, continue, retry
# Logging
log_level: str = 'info'
save_execution_history: bool = True
# Concurrency
max_concurrent_executions: int = 10
class SafetyConfig(pydantic.BaseModel):
"""Safety configuration (inherited from Pipeline)"""
content_filter: dict[str, Any] = {'enable': False, 'sensitive_words': [], 'replace_with': '***'}
rate_limit: dict[str, Any] = {'enable': False, 'requests_per_minute': 60, 'burst_limit': 10}
class OutputConfig(pydantic.BaseModel):
"""Output configuration (inherited from Pipeline)"""
long_text_processing: dict[str, Any] = {
'strategy': 'split', # split, truncate, file
'max_length': 4000,
'split_separator': '\n\n',
}
force_delay: dict[str, Any] = {'enable': False, 'min_delay_ms': 0, 'max_delay_ms': 0}
misc: dict[str, Any] = {}
class WorkflowGlobalConfig(pydantic.BaseModel):
"""Workflow global configuration (inherited from Pipeline capabilities)"""
safety: SafetyConfig = SafetyConfig()
output: OutputConfig = OutputConfig()
class ExtensionsPreferences(pydantic.BaseModel):
"""Extensions preferences (same as Pipeline)"""
enable_all_plugins: bool = True
enable_all_mcp_servers: bool = True
plugins: list[str] = []
mcp_servers: list[str] = []
class ConversationVariable(pydantic.BaseModel):
"""Conversation-level variable definition"""
name: str
type: str = 'string' # string, number, boolean, object, array
description: str = ''
default_value: Any = None
max_length: Optional[int] = None # For strings
class WorkflowDefinition(pydantic.BaseModel):
"""Complete workflow definition"""
uuid: str
name: str
description: str = ''
emoji: str = '💼'
version: int = 1
# Workflow graph
nodes: list[NodeDefinition] = []
edges: list[EdgeDefinition] = []
# Variables
variables: dict[str, Any] = {} # Global variables
conversation_variables: list[ConversationVariable] = [] # Session-level variables
# Settings
settings: WorkflowSettings = WorkflowSettings()
# Triggers (for automation)
triggers: list[TriggerDefinition] = []
# Global configuration (inherited from Pipeline)
global_config: WorkflowGlobalConfig = WorkflowGlobalConfig()
# Extensions
extensions_preferences: ExtensionsPreferences = ExtensionsPreferences()
# Metadata
is_enabled: bool = True
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
# Source tracking (for imported workflows)
source: Optional[str] = None # dify, n8n, langflow, etc.
source_id: Optional[str] = None

View File

@@ -0,0 +1,837 @@
"""Workflow execution engine.
This module contains the core workflow execution logic:
- WorkflowExecutor: Main execution engine with control flow handling
- ParallelExecutor: Parallel branch execution
- LoopExecutor: Loop/iterator execution
Debug execution support has been moved to the ``debug`` module.
"""
from __future__ import annotations
import ast
import asyncio
import logging
import operator
import uuid
from datetime import datetime
from typing import Any, Optional, TYPE_CHECKING
import sqlalchemy
from .entities import (
WorkflowDefinition,
NodeDefinition,
EdgeDefinition,
ExecutionContext,
ExecutionStatus,
NodeState,
NodeStatus,
ExecutionStep,
)
from ..entity.persistence import workflow as persistence_workflow
from .registry import NodeTypeRegistry
from . import monitor
if TYPE_CHECKING:
from ..core import app
logger = logging.getLogger(__name__)
# ─── Safe expression evaluator (replaces eval()) ─────────────────────
# Uses Python's ast module to whitelist only comparison / boolean / arithmetic
# operations. No function calls, attribute access, or subscript injection.
_SAFE_OPS = {
ast.Add: operator.add,
ast.Sub: operator.sub,
ast.Mult: operator.mul,
ast.Div: operator.truediv,
ast.FloorDiv: operator.floordiv,
ast.Mod: operator.mod,
ast.Pow: operator.pow,
ast.USub: operator.neg,
ast.UAdd: operator.pos,
ast.Not: operator.not_,
ast.Eq: operator.eq,
ast.NotEq: operator.ne,
ast.Lt: operator.lt,
ast.LtE: operator.le,
ast.Gt: operator.gt,
ast.GtE: operator.ge,
ast.Is: operator.is_,
ast.IsNot: operator.is_not,
ast.In: lambda a, b: a in b,
ast.NotIn: lambda a, b: a not in b,
}
def _safe_eval(expr: str) -> Any:
"""Evaluate a simple expression safely via AST whitelist.
Supports: literals, comparisons (==, !=, <, >, <=, >=, in, not in, is, is not),
boolean logic (and, or, not), arithmetic (+, -, *, /, //, %, **), and
string operations (contains via ``in``).
Raises ``ValueError`` on any disallowed construct (function calls,
attribute access, imports, etc.).
"""
tree = ast.parse(expr.strip(), mode='eval')
return _eval_node(tree.body)
def _eval_node(node: ast.AST) -> Any:
# Literals: numbers, strings, True/False/None
if isinstance(node, ast.Constant):
return node.value
# Unary operators: -x, +x, not x
if isinstance(node, ast.UnaryOp):
op_fn = _SAFE_OPS.get(type(node.op))
if op_fn is None:
raise ValueError(f'Unsupported unary op: {type(node.op).__name__}')
return op_fn(_eval_node(node.operand))
# Binary operators: x + y, x * y, etc.
if isinstance(node, ast.BinOp):
op_fn = _SAFE_OPS.get(type(node.op))
if op_fn is None:
raise ValueError(f'Unsupported binary op: {type(node.op).__name__}')
return op_fn(_eval_node(node.left), _eval_node(node.right))
# Comparisons: x == y, x > y, x in y, etc. (chained)
if isinstance(node, ast.Compare):
left = _eval_node(node.left)
for op, comparator in zip(node.ops, node.comparators):
op_fn = _SAFE_OPS.get(type(op))
if op_fn is None:
raise ValueError(f'Unsupported comparison: {type(op).__name__}')
right = _eval_node(comparator)
if not op_fn(left, right):
return False
left = right
return True
# Boolean operators: x and y, x or y
if isinstance(node, ast.BoolOp):
if isinstance(node.op, ast.And):
return all(_eval_node(v) for v in node.values)
if isinstance(node.op, ast.Or):
return any(_eval_node(v) for v in node.values)
# Ternary: x if cond else y
if isinstance(node, ast.IfExp):
return _eval_node(node.body) if _eval_node(node.test) else _eval_node(node.orelse)
# Tuples / Lists (used in "x in [1,2,3]")
if isinstance(node, (ast.Tuple, ast.List)):
return [_eval_node(e) for e in node.elts]
# Name lookup only allow None, True, False
if isinstance(node, ast.Name):
if node.id == 'None':
return None
if node.id == 'True':
return True
if node.id == 'False':
return False
raise ValueError(f'Unsupported variable reference: {node.id}')
raise ValueError(f'Unsupported expression node: {type(node).__name__}')
class WorkflowExecutor:
"""
Workflow execution engine.
Handles the execution of workflow definitions with proper control flow.
"""
def __init__(self, ap: Optional['app.Application'] = None):
self.ap = ap
self.registry = NodeTypeRegistry.instance()
self._edges: list[EdgeDefinition] = []
async def execute(
self, workflow: WorkflowDefinition, context: ExecutionContext, start_node_id: Optional[str] = None
) -> ExecutionContext:
"""
Execute a workflow.
Args:
workflow: Workflow definition
context: Execution context
start_node_id: Optional starting node (for resumption)
Returns:
Updated execution context
"""
context.status = ExecutionStatus.RUNNING
context.start_time = datetime.now()
# Note: Frontend panel logging has been removed.
# A new solution will be implemented separately.
monitoring_message_id = ''
try:
# Build execution graph
node_map = {node.id: node for node in workflow.nodes}
edge_map = self._build_edge_map(workflow.edges)
self._edges = workflow.edges
# Initialize node states
for node in workflow.nodes:
if node.id not in context.node_states:
context.node_states[node.id] = NodeState(node_id=node.id, node_type=node.type, status=NodeStatus.PENDING)
# Find start node(s)
if start_node_id:
start_nodes = [node_map[start_node_id]]
else:
start_nodes = self._find_start_nodes(workflow.nodes, workflow.edges)
if not start_nodes:
raise ValueError('No start nodes found in workflow')
# Execute from start nodes
for start_node in start_nodes:
await self._execute_from_node(
start_node, node_map, edge_map, context, workflow.settings.max_retries, path=set()
)
# Check final status
all_completed = all(
state.status in (NodeStatus.COMPLETED, NodeStatus.SKIPPED) for state in context.node_states.values()
)
if all_completed:
context.status = ExecutionStatus.COMPLETED
else:
# Some nodes might still be waiting
has_failed = any(state.status == NodeStatus.FAILED for state in context.node_states.values())
if has_failed:
context.status = ExecutionStatus.FAILED
except Exception as e:
context.status = ExecutionStatus.FAILED
context.error = str(e)
logger.error(
'Workflow execution failed',
exc_info=True,
extra={
'workflow_id': workflow.uuid,
'execution_id': context.execution_id,
'node_states': {
node_id: {
'status': state.status.value if state.status else None,
'error': state.error,
}
for node_id, state in context.node_states.items()
},
},
)
# Note: Frontend panel logging has been removed.
# A new solution will be implemented separately.
finally:
context.end_time = datetime.now()
# Note: Frontend panel logging has been removed.
# A new solution will be implemented separately.
return context
async def _execute_from_node(
self,
node: NodeDefinition,
node_map: dict[str, NodeDefinition],
edge_map: dict[str, list[EdgeDefinition]],
context: ExecutionContext,
max_retries: int = 3,
path: set[str] | None = None,
):
"""Execute workflow starting from a specific node"""
# Initialize path set for cycle detection (path-based, not global visited)
if path is None:
path = set()
# Check for circular dependency on the *current path* only
# This correctly allows diamond shapes (A→B, A→C, B→D, C→D)
if node.id in path:
logger.warning(f'Circular dependency detected at node: {node.id}')
context.node_states[node.id].status = NodeStatus.SKIPPED
context.node_states[node.id].error = 'Circular dependency detected'
context.node_states[node.id].end_time = datetime.now()
await self._persist_node_execution(node, context.node_states[node.id], context)
return
# Add node to current path
path.add(node.id)
# Check if node should be skipped
if await self._should_skip_node(node, context):
existing_state = context.node_states[node.id]
if existing_state.status == NodeStatus.SKIPPED:
existing_state.end_time = existing_state.end_time or datetime.now()
await self._persist_node_execution(node, existing_state, context)
path.discard(node.id)
return
# Execute current node
await self._execute_node(node, context, max_retries)
# If node failed and we should stop on error, return
if context.node_states[node.id].status == NodeStatus.FAILED:
path.discard(node.id)
return
node_state = context.node_states[node.id]
node_type_name = node.type.split('.')[-1] if '.' in node.type else node.type
# ── Control flow integration ────────────────────────────────
# For loop / iterator nodes: run the LoopExecutor over
# downstream body nodes for each item, then continue to the
# "completed" output edge.
if node_type_name in ('loop', 'iterator'):
items = node_state.outputs.get('_items') or []
if not items:
# iterator: items come from inputs
items = node_state.inputs.get('items', node_state.inputs.get('array', []))
if not isinstance(items, list):
items = [items] if items else []
max_iter = int(node.config.get('max_iterations', 100))
items = items[:max_iter]
# Collect downstream "body" nodes (connected via edges)
outgoing_edges = edge_map.get(node.id, [])
body_nodes = []
for edge in outgoing_edges:
target = node_map.get(edge.target_node)
if target:
body_nodes.append(target)
if body_nodes and items:
loop_exec = LoopExecutor(self)
results = await loop_exec.execute_loop(items, body_nodes, context, max_iter)
node_state.outputs['results'] = results
node_state.outputs['completed'] = True
else:
node_state.outputs['results'] = []
node_state.outputs['completed'] = True
path.discard(node.id)
return # body nodes already executed by LoopExecutor
# For parallel nodes: run downstream branches concurrently
if node_type_name == 'parallel':
outgoing_edges = edge_map.get(node.id, [])
branch_nodes = []
for edge in outgoing_edges:
target = node_map.get(edge.target_node)
if target:
branch_nodes.append([target])
if branch_nodes:
par_exec = ParallelExecutor(self)
results = await par_exec.execute_parallel(branch_nodes, context)
node_state.outputs['results'] = results
path.discard(node.id)
return # branch nodes already executed by ParallelExecutor
# ── Standard edge-based continuation ────────────────────────
# Get outgoing edges
outgoing_edges = edge_map.get(node.id, [])
# Execute next nodes based on edge conditions
for edge in outgoing_edges:
target_node = node_map.get(edge.target_node)
if not target_node:
continue
# Check edge condition
if edge.condition:
condition_met = await self._evaluate_condition(edge.condition, context)
if not condition_met:
continue
# Check if all inputs are ready
if await self._inputs_ready(target_node, edge_map, context):
await self._execute_from_node(target_node, node_map, edge_map, context, max_retries, path)
# Remove node from path when backtracking (allows diamond revisit)
path.discard(node.id)
async def _execute_node(self, node: NodeDefinition, context: ExecutionContext, max_retries: int = 3):
"""Execute a single node with retry logic"""
node_state = context.node_states[node.id]
node_state.status = NodeStatus.RUNNING
node_state.start_time = datetime.now()
# Get node instance (pass ap for access to services)
node_instance = self.registry.create_instance(node.type, node.id, node.config, ap=self.ap)
if not node_instance:
node_state.status = NodeStatus.FAILED
node_state.error = f'Unknown node type: {node.type}'
node_state.end_time = datetime.now()
self._record_execution_step(node, node_state, context)
await self._persist_node_execution(node, node_state, context)
return
# Resolve inputs
inputs = await self._resolve_inputs(node, context)
node_state.inputs = inputs
# Validate inputs
validation_errors = await node_instance.validate_inputs(inputs)
if validation_errors:
node_state.status = NodeStatus.FAILED
node_state.error = '; '.join(validation_errors)
node_state.end_time = datetime.now()
self._record_execution_step(node, node_state, context)
await self._persist_node_execution(node, node_state, context)
return
# Check if node supports streaming (has execute_stream method and stream config is enabled)
use_streaming = hasattr(node_instance, 'execute_stream') and node.config.get('stream', False)
# Execute with retries
for attempt in range(max_retries + 1):
try:
if use_streaming:
# Streaming execution with aggregation and timeout
aggregated_response = ''
try:
async with asyncio.timeout(300): # 5 minute timeout for streaming
async for chunk in node_instance.execute_stream(inputs, context):
if chunk:
aggregated_response += chunk
except asyncio.TimeoutError:
logger.warning(f'Node {node.id} ({node.type}) streaming timed out, falling back to non-streaming')
use_streaming = False
outputs = await node_instance.execute(inputs, context)
else:
# Get response from context if set by execute_stream, otherwise use aggregated
final_response = context.variables.pop('_last_llm_response', aggregated_response)
outputs = {'response': final_response, 'usage': {'prompt_tokens': 0, 'completion_tokens': 0, 'total_tokens': 0}}
logger.info(f'Node {node.id} ({node.type}) streaming completed, response length: {len(final_response)}')
else:
outputs = await node_instance.execute(inputs, context)
node_state.outputs = outputs
node_state.status = NodeStatus.COMPLETED
node_state.end_time = datetime.now()
break
except Exception as e:
node_state.retry_count = attempt + 1
logger.error(
f'Node {node.id} ({node.type}) execution failed (attempt {attempt + 1}/{max_retries + 1}): {e}',
exc_info=True,
extra={
'node_id': node.id,
'node_type': node.type,
'attempt': attempt + 1,
'max_retries': max_retries,
'execution_id': context.execution_id,
},
)
if attempt < max_retries:
await asyncio.sleep(1) # Brief delay before retry
else:
node_state.status = NodeStatus.FAILED
node_state.error = str(e)
node_state.end_time = datetime.now()
logger.error(
f'Node {node.id} ({node.type}) permanently failed after {max_retries + 1} attempts',
extra={
'node_id': node.id,
'node_type': node.type,
'error': str(e),
'execution_id': context.execution_id,
},
)
self._record_execution_step(node, node_state, context)
await self._persist_node_execution(node, node_state, context)
async def _resolve_inputs(self, node: NodeDefinition, context: ExecutionContext) -> dict[str, Any]:
"""Resolve input values for a node from connected nodes and context"""
inputs = {}
# Get inputs from context variables
if 'message' in context.variables:
inputs['message'] = context.variables['message']
# Get inputs from message context
if context.message_context:
inputs['message'] = context.message_context.message_content
inputs['message_content'] = context.message_context.message_content
inputs['sender_id'] = context.message_context.sender_id
inputs['platform'] = context.message_context.platform
else:
logger.warning(
f'[_resolve_inputs] node={node.id} ({node.type}): message_context is None!',
extra={
'node_id': node.id,
'node_type': node.type,
'execution_id': context.execution_id,
'variables_keys': list(context.variables.keys()) if context.variables else [],
},
)
# Log current inputs state after message_context processing
logger.debug(
f'[_resolve_inputs] node={node.id} after message_context: {list(inputs.keys())}',
)
# Get inputs from node config that reference other nodes
for key, value in node.config.items():
if isinstance(value, str) and value.startswith('{{') and value.endswith('}}'):
resolved = await self._resolve_expression(value[2:-2], context)
inputs[key] = resolved
else:
inputs[key] = value
# Get inputs from connected upstream nodes via edges
# Build a reverse map: for each incoming edge to this node, find the
# source node and the specific source/target port.
for edge in self._edges:
if edge.target_node != node.id:
continue
source_state = context.node_states.get(edge.source_node)
if not source_state or source_state.status != NodeStatus.COMPLETED:
continue
target_port = edge.target_port or 'input'
source_port = edge.source_port or 'output'
# Map the source node's output port value to this node's input port
if source_port in source_state.outputs:
inputs[target_port] = source_state.outputs[source_port]
elif 'output' in source_state.outputs:
# Fallback: if exact port not found, try generic 'output'
inputs[target_port] = source_state.outputs['output']
elif source_state.outputs:
# Last resort: use the first available output
inputs[target_port] = next(iter(source_state.outputs.values()))
# Smart input mapping: if a node needs 'message' but received a different
# port name (e.g., 'content' from llm_call), copy the value to 'message'.
# This handles edge connection mismatches where the sender uses a different
# port name than what the receiver expects.
if 'message' not in inputs or inputs.get('message') is None:
for fallback_key in ('content', 'response', 'input', 'output', 'result', 'text'):
if fallback_key in inputs and inputs[fallback_key] is not None:
inputs['message'] = inputs[fallback_key]
logger.debug(
f'[_resolve_inputs] node={node.id}: mapped {fallback_key} -> message',
)
break
logger.debug(
f'[_resolve_inputs] node={node.id} final inputs keys: {list(inputs.keys())}, message={repr(inputs.get("message", "<missing>")[:100] if isinstance(inputs.get("message"), str) else inputs.get("message"))}',
)
return inputs
async def _resolve_expression(self, expression: str, context: ExecutionContext) -> Any:
"""Resolve a variable expression like 'nodes.node1.outputs.text'"""
parts = expression.strip().split('.')
if not parts:
return None
if parts[0] == 'nodes' and len(parts) >= 4:
# nodes.node_id.outputs.output_name
node_id = parts[1]
if parts[2] == 'outputs' and node_id in context.node_states:
output_name = '.'.join(parts[3:])
return context.node_states[node_id].outputs.get(output_name)
elif parts[0] == 'variables':
# variables.var_name
var_name = '.'.join(parts[1:])
return context.variables.get(var_name)
elif parts[0] == 'conversation_variables':
# conversation_variables.var_name
var_name = '.'.join(parts[1:])
return context.conversation_variables.get(var_name)
elif parts[0] == 'message':
# message.content, message.sender_id, etc.
if context.message_context:
attr = parts[1] if len(parts) > 1 else None
if attr == 'content':
return context.message_context.message_content
elif attr == 'sender_id':
return context.message_context.sender_id
elif attr == 'platform':
return context.message_context.platform
elif attr == 'conversation_id':
return context.message_context.conversation_id
return None
async def _evaluate_condition(self, condition: str, context: ExecutionContext) -> bool:
"""Evaluate a condition expression safely using AST whitelist"""
try:
# Resolve variable references in condition
if '{{' in condition:
import re
pattern = r'\{\{([^}]+)\}\}'
# First pass: replace all variable references with placeholders
placeholders = {}
placeholder_idx = 0
def replace_with_placeholder(match):
nonlocal placeholder_idx
var_expr = match.group(1)
placeholder = f'__PH{placeholder_idx}__'
placeholders[placeholder] = var_expr
placeholder_idx += 1
return placeholder
condition_with_placeholders = re.sub(pattern, replace_with_placeholder, condition)
# Second pass: resolve each placeholder asynchronously
for placeholder, var_expr in placeholders.items():
value = await self._resolve_expression(var_expr, context)
if isinstance(value, str):
condition_with_placeholders = condition_with_placeholders.replace(placeholder, f'"{value}"')
elif value is None:
condition_with_placeholders = condition_with_placeholders.replace(placeholder, 'None')
else:
condition_with_placeholders = condition_with_placeholders.replace(placeholder, str(value))
condition = condition_with_placeholders
# Safe expression evaluation using AST whitelist
result = _safe_eval(condition)
return bool(result)
except Exception as e:
logger.warning(f'Condition evaluation failed: {condition} - {e}')
return False
async def _should_skip_node(self, node: NodeDefinition, context: ExecutionContext) -> bool:
"""Check if a node should be skipped"""
state = context.node_states.get(node.id)
if state and state.status in (NodeStatus.COMPLETED, NodeStatus.RUNNING, NodeStatus.SKIPPED):
return True
return False
async def _inputs_ready(
self, node: NodeDefinition, edge_map: dict[str, list[EdgeDefinition]], context: ExecutionContext
) -> bool:
"""Check if all inputs for a node are ready"""
# Find all edges that connect to this node
incoming_nodes = set()
for source_id, edges in edge_map.items():
for edge in edges:
if edge.target_node == node.id:
incoming_nodes.add(source_id)
# Check if all incoming nodes have completed
for source_id in incoming_nodes:
state = context.node_states.get(source_id)
if not state or state.status not in (NodeStatus.COMPLETED, NodeStatus.SKIPPED):
return False
return True
def _find_start_nodes(self, nodes: list[NodeDefinition], edges: list[EdgeDefinition]) -> list[NodeDefinition]:
"""Find nodes that have no incoming edges (start nodes)"""
target_nodes = {edge.target_node for edge in edges}
start_nodes = [node for node in nodes if node.id not in target_nodes]
# Also check for trigger nodes
trigger_types = {'message_trigger', 'cron_trigger', 'webhook_trigger', 'event_trigger'}
for node in nodes:
if node.type in trigger_types and node not in start_nodes:
start_nodes.insert(0, node)
return start_nodes
def _build_edge_map(self, edges: list[EdgeDefinition]) -> dict[str, list[EdgeDefinition]]:
"""Build a map of source node ID to outgoing edges"""
edge_map: dict[str, list[EdgeDefinition]] = {}
for edge in edges:
if edge.source_node not in edge_map:
edge_map[edge.source_node] = []
edge_map[edge.source_node].append(edge)
return edge_map
def _record_execution_step(self, node: NodeDefinition, node_state: NodeState, context: ExecutionContext):
"""Record an execution step in the history"""
duration_ms = 0
if node_state.start_time and node_state.end_time:
duration_ms = int((node_state.end_time - node_state.start_time).total_seconds() * 1000)
step = ExecutionStep(
step_id=f"step_{uuid.uuid4().hex[:8]}",
timestamp=datetime.now(),
node_id=node.id,
node_type=node.type,
status=node_state.status,
duration_ms=duration_ms,
error=node_state.error,
inputs=node_state.inputs,
outputs=node_state.outputs,
)
context.history.append(step)
async def _persist_node_execution(
self,
node: NodeDefinition,
node_state: NodeState,
context: ExecutionContext,
):
"""Persist node execution state for execution detail and logs."""
if not self.ap:
return
values = {
'execution_uuid': context.execution_id,
'node_id': node.id,
'node_type': node.type,
'status': node_state.status.value,
'inputs': node_state.inputs,
'outputs': node_state.outputs,
'start_time': node_state.start_time,
'end_time': node_state.end_time,
'error': node_state.error,
'retry_count': node_state.retry_count,
}
existing_query = sqlalchemy.select(persistence_workflow.WorkflowNodeExecution).where(
persistence_workflow.WorkflowNodeExecution.execution_uuid == context.execution_id,
persistence_workflow.WorkflowNodeExecution.node_id == node.id,
)
existing_result = await self.ap.persistence_mgr.execute_async(existing_query)
existing = existing_result.first()
if existing is None:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(persistence_workflow.WorkflowNodeExecution).values(**values)
)
else:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_workflow.WorkflowNodeExecution)
.where(persistence_workflow.WorkflowNodeExecution.id == existing.id)
.values(**values)
)
class ParallelExecutor:
"""Execute multiple branches in parallel"""
def __init__(self, executor: WorkflowExecutor):
self.executor = executor
async def execute_parallel(
self, branches: list[list[NodeDefinition]], context: ExecutionContext
) -> list[dict[str, Any]]:
"""
Execute multiple branches in parallel.
Args:
branches: List of node sequences to execute in parallel
context: Execution context
Returns:
List of results from each branch
"""
tasks = []
for branch in branches:
task = self._execute_branch(branch, context)
tasks.append(task)
results = await asyncio.gather(*tasks, return_exceptions=True)
processed_results = []
for result in results:
if isinstance(result, Exception):
processed_results.append({'error': str(result)})
else:
processed_results.append(result)
return processed_results
async def _execute_branch(self, nodes: list[NodeDefinition], context: ExecutionContext) -> dict[str, Any]:
"""Execute a single branch"""
# Create a copy of context for this branch
branch_outputs = {}
for node in nodes:
await self.executor._execute_node(node, context, max_retries=3)
state = context.node_states.get(node.id)
if state and state.status == NodeStatus.COMPLETED:
branch_outputs[node.id] = state.outputs
elif state and state.status == NodeStatus.FAILED:
branch_outputs['error'] = state.error
break
return branch_outputs
class LoopExecutor:
"""Execute loop iterations"""
def __init__(self, executor: WorkflowExecutor):
self.executor = executor
async def execute_loop(
self, items: list[Any], loop_body: list[NodeDefinition], context: ExecutionContext, max_iterations: int = 100
) -> list[dict[str, Any]]:
"""
Execute a loop over items.
Args:
items: Items to iterate over
loop_body: Nodes to execute for each item
context: Execution context
max_iterations: Maximum number of iterations
Returns:
List of results from each iteration
"""
results = []
for i, item in enumerate(items[:max_iterations]):
# Set loop variables
context.variables['loop_item'] = item
context.variables['loop_index'] = i
context.variables['loop_is_first'] = i == 0
context.variables['loop_is_last'] = i == len(items) - 1
iteration_result = {}
for node in loop_body:
# Reset node state for this iteration
context.node_states[node.id] = NodeState(node_id=node.id, node_type=node.type, status=NodeStatus.PENDING)
await self.executor._execute_node(node, context, max_retries=3)
state = context.node_states.get(node.id)
if state:
iteration_result[node.id] = state.outputs
# Check for break condition
if state.outputs.get('break', False):
results.append(iteration_result)
return results
results.append(iteration_result)
# Clean up loop variables
context.variables.pop('loop_item', None)
context.variables.pop('loop_index', None)
context.variables.pop('loop_is_first', None)
context.variables.pop('loop_is_last', None)
return results

View File

@@ -0,0 +1,284 @@
"""Workflow node metadata loading and validation.
This module makes YAML files under ``templates/metadata/nodes`` the backend
source of truth for workflow node metadata. Python node classes still provide
execution logic, but UI-facing metadata is loaded from YAML.
"""
from __future__ import annotations
import copy
import logging
from importlib import resources
from pathlib import Path
from typing import Any, Iterable, Optional
import yaml
logger = logging.getLogger(__name__)
class MetadataLoadError(Exception):
"""Raised when a workflow node metadata file cannot be loaded."""
class MetadataValidationError(Exception):
"""Raised when workflow node metadata does not match the expected shape."""
class NodeMetadataValidator:
"""Validate workflow node metadata loaded from YAML files.
The validator is intentionally strict about the structural fields that the
editor needs, but tolerant of legacy YAML details such as missing top-level
``label`` or additional frontend field types.
"""
REQUIRED_FIELDS = ('name', 'category', 'inputs', 'outputs', 'config')
VALID_CATEGORIES = {'trigger', 'process', 'control', 'action', 'integration', 'misc'}
VALID_PORT_TYPES = {'any', 'string', 'number', 'integer', 'boolean', 'object', 'array', 'datetime', 'null'}
VALID_CONFIG_TYPES = {
'string',
'integer',
'number',
'float',
'boolean',
'select',
'json',
'textarea',
'text',
'secret',
'array[string]',
'file',
'array[file]',
'llm-model-selector',
'embedding-model-selector',
'rerank-model-selector',
'pipeline-selector',
'knowledge-base-selector',
'knowledge-base-multi-selector',
'bot-selector',
'tools-selector',
'model-fallback-selector',
'prompt-editor',
'plugin-selector',
'webhook-url',
'embed-code',
'workflow-selector',
}
def validate(self, metadata: dict[str, Any]) -> list[str]:
"""Return validation errors. An empty list means the metadata is valid."""
errors: list[str] = []
if not isinstance(metadata, dict):
return ['metadata root must be a mapping']
for field in self.REQUIRED_FIELDS:
if field not in metadata:
errors.append(f'missing required field: {field}')
if errors:
return errors
name = metadata.get('name')
if not isinstance(name, str) or not name.strip():
errors.append('field "name" must be a non-empty string')
category = metadata.get('category')
if category not in self.VALID_CATEGORIES:
errors.append(f'invalid category: {category}')
errors.extend(self._validate_ports(metadata.get('inputs'), 'inputs'))
errors.extend(self._validate_ports(metadata.get('outputs'), 'outputs'))
errors.extend(self._validate_config(metadata.get('config')))
return errors
def validate_or_raise(self, metadata: dict[str, Any]) -> dict[str, Any]:
"""Validate metadata and raise ``MetadataValidationError`` on failure."""
errors = self.validate(metadata)
if errors:
node_name = metadata.get('name', 'unknown') if isinstance(metadata, dict) else 'unknown'
raise MetadataValidationError(f'invalid metadata for {node_name}: {errors}')
return metadata
def _validate_ports(self, ports: Any, field_name: str) -> list[str]:
errors: list[str] = []
if not isinstance(ports, list):
return [f'{field_name} must be a list']
seen_names: set[str] = set()
for index, port in enumerate(ports):
path = f'{field_name}[{index}]'
if not isinstance(port, dict):
errors.append(f'{path} must be a mapping')
continue
name = port.get('name')
if not isinstance(name, str) or not name:
errors.append(f'{path}.name must be a non-empty string')
continue
if name in seen_names:
errors.append(f'{path}.name duplicates "{name}"')
seen_names.add(name)
port_type = port.get('type', 'any')
if port_type not in self.VALID_PORT_TYPES:
errors.append(f'{path}.type has unsupported value "{port_type}"')
return errors
def _validate_config(self, config: Any) -> list[str]:
errors: list[str] = []
if not isinstance(config, list):
return ['config must be a list']
seen_names: set[str] = set()
for index, item in enumerate(config):
path = f'config[{index}]'
if not isinstance(item, dict):
errors.append(f'{path} must be a mapping')
continue
name = item.get('name')
if not isinstance(name, str) or not name:
errors.append(f'{path}.name must be a non-empty string')
continue
if name in seen_names:
errors.append(f'{path}.name duplicates "{name}"')
seen_names.add(name)
item_type = item.get('type', 'string')
if item_type not in self.VALID_CONFIG_TYPES:
errors.append(f'{path}.type has unsupported value "{item_type}"')
min_value = item.get('min_value')
max_value = item.get('max_value')
if isinstance(min_value, (int, float)) and isinstance(max_value, (int, float)) and min_value > max_value:
errors.append(f'{path}.min_value must be <= max_value')
return errors
class NodeMetadataLoader:
"""Load and cache workflow node metadata from YAML files."""
def __init__(self, validator: Optional[NodeMetadataValidator] = None) -> None:
self._validator = validator or NodeMetadataValidator()
self._metadata: dict[str, dict[str, Any]] = {}
self._sources: dict[str, str] = {}
self._load_errors: list[dict[str, str]] = []
async def load_core_metadata(self, resource_dir: str = 'metadata/nodes') -> int:
"""Load all core node metadata from the ``langbot.templates`` package."""
return await self.load_package_directory('langbot.templates', resource_dir, source='core')
async def load_package_directory(self, package: str, resource_dir: str, source: str = 'core') -> int:
"""Load YAML files from a package resource directory."""
try:
root = resources.files(package).joinpath(resource_dir)
yaml_files = sorted(
(item for item in root.iterdir() if item.is_file() and item.name.endswith(('.yaml', '.yml'))),
key=lambda item: item.name,
)
except Exception as exc:
raise MetadataLoadError(f'failed to scan package directory {package}:{resource_dir}: {exc}') from exc
return self._load_files(yaml_files, source=source)
async def load_directory(self, directory: str | Path, source: str) -> int:
"""Load YAML files from an external filesystem directory, e.g. a plugin."""
directory_path = Path(directory)
if not directory_path.exists():
logger.warning('Workflow metadata directory does not exist: %s', directory_path)
return 0
if not directory_path.is_dir():
raise MetadataLoadError(f'workflow metadata path is not a directory: {directory_path}')
yaml_files = sorted(directory_path.glob('*.yml')) + sorted(directory_path.glob('*.yaml'))
return self._load_files(yaml_files, source=source)
def get_metadata(self, node_type: str) -> Optional[dict[str, Any]]:
"""Return metadata by full type or short node name."""
if node_type in self._metadata:
return copy.deepcopy(self._metadata[node_type])
short_name = node_type.split('.')[-1]
for registered_type, metadata in self._metadata.items():
if registered_type.split('.')[-1] == short_name or metadata.get('name') == short_name:
return copy.deepcopy(metadata)
return None
def get_all_metadata(self) -> dict[str, dict[str, Any]]:
"""Return a deep copy of all loaded metadata keyed by canonical node type."""
return copy.deepcopy(self._metadata)
def get_load_errors(self) -> list[dict[str, str]]:
"""Return metadata files that failed to load or validate."""
return copy.deepcopy(self._load_errors)
def clear(self) -> None:
"""Clear all cached metadata and errors."""
self._metadata.clear()
self._sources.clear()
self._load_errors.clear()
def _load_files(self, yaml_files: Iterable[Any], source: str) -> int:
count = 0
for yaml_file in yaml_files:
file_name = getattr(yaml_file, 'name', str(yaml_file))
try:
metadata = self._load_yaml(yaml_file)
self._validator.validate_or_raise(metadata)
node_type = build_node_type(metadata)
if node_type in self._metadata:
existing_source = self._sources.get(node_type, 'unknown')
if existing_source == 'core' and source != 'core':
raise MetadataLoadError(
f'plugin source "{source}" attempted to override core node "{node_type}"'
)
logger.warning(
'Workflow node metadata %s from %s overrides previous source %s',
node_type,
source,
existing_source,
)
cached_metadata = copy.deepcopy(metadata)
cached_metadata['_source'] = source
cached_metadata['_file'] = file_name
self._metadata[node_type] = cached_metadata
self._sources[node_type] = source
count += 1
except Exception as exc:
self._load_errors.append({'file': file_name, 'source': source, 'error': str(exc)})
logger.error('Failed to load workflow node metadata %s: %s', file_name, exc)
return count
def _load_yaml(self, yaml_file: Any) -> dict[str, Any]:
try:
if hasattr(yaml_file, 'open'):
with yaml_file.open('r', encoding='utf-8') as file:
data = yaml.load(file, Loader=yaml.FullLoader)
else:
with open(yaml_file, 'r', encoding='utf-8') as file:
data = yaml.load(file, Loader=yaml.FullLoader)
except Exception as exc:
raise MetadataLoadError(f'failed to parse YAML: {exc}') from exc
if not isinstance(data, dict):
raise MetadataLoadError('YAML root must be a mapping')
return data
def build_node_type(metadata: dict[str, Any]) -> str:
"""Build canonical ``category.name`` node type from metadata."""
category = metadata.get('category') or 'misc'
name = metadata.get('name') or ''
return f'{category}.{name}'

View File

@@ -0,0 +1,61 @@
"""
Monitoring helper for recording events during workflow execution.
This module provides convenient methods to record monitoring data
without cluttering the main workflow code.
NOTE: All frontend panel logging functionality has been removed.
A new solution will be implemented separately.
"""
from __future__ import annotations
import typing
import time
if typing.TYPE_CHECKING:
from ..core import app
from langbot_plugin.api.entities.builtin.workflow.query import WorkflowQuery
class WorkflowMonitoringHelper:
"""Helper class for workflow monitoring operations"""
# All frontend panel logging methods have been removed.
# A new solution will be implemented separately.
pass
class LLMCallMonitor:
"""Context manager for monitoring LLM calls in workflow"""
def __init__(
self,
ap: app.Application,
query: WorkflowQuery,
bot_id: str,
bot_name: str,
workflow_id: str,
workflow_name: str,
node_name: str,
model_name: str,
):
self.ap = ap
self.query = query
self.bot_id = bot_id
self.bot_name = bot_name
self.workflow_id = workflow_id
self.workflow_name = workflow_name
self.node_name = node_name
self.model_name = model_name
self.start_time = None
self.input_tokens = 0
self.output_tokens = 0
async def __aenter__(self):
self.start_time = time.time()
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
# LLM call monitoring has been removed.
# A new solution will be implemented separately.
return False

View File

@@ -0,0 +1,350 @@
"""
Monitoring helper for recording events during workflow execution.
This module provides convenient methods to record monitoring data
without cluttering the main workflow code.
Logging scheme (aligned with pipeline monitoring):
- Trigger log: stores original user message content directly
- LLM call log: uses record_llm_call only (no additional message record)
- LLM response log: stores response message content directly
- Reply log: stores reply content directly
Fields are extracted from WorkflowQuery object when available, with fallback to context_vars.
"""
from __future__ import annotations
import typing
import time
import json
if typing.TYPE_CHECKING:
from ..core import app
from langbot_plugin.api.entities.builtin.workflow.query import WorkflowQuery
class WorkflowMonitoringHelper:
"""Helper class for workflow monitoring operations"""
@staticmethod
def _get_session_id(query, context_vars: dict | None = None) -> str:
"""Build session_id from query or context_vars"""
# Try to get from query first
if not isinstance(query, str) and query.launcher_type:
launcher_type = query.launcher_type.value if hasattr(query.launcher_type, 'value') else str(query.launcher_type)
launcher_id = query.launcher_id or 'unknown'
return f'{launcher_type}_{launcher_id}'
# Fallback to context_vars
if context_vars and context_vars.get('_launcher_type') and context_vars.get('_launcher_id'):
return f"{context_vars['_launcher_type']}_{context_vars['_launcher_id']}"
return 'workflow_session'
@staticmethod
def _get_platform(query, context_vars: dict | None = None) -> str:
"""Get platform name from query or context_vars"""
if not isinstance(query, str) and query.launcher_type:
if hasattr(query.launcher_type, 'value'):
return query.launcher_type.value
return str(query.launcher_type)
return 'workflow'
@staticmethod
def _get_sender_name(query, context_vars: dict | None = None) -> str | None:
"""Get sender name from query or context_vars"""
# Try query first
if not isinstance(query, str):
if query.sender_name:
return query.sender_name
if query.message_event and hasattr(query.message_event, 'sender'):
sender = query.message_event.sender
if hasattr(sender, 'nickname'):
return sender.nickname
if hasattr(sender, 'member_name'):
return sender.member_name
# Fallback to context_vars
if context_vars:
return context_vars.get('_sender_name')
return None
@staticmethod
async def record_trigger_log(
ap: app.Application,
query,
workflow_id: str,
workflow_name: str,
bot_name: str = 'Workflow',
context_vars: dict | None = None,
) -> str:
"""Record trigger node log (stores original user message content directly)
Aligned with pipeline monitoring: record_query_start
"""
try:
session_id = WorkflowMonitoringHelper._get_session_id(query, context_vars)
platform = WorkflowMonitoringHelper._get_platform(query, context_vars)
sender_name = WorkflowMonitoringHelper._get_sender_name(query, context_vars)
# Get message content - store original content directly
message_content = ''
if isinstance(query, str):
message_content = query
elif not isinstance(query, str) and query.message_context:
message_content = query.message_context.message_content
elif not isinstance(query, str) and query.message_chain and hasattr(query.message_chain, 'model_dump'):
message_content = json.dumps(query.message_chain.model_dump(), ensure_ascii=False)
elif not isinstance(query, str) and query.user_message:
message_content = str(query.user_message)
# Get bot_id and user_id
bot_id = ''
user_id = None
if not isinstance(query, str):
bot_id = query.bot_uuid or ''
user_id = query.sender_id
elif context_vars:
bot_id = context_vars.get('_bot_id', '') or ''
user_id = context_vars.get('_user_id')
message_id = await ap.monitoring_service.record_message(
bot_id=bot_id,
bot_name=bot_name,
pipeline_id=workflow_id,
pipeline_name=workflow_name or 'Workflow',
message_content=message_content,
session_id=session_id,
status='success',
level='info',
platform=platform,
user_id=user_id,
user_name=sender_name,
role='user',
runner_name='local-workflow',
)
return message_id
except Exception as e:
ap.logger.error(f'Failed to record trigger log: {e}')
return ''
@staticmethod
async def record_llm_call_log(
ap: app.Application,
query,
workflow_id: str,
workflow_name: str,
node_name: str,
model_name: str,
input_tokens: int,
output_tokens: int,
duration_ms: int,
status: str = 'success',
error_message: str | None = None,
bot_name: str = 'Workflow',
context_vars: dict | None = None,
input_message: str | None = None,
message_id: str | None = None,
):
"""Record LLM call log with message_id association
Aligned with pipeline monitoring: record_llm_call with message_id
LLM calls are aggregated under the trigger log via message_id.
"""
try:
session_id = WorkflowMonitoringHelper._get_session_id(query, context_vars)
# Get bot_id
bot_id = ''
if not isinstance(query, str):
bot_id = query.bot_uuid or ''
elif context_vars:
bot_id = context_vars.get('_bot_id', '') or ''
# Record LLM call with message_id for association
await ap.monitoring_service.record_llm_call(
bot_id=bot_id,
bot_name=bot_name,
pipeline_id=workflow_id,
pipeline_name=workflow_name or 'Workflow',
session_id=session_id,
model_name=model_name,
input_tokens=input_tokens,
output_tokens=output_tokens,
duration=duration_ms,
status=status,
error_message=error_message,
message_id=message_id,
)
except Exception as e:
ap.logger.error(f'Failed to record LLM call log: {e}')
@staticmethod
async def record_llm_response_log(
ap: app.Application,
query,
workflow_id: str,
workflow_name: str,
node_name: str,
response_content: str,
bot_name: str = 'Workflow',
context_vars: dict | None = None,
):
"""Record LLM response log (stores response content directly)
Aligned with pipeline monitoring: record_query_response
"""
try:
session_id = WorkflowMonitoringHelper._get_session_id(query, context_vars)
platform = WorkflowMonitoringHelper._get_platform(query, context_vars)
sender_name = WorkflowMonitoringHelper._get_sender_name(query, context_vars)
# Get bot_id and user_id
bot_id = ''
user_id = None
if not isinstance(query, str):
bot_id = query.bot_uuid or ''
user_id = query.sender_id
elif context_vars:
bot_id = context_vars.get('_bot_id', '') or ''
user_id = context_vars.get('_user_id')
# Store response content directly, no prefix
await ap.monitoring_service.record_message(
bot_id=bot_id,
bot_name=bot_name,
pipeline_id=workflow_id,
pipeline_name=workflow_name or 'Workflow',
message_content=response_content[:2000], # Limit length
session_id=session_id,
status='success',
level='info',
platform=platform,
user_id=user_id,
user_name=sender_name,
role='assistant',
runner_name='local-workflow',
)
except Exception as e:
ap.logger.error(f'Failed to record LLM response log: {e}')
@staticmethod
async def record_reply_log(
ap: app.Application,
query,
workflow_id: str,
workflow_name: str,
node_name: str,
reply_content: str,
bot_name: str = 'Workflow',
context_vars: dict | None = None,
):
"""Record reply message log (stores reply content directly)
Aligned with pipeline monitoring: record_query_response
"""
try:
session_id = WorkflowMonitoringHelper._get_session_id(query, context_vars)
platform = WorkflowMonitoringHelper._get_platform(query, context_vars)
sender_name = WorkflowMonitoringHelper._get_sender_name(query, context_vars)
# Get bot_id and user_id
bot_id = ''
user_id = None
if not isinstance(query, str):
bot_id = query.bot_uuid or ''
user_id = query.sender_id
elif context_vars:
bot_id = context_vars.get('_bot_id', '') or ''
user_id = context_vars.get('_user_id')
# Store reply content directly, no prefix
await ap.monitoring_service.record_message(
bot_id=bot_id,
bot_name=bot_name,
pipeline_id=workflow_id,
pipeline_name=workflow_name or 'Workflow',
message_content=reply_content[:2000], # Limit length
session_id=session_id,
status='success',
level='info',
platform=platform,
user_id=user_id,
user_name=sender_name,
role='assistant',
runner_name='local-workflow',
)
except Exception as e:
ap.logger.error(f'Failed to record reply log: {e}')
class LLMCallMonitor:
"""Context manager for monitoring LLM calls in workflow"""
def __init__(
self,
ap: app.Application,
query,
bot_id: str,
bot_name: str,
workflow_id: str,
workflow_name: str,
node_name: str,
model_name: str,
context_vars: dict | None = None,
):
self.ap = ap
self.query = query
self.bot_id = bot_id
self.bot_name = bot_name
self.workflow_id = workflow_id
self.workflow_name = workflow_name
self.node_name = node_name
self.model_name = model_name
self.context_vars = context_vars
self.start_time = None
self.input_tokens = 0
self.output_tokens = 0
async def __aenter__(self):
self.start_time = time.time()
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
duration_ms = int((time.time() - self.start_time) * 1000) if self.start_time else 0
if exc_type is not None:
await WorkflowMonitoringHelper.record_llm_call_log(
ap=self.ap,
query=self.query,
workflow_id=self.workflow_id,
workflow_name=self.workflow_name,
node_name=self.node_name,
model_name=self.model_name,
input_tokens=self.input_tokens,
output_tokens=self.output_tokens,
duration_ms=duration_ms,
status='error',
error_message=str(exc_val) if exc_val else None,
bot_name=self.bot_name,
context_vars=self.context_vars,
)
else:
await WorkflowMonitoringHelper.record_llm_call_log(
ap=self.ap,
query=self.query,
workflow_id=self.workflow_id,
workflow_name=self.workflow_name,
node_name=self.node_name,
model_name=self.model_name,
input_tokens=self.input_tokens,
output_tokens=self.output_tokens,
duration_ms=duration_ms,
status='success',
bot_name=self.bot_name,
context_vars=self.context_vars,
)
return False

View File

@@ -0,0 +1,164 @@
"""Workflow node base class and decorators"""
from __future__ import annotations
import abc
from typing import Any, Callable, Optional, TYPE_CHECKING
if TYPE_CHECKING:
from .entities import ExecutionContext
from ..core import app
class WorkflowNode(abc.ABC):
"""Base class for all workflow nodes.
Node metadata (inputs, outputs, config schema, label, icon, etc.) is
defined exclusively in YAML files under templates/metadata/nodes/.
Python subclasses only provide execution logic and runtime behaviour.
"""
# Set by @workflow_node decorator
type_name: str = ''
# Category is kept as a fallback for registry when YAML is missing
category: str = 'misc'
# Pipeline config reuse (referenced by registry merge logic)
config_schema_source: Optional[str] = None
config_stages: list[str] = []
def __init__(self, node_id: str, config: dict[str, Any], ap: Optional['app.Application'] = None):
"""Initialize node with ID and configuration"""
self.node_id = node_id
self.config = config
self.ap = ap
@abc.abstractmethod
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
"""Execute the node logic.
Args:
inputs: Input data from connected nodes
context: Execution context with workflow state
Returns:
Dictionary of output values
"""
pass
# ------------------------------------------------------------------
# Validation helpers — metadata is resolved from the registry at
# runtime so that YAML remains the single source of truth.
# ------------------------------------------------------------------
async def validate_inputs(self, inputs: dict[str, Any]) -> list[str]:
"""Validate input data against YAML port definitions.
Returns:
List of validation error messages (empty if valid)
"""
metadata = self._get_metadata()
if metadata is None:
return []
errors: list[str] = []
for port in metadata.get('inputs', []):
if port.get('required', True) and port.get('name') and port['name'] not in inputs:
errors.append(f"Missing required input: {port['name']}")
return errors
async def validate_config(self) -> list[str]:
"""Validate node configuration against YAML config schema.
Returns:
List of validation error messages (empty if valid)
"""
metadata = self._get_metadata()
if metadata is None:
return []
errors: list[str] = []
for cfg in metadata.get('config', []):
name = cfg.get('name', '')
if not name:
continue
required = cfg.get('required', False)
cfg_type = cfg.get('type', 'string')
if required and name not in self.config:
errors.append(f'Missing required config: {name}')
elif name in self.config:
value = self.config[name]
# Type validation
if cfg_type == 'integer' and not isinstance(value, int):
errors.append(f'Config {name} must be an integer')
elif cfg_type == 'number' and not isinstance(value, (int, float)):
errors.append(f'Config {name} must be a number')
elif cfg_type == 'boolean' and not isinstance(value, bool):
errors.append(f'Config {name} must be a boolean')
# Range validation
min_val = cfg.get('min_value')
max_val = cfg.get('max_value')
if min_val is not None and isinstance(value, (int, float)):
if value < min_val:
errors.append(f'Config {name} must be >= {min_val}')
if max_val is not None and isinstance(value, (int, float)):
if value > max_val:
errors.append(f'Config {name} must be <= {max_val}')
return errors
def get_config(self, key: str, default: Any = None) -> Any:
"""Get configuration value with default"""
return self.config.get(key, default)
def _get_metadata(self) -> Optional[dict[str, Any]]:
"""Retrieve YAML metadata for this node from the registry."""
from .registry import NodeTypeRegistry
registry = NodeTypeRegistry.instance()
return registry.get_metadata(self.type_name)
@classmethod
def to_schema(cls) -> dict[str, Any]:
"""Return a schema dict for this node type.
This is used by tests and tooling to inspect node capabilities.
"""
from .registry import NodeTypeRegistry
registry = NodeTypeRegistry.instance()
metadata = registry.get_metadata(cls.type_name)
if metadata:
return registry._metadata_to_schema(metadata)
# Fallback: build a minimal schema from class attributes
return {
'type': f'{cls.category}.{cls.type_name}' if cls.type_name else cls.type_name,
'category': cls.category,
'label': getattr(cls, 'name', cls.type_name),
'description': getattr(cls, 'description', ''),
'inputs': [],
'outputs': [],
'config_schema': [],
}
# ------------------------------------------------------------------
# Decorator for setting type_name attribute
# ------------------------------------------------------------------
def workflow_node(type_name: str) -> Callable[[type[WorkflowNode]], type[WorkflowNode]]:
"""Decorator to set the type_name attribute on a workflow node class.
Usage:
@workflow_node('llm_call')
class LLMCallNode(WorkflowNode):
...
The actual registration is now handled by the discovery engine.
"""
def decorator(cls: type[WorkflowNode]) -> type[WorkflowNode]:
cls.type_name = type_name
return cls
return decorator

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,263 @@
"""Call Pipeline Node - invoke an existing pipeline
Node metadata is loaded from: ../../templates/metadata/nodes/call_pipeline.yaml
"""
from __future__ import annotations
from typing import Any, Optional
import pydantic
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_event_logger
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
import langbot_plugin.api.entities.builtin.platform.events as platform_events
import langbot_plugin.api.entities.builtin.platform.message as platform_message
import langbot_plugin.api.entities.builtin.provider.session as provider_session
from langbot_plugin.api.entities.builtin.workflow.entities import ExecutionContext
from ..node import WorkflowNode, workflow_node
class _NoOpEventLogger(abstract_event_logger.AbstractEventLogger):
"""No-op event logger for workflow pipeline adapter."""
async def info(
self,
text: str,
images: Optional[list[platform_message.Image]] = None,
message_session_id: Optional[str] = None,
no_throw: bool = True,
):
pass
async def debug(
self,
text: str,
images: Optional[list[platform_message.Image]] = None,
message_session_id: Optional[str] = None,
no_throw: bool = True,
):
pass
async def warning(
self,
text: str,
images: Optional[list[platform_message.Image]] = None,
message_session_id: Optional[str] = None,
no_throw: bool = True,
):
pass
async def error(
self,
text: str,
images: Optional[list[platform_message.Image]] = None,
message_session_id: Optional[str] = None,
no_throw: bool = True,
):
pass
@workflow_node('call_pipeline')
class CallPipelineNode(WorkflowNode):
"""Call pipeline node - invoke an existing pipeline"""
category = 'action'
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
if not self.ap:
raise RuntimeError('Application instance not available — cannot call pipeline')
raw_query = inputs.get('query', '')
query_text = str(raw_query or inputs.get('input') or '')
pipeline_ref = str(self.get_config('pipeline_uuid', '') or '').strip()
if not pipeline_ref:
raise ValueError('No pipeline configured for call pipeline node')
pipeline_data = await self.ap.pipeline_service.get_pipeline(pipeline_ref)
if pipeline_data is None:
pipeline_data = await self.ap.pipeline_service.get_pipeline_by_name(pipeline_ref)
if pipeline_data is None:
raise ValueError(f'Pipeline not found: {pipeline_ref}')
pipeline_uuid = str(pipeline_data.get('uuid', '') or '')
if not pipeline_uuid:
raise ValueError(f'Pipeline UUID missing for: {pipeline_ref}')
runtime_pipeline = await self.ap.pipeline_mgr.get_pipeline_by_uuid(pipeline_uuid)
if runtime_pipeline is None:
raise ValueError(f'Runtime pipeline not loaded: {pipeline_uuid}')
adapter = _WorkflowPipelineCaptureAdapter(context=context)
adapter.bot_account_id = 'workflow-call-pipeline'
message_event = self._build_message_event(query_text, context)
message_chain = message_event.message_chain
launcher_type = (
provider_session.LauncherTypes.GROUP
if context.message_context and context.message_context.is_group
else provider_session.LauncherTypes.PERSON
)
launcher_id = context.session_id or context.execution_id
sender_id = (
context.message_context.sender_id
if context.message_context and context.message_context.sender_id
else context.user_id or f'workflow_{context.execution_id}'
)
query = pipeline_query.Query(
bot_uuid=context.bot_id,
query_id=-1,
launcher_type=launcher_type,
launcher_id=launcher_id,
sender_id=sender_id,
message_event=message_event,
message_chain=message_chain,
variables={
'_called_from_workflow': True,
'_workflow_execution_id': context.execution_id,
'_workflow_id': context.workflow_id,
**dict(context.variables or {}),
},
resp_messages=[],
resp_message_chain=[],
adapter=adapter,
pipeline_uuid=pipeline_uuid,
)
await runtime_pipeline.run(query)
response_text = adapter.get_last_text_response()
result = {
'pipeline_uuid': pipeline_uuid,
'pipeline_name': pipeline_data.get('name', ''),
'responses': adapter.responses,
'query_text': query_text,
}
return {'response': response_text, 'result': result}
def _build_message_event(
self,
query_text: str,
context: ExecutionContext,
) -> platform_events.MessageEvent:
message_chain_data = context.trigger_data.get('message_chain') or context.trigger_data.get('message', [])
if isinstance(message_chain_data, list) and message_chain_data:
message_chain = platform_message.MessageChain.model_validate(message_chain_data)
else:
message_chain = platform_message.MessageChain([platform_message.Plain(text=query_text)])
if context.message_context and context.message_context.is_group:
group = platform_entities.Group(
id=context.message_context.group_id or context.session_id or 'workflow_group',
name=context.message_context.raw_message.get('group_name', 'Workflow Group') if context.message_context.raw_message else 'Workflow Group',
permission=platform_entities.Permission.Member,
)
sender = platform_entities.GroupMember(
id=context.message_context.sender_id,
member_name=context.message_context.sender_name or 'Workflow User',
permission=platform_entities.Permission.Member,
group=group,
)
return platform_events.GroupMessage(
sender=sender,
message_chain=message_chain,
time=context.message_context.raw_message.get('time') if context.message_context.raw_message else None,
)
sender = platform_entities.Friend(
id=context.message_context.sender_id if context.message_context else context.user_id or 'workflow_user',
nickname=context.message_context.sender_name if context.message_context else 'Workflow User',
remark=context.message_context.sender_name if context.message_context else 'Workflow User',
)
return platform_events.FriendMessage(
sender=sender,
message_chain=message_chain,
time=context.message_context.raw_message.get('time')
if context.message_context and context.message_context.raw_message
else None,
)
class _WorkflowPipelineCaptureAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
"""Adapter to capture pipeline responses for workflow execution."""
class Config:
arbitrary_types_allowed = True
responses: list[dict[str, Any]] = []
context: Optional[ExecutionContext] = pydantic.Field(default=None, exclude=True)
def __init__(self, context: ExecutionContext):
super().__init__(config={}, logger=_NoOpEventLogger(), context=context)
self.responses = []
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
payload = {
'type': 'send',
'target_type': target_type,
'target_id': target_id,
'content': str(message),
'message_chain': message.model_dump(),
}
self.responses.append(payload)
return payload
async def reply_message(
self,
message_source: platform_events.MessageEvent,
message: platform_message.MessageChain,
quote_origin: bool = False,
):
payload = {
'type': 'reply',
'content': str(message),
'message_chain': message.model_dump(),
'quote_origin': quote_origin,
}
self.responses.append(payload)
return payload
async def reply_message_chunk(
self,
message_source: platform_events.MessageEvent,
bot_message: dict,
message: platform_message.MessageChain,
quote_origin: bool = False,
is_final: bool = False,
):
payload = {
'type': 'reply_chunk',
'content': str(message),
'message_chain': message.model_dump(),
'quote_origin': quote_origin,
'is_final': is_final,
}
self.responses.append(payload)
return payload
async def create_message_card(self, message_id, event: platform_events.MessageEvent) -> bool:
return False
def register_listener(self, event_type, callback):
return None
def unregister_listener(self, event_type, callback):
return None
async def run_async(self):
return None
async def is_stream_output_supported(self) -> bool:
return False
async def kill(self) -> bool:
return True
def get_last_text_response(self) -> str:
if not self.responses:
return ''
return str(self.responses[-1].get('content', '') or '')

View File

@@ -0,0 +1,85 @@
"""Call Workflow Node - invoke an existing workflow
Node metadata is loaded from: ../../templates/metadata/nodes/call_workflow.yaml
"""
from __future__ import annotations
from typing import Any
from langbot_plugin.api.entities.builtin.workflow.entities import ExecutionContext
from ..node import WorkflowNode, workflow_node
@workflow_node('call_workflow')
class CallWorkflowNode(WorkflowNode):
"""Call workflow node - invoke an existing workflow"""
category = 'action'
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
if not self.ap:
raise RuntimeError('Application instance not available — cannot call workflow')
# Get workflow reference from config
workflow_ref = str(self.get_config('workflow_uuid', '') or '').strip()
if not workflow_ref:
raise ValueError('No workflow configured for call workflow node')
# Get workflow definition from service
workflow_data = await self.ap.workflow_service.get_workflow(workflow_ref)
if workflow_data is None:
raise ValueError(f'Workflow not found: {workflow_ref}')
workflow_uuid = str(workflow_data.get('uuid', '') or '')
if not workflow_uuid:
raise ValueError(f'Workflow UUID missing for: {workflow_ref}')
# Build variables to pass to the called workflow
variables = dict(inputs.get('variables', {}) or {})
# Inherit current workflow variables if configured
if self.get_config('inherit_variables', True):
for key, value in (context.variables or {}).items():
if key not in variables:
variables[key] = value
# Add context markers for debugging
variables['_called_from_workflow'] = True
variables['_parent_workflow_id'] = context.workflow_id
variables['_parent_execution_id'] = context.execution_id
# Execute the workflow
execution_id = await self.ap.workflow_service.execute_workflow(
workflow_uuid=workflow_uuid,
trigger_type='workflow_call',
trigger_data={
'variables': variables,
'parent_execution_id': context.execution_id,
},
session_id=context.session_id,
user_id=context.user_id,
bot_id=context.bot_id,
)
# Get execution result
execution = await self.ap.workflow_service.get_execution(execution_id)
if execution is None:
raise ValueError(f'Execution result not found: {execution_id}')
# Build result
result = {
'workflow_uuid': workflow_uuid,
'workflow_name': workflow_data.get('name', ''),
'execution_id': execution_id,
'status': execution.get('status', 'unknown'),
'variables': execution.get('variables', {}),
'error': execution.get('error'),
}
return {
'result': result,
'status': execution.get('status', 'unknown'),
'error': execution.get('error'),
}

View File

@@ -0,0 +1,156 @@
"""Code Executor Node - run Python or JavaScript code
Node metadata is loaded from: ../../templates/metadata/nodes/code_executor.yaml
"""
from __future__ import annotations
import ast
import io
import logging
import sys
import threading
from typing import Any
from langbot_plugin.api.entities.builtin.workflow.entities import ExecutionContext
from ..node import WorkflowNode, workflow_node
logger = logging.getLogger(__name__)
# 危险的内置函数和模块黑名单
_DANGEROUS_BUILTINS = {
'__import__', 'eval', 'exec', 'compile', 'open', 'file',
'input', 'exit', 'quit', 'globals', 'locals', 'vars',
'dir', 'help', 'breakpoint',
}
# 允许的安全内置函数
_SAFE_BUILTINS = {
'abs': abs, 'all': all, 'any': any, 'bin': bin, 'bool': bool,
'bytearray': bytearray, 'bytes': bytes, 'callable': callable,
'chr': chr, 'complex': complex, 'dict': dict, 'divmod': divmod,
'enumerate': enumerate, 'filter': filter, 'float': float,
'format': format, 'frozenset': frozenset, 'hash': hash,
'hex': hex, 'int': int, 'isinstance': isinstance, 'issubclass': issubclass,
'iter': iter, 'len': len, 'list': list, 'map': map, 'max': max,
'min': min, 'next': next, 'object': object, 'oct': oct, 'ord': ord,
'pow': pow, 'print': print, 'range': range, 'repr': repr,
'reversed': reversed, 'round': round, 'set': set, 'slice': slice,
'sorted': sorted, 'str': str, 'sum': sum, 'tuple': tuple,
'type': type, 'zip': zip,
}
def _check_code_safety(code: str) -> list[str]:
"""检查代码中是否包含危险操作"""
warnings = []
try:
tree = ast.parse(code)
for node in ast.walk(tree):
# 检查 import 语句
if isinstance(node, (ast.Import, ast.ImportFrom)):
warnings.append('Import statements are not allowed')
# 检查危险函数调用
if isinstance(node, ast.Call):
if isinstance(node.func, ast.Name) and node.func.id in _DANGEROUS_BUILTINS:
warnings.append(f'Dangerous function call: {node.func.id}')
# 检查 __import__ 通过 getattr 调用
if isinstance(node.func, ast.Attribute):
if node.func.attr in ('__import__', 'eval', 'exec', 'open', 'file'):
warnings.append(f'Dangerous attribute access: {node.func.attr}')
except SyntaxError as e:
warnings.append(f'Syntax error in code: {e}')
return warnings
class _ExecutionTimeoutError(Exception):
"""执行超时错误"""
pass
def _run_with_timeout(func, timeout: float = 10.0):
"""带超时限制的函数执行"""
result = [None]
error = [None]
def _target():
try:
result[0] = func()
except Exception as e:
error[0] = e
thread = threading.Thread(target=_target)
thread.daemon = True
thread.start()
thread.join(timeout)
if thread.is_alive():
raise _ExecutionTimeoutError(f'Code execution timed out after {timeout} seconds')
if error[0]:
raise error[0]
return result[0]
@workflow_node('code_executor')
class CodeExecutorNode(WorkflowNode):
"""Code executor node - run Python or JavaScript code"""
category = 'process'
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
code = self.get_config('code', '')
language = self.get_config('language', 'python')
timeout = self.get_config('timeout', 10)
# 限制最大超时时间
timeout = min(max(timeout, 1), 30)
if not code:
return {'output': None, 'console': '', 'error': 'No code provided'}
if language == 'python':
return await self._execute_python(code, inputs, context, timeout)
else:
return await self._execute_javascript(code, inputs, context)
async def _execute_python(self, code: str, inputs: dict[str, Any], context: ExecutionContext, timeout: float) -> dict[str, Any]:
# 安全检查
warnings = _check_code_safety(code)
if warnings:
logger.warning('Code safety warnings: %s', warnings)
return {'output': None, 'console': '', 'error': '; '.join(warnings)}
stdout_capture = io.StringIO()
old_stdout = sys.stdout
def _exec_code():
nonlocal stdout_capture
sys.stdout = stdout_capture
try:
# 使用更安全的执行方式
compiled = compile(code, '<workflow>', 'exec')
safe_globals = {
'__builtins__': _SAFE_BUILTINS,
'__name__': '__workflow_sandbox__',
}
local_vars = {'inputs': inputs, 'output': None}
exec(compiled, safe_globals, local_vars)
return local_vars.get('output')
finally:
sys.stdout = old_stdout
try:
output = _run_with_timeout(_exec_code, timeout)
console_output = stdout_capture.getvalue()
return {'output': output, 'console': console_output, 'error': None}
except _ExecutionTimeoutError as e:
logger.error('Code execution timeout: %s', e)
return {'output': None, 'console': stdout_capture.getvalue(), 'error': str(e)}
except Exception as e:
logger.error('Code execution error: %s', e)
return {'output': None, 'console': stdout_capture.getvalue(), 'error': f'{type(e).__name__}: {e}'}
async def _execute_javascript(self, code: str, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
return {'output': None, 'console': '', 'error': 'JavaScript execution is not implemented'}

View File

@@ -0,0 +1,125 @@
"""Condition Node - branch based on condition
Node metadata is loaded from: ../../templates/metadata/nodes/condition.yaml
"""
from __future__ import annotations
import logging
import re
import signal
from typing import Any
from langbot_plugin.api.entities.builtin.workflow.entities import ExecutionContext
from ..node import WorkflowNode, workflow_node
from ..safe_eval import safe_eval_with_vars
logger = logging.getLogger(__name__)
# 正则表达式超时限制(秒)
_REGEX_TIMEOUT = 2
class _RegexTimeoutError(Exception):
"""正则表达式超时错误"""
pass
def _handle_timeout(signum, frame):
"""超时信号处理"""
raise _RegexTimeoutError('Regex match timed out')
def _safe_regex_match(pattern: str, text: str) -> tuple[bool, str]:
"""安全地执行正则表达式匹配,带有超时限制"""
# 设置超时信号
old_handler = signal.signal(signal.SIGALRM, _handle_timeout)
signal.setitimer(signal.ITIMER_REAL, _REGEX_TIMEOUT)
try:
result = bool(re.match(pattern, str(text)))
return result, ''
except _RegexTimeoutError:
logger.warning('Regex match timed out for pattern: %s', pattern[:50])
return False, 'Regex match timed out'
except re.error as e:
logger.warning('Invalid regex pattern: %s', e)
return False, f'Invalid regex: {e}'
finally:
signal.setitimer(signal.ITIMER_REAL, 0)
signal.signal(signal.SIGALRM, old_handler)
@workflow_node('condition')
class ConditionNode(WorkflowNode):
"""Condition node - branch based on condition"""
category = 'control'
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
condition_type = self.get_config('condition_type', 'expression')
input_data = inputs.get('input')
result = False
if condition_type == 'expression':
expression = self.get_config('expression', 'false')
result = await self._evaluate_expression(expression, input_data, context)
elif condition_type == 'comparison':
result = await self._evaluate_comparison(input_data, context)
elif condition_type == 'contains':
left = self.get_config('left_value', '')
right = self.get_config('right_value', '')
result = right in left
elif condition_type == 'empty':
result = not bool(input_data)
elif condition_type == 'regex':
left = self.get_config('left_value', '')
pattern = self.get_config('right_value', '')
result, error = _safe_regex_match(pattern, left)
if error:
return {'true': None, 'false': input_data, 'error': error}
if result:
return {'true': input_data, 'false': None}
else:
return {'true': None, 'false': input_data}
async def _evaluate_expression(self, expression: str, data: Any, context: ExecutionContext) -> bool:
try:
local_vars = {'input': data, 'data': data, 'variables': context.variables}
return bool(safe_eval_with_vars(expression, local_vars))
except Exception as e:
logger.warning('Expression evaluation error: %s', e)
return False
async def _evaluate_comparison(self, data: Any, context: ExecutionContext) -> bool:
left = self.get_config('left_value', '')
right = self.get_config('right_value', '')
operator = self.get_config('operator', '==')
try:
left_num = float(left)
right_num = float(right)
if operator == '==':
return left_num == right_num
elif operator == '!=':
return left_num != right_num
elif operator == '>':
return left_num > right_num
elif operator == '<':
return left_num < right_num
elif operator == '>=':
return left_num >= right_num
elif operator == '<=':
return left_num <= right_num
except ValueError:
if operator == '==':
return left == right
elif operator == '!=':
return left != right
elif operator in ('>', '<', '>=', '<='):
return False
return False

View File

@@ -0,0 +1,39 @@
"""Coze Bot Node - call Coze API bot
Node metadata is loaded from: ../../templates/metadata/nodes/coze_bot.yaml
"""
from __future__ import annotations
from typing import Any
from langbot_plugin.api.entities.builtin.workflow.entities import ExecutionContext
from ..node import WorkflowNode, workflow_node
@workflow_node('coze_bot')
class CozeBotNode(WorkflowNode):
"""Coze bot node - call Coze API bot"""
category = 'integration'
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
api_key = self.get_config('api_key', '')
bot_id = self.get_config('bot_id', '')
api_base = self.get_config('api_base', 'https://api.coze.cn')
query = inputs.get('query', '')
conversation_id = inputs.get('conversation_id')
# Safe API key truncation
masked_key = f'{api_key[:4]}...{api_key[-4:]}' if len(api_key) > 8 else '***' if api_key else ''
return {
'answer': '',
'conversation_id': conversation_id,
'success': False,
'_debug': {
'api_key': masked_key,
'bot_id': bot_id,
'api_base': api_base,
'query': query,
},
}

View File

@@ -0,0 +1,26 @@
"""Cron Trigger Node - triggers workflow on schedule
Node metadata is loaded from: ../../templates/metadata/nodes/cron_trigger.yaml
"""
from __future__ import annotations
from typing import Any
from langbot_plugin.api.entities.builtin.workflow.entities import ExecutionContext
from ..node import WorkflowNode, workflow_node
@workflow_node('cron_trigger')
class CronTriggerNode(WorkflowNode):
"""Cron trigger node - triggers workflow on schedule"""
category = 'trigger'
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
from datetime import datetime
return {
'timestamp': datetime.now().isoformat(),
'schedule': self.get_config('cron', ''),
'context': context.trigger_data,
}

View File

@@ -0,0 +1,68 @@
"""Data Transform Node - transform data using templates or JSONPath
Node metadata is loaded from: ../../templates/metadata/nodes/data_transform.yaml
"""
from __future__ import annotations
from typing import Any
from langbot_plugin.api.entities.builtin.workflow.entities import ExecutionContext
from ..node import WorkflowNode, workflow_node
from ..safe_eval import safe_eval_with_vars
@workflow_node('data_transform')
class DataTransformNode(WorkflowNode):
"""Data transform node - transform data using templates or JSONPath"""
category = 'process'
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
data = inputs.get('data')
transform_type = self.get_config('transform_type', 'template')
if transform_type == 'template':
template = self.get_config('template', '')
result = self._apply_template(template, data, context)
elif transform_type == 'jsonpath':
expression = self.get_config('expression', '$')
result = self._apply_jsonpath(expression, data)
elif transform_type == 'expression':
expression = self.get_config('expression', '')
result = self._evaluate_expression(expression, data, context)
else:
result = data
return {'result': result}
def _apply_template(self, template: str, data: Any, context: ExecutionContext) -> str:
result = template
if isinstance(data, dict):
for key, value in data.items():
result = result.replace(f'{{{{data.{key}}}}}', str(value))
for key, value in context.variables.items():
result = result.replace(f'{{{{variables.{key}}}}}', str(value))
return result
def _apply_jsonpath(self, expression: str, data: Any) -> Any:
if expression == '$':
return data
if expression.startswith('$.'):
parts = expression[2:].split('.')
result = data
for part in parts:
if isinstance(result, dict):
result = result.get(part)
elif isinstance(result, list) and part.isdigit():
result = result[int(part)]
else:
return None
return result
return data
def _evaluate_expression(self, expression: str, data: Any, context: ExecutionContext) -> Any:
local_vars = {'data': data, 'variables': context.variables}
try:
return safe_eval_with_vars(expression, local_vars)
except Exception:
return None

View File

@@ -0,0 +1,38 @@
"""Database Query Node - execute database queries
Node metadata is loaded from: ../../templates/metadata/nodes/database_query.yaml
"""
from __future__ import annotations
from typing import Any
from langbot_plugin.api.entities.builtin.workflow.entities import ExecutionContext
from ..node import WorkflowNode, workflow_node
@workflow_node('database_query')
class DatabaseQueryNode(WorkflowNode):
"""Database query node - execute database queries"""
category = 'integration'
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
connection_type = self.get_config('connection_type', 'postgresql')
query = self.get_config('query', '')
query_type = self.get_config('query_type', 'select')
timeout = self.get_config('timeout', 30)
parameters = inputs.get('parameters', {})
return {
'results': [],
'row_count': 0,
'success': False,
'_debug': {
'connection_type': connection_type,
'query': query,
'query_type': query_type,
'timeout': timeout,
'parameters': parameters,
},
}

View File

@@ -0,0 +1,37 @@
"""Dify Knowledge Query Node - query Dify knowledge base
Node metadata is loaded from: ../../templates/metadata/nodes/dify_knowledge_query.yaml
"""
from __future__ import annotations
from typing import Any
from langbot_plugin.api.entities.builtin.workflow.entities import ExecutionContext
from ..node import WorkflowNode, workflow_node
@workflow_node('dify_knowledge_query')
class DifyKnowledgeQueryNode(WorkflowNode):
"""Dify knowledge base query node - query Dify knowledge base"""
category = 'integration'
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
base_url = self.get_config('base_url', 'https://api.dify.ai/v1')
api_key = self.get_config('api_key', '')
dataset_id = self.get_config('dataset_id', '')
query = inputs.get('query', '')
# Safe API key truncation
masked_key = f'{api_key[:4]}...{api_key[-4:]}' if len(api_key) > 8 else '***' if api_key else ''
return {
'results': [],
'success': False,
'_debug': {
'base_url': base_url,
'api_key': masked_key,
'dataset_id': dataset_id,
'query': query,
},
}

View File

@@ -0,0 +1,39 @@
"""Dify Workflow Node - call Dify service API
Node metadata is loaded from: ../../templates/metadata/nodes/dify_workflow.yaml
"""
from __future__ import annotations
from typing import Any
from langbot_plugin.api.entities.builtin.workflow.entities import ExecutionContext
from ..node import WorkflowNode, workflow_node
@workflow_node('dify_workflow')
class DifyWorkflowNode(WorkflowNode):
"""Dify workflow node - call Dify service API"""
category = 'integration'
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
base_url = self.get_config('base_url', 'https://api.dify.ai/v1')
api_key = self.get_config('api_key', '')
app_type = self.get_config('app_type', 'chat')
query = inputs.get('query', '')
conversation_id = inputs.get('conversation_id')
# Safe API key truncation
masked_key = f'{api_key[:4]}...{api_key[-4:]}' if len(api_key) > 8 else '***' if api_key else ''
return {
'answer': '',
'conversation_id': conversation_id,
'success': False,
'_debug': {
'base_url': base_url,
'api_key': masked_key,
'app_type': app_type,
'query': query,
},
}

View File

@@ -0,0 +1,33 @@
"""End Node - marks the end of workflow execution
Node metadata is loaded from: ../../templates/metadata/nodes/end.yaml
"""
from __future__ import annotations
from typing import Any
from langbot_plugin.api.entities.builtin.workflow.entities import ExecutionContext
from ..node import WorkflowNode, workflow_node
@workflow_node('end')
class EndNode(WorkflowNode):
"""End node - marks the end of workflow execution"""
category = 'control'
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
result = inputs.get('result')
output_format = self.get_config('output_format', 'passthrough')
if output_format == 'text':
return {'output': str(result)}
elif output_format == 'json':
import json
try:
return {'output': json.dumps(result, ensure_ascii=False)}
except Exception:
return {'output': str(result)}
else:
return {'output': result}

View File

@@ -0,0 +1,28 @@
"""Event Trigger Node - triggers workflow on system events
Node metadata is loaded from: ../../templates/metadata/nodes/event_trigger.yaml
"""
from __future__ import annotations
from datetime import datetime
from typing import Any
from langbot_plugin.api.entities.builtin.workflow.entities import ExecutionContext
from ..node import WorkflowNode, workflow_node
@workflow_node('event_trigger')
class EventTriggerNode(WorkflowNode):
"""Event trigger node - triggers workflow on system events"""
category = 'trigger'
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
# Safe access to trigger_data which may be None
trigger_data = context.trigger_data or {}
return {
'event_type': trigger_data.get('event_type', ''),
'event_data': trigger_data.get('event_data', {}),
'timestamp': trigger_data.get('timestamp', datetime.now().isoformat()),
}

View File

@@ -0,0 +1,152 @@
"""HTTP Request Node - make HTTP API calls
Node metadata is loaded from: ../../templates/metadata/nodes/http_request.yaml
"""
from __future__ import annotations
import ipaddress
import logging
from typing import Any
from urllib.parse import urlparse
from langbot_plugin.api.entities.builtin.workflow.entities import ExecutionContext
from ..node import WorkflowNode, workflow_node
logger = logging.getLogger(__name__)
# 内网地址黑名单
_PRIVATE_NETWORKS = [
ipaddress.ip_network('10.0.0.0/8'),
ipaddress.ip_network('172.16.0.0/12'),
ipaddress.ip_network('192.168.0.0/16'),
ipaddress.ip_network('127.0.0.0/8'),
ipaddress.ip_network('169.254.0.0/16'),
ipaddress.ip_network('0.0.0.0/8'),
ipaddress.ip_network('::1/128'),
ipaddress.ip_network('fc00::/7'),
ipaddress.ip_network('fe80::/10'),
]
# 危险协议
_DANGEROUS_SCHEMES = {'file', 'gopher', 'dict', 'ftp', 'telnet'}
def _is_safe_url(url: str) -> tuple[bool, str]:
"""检查 URL 是否安全(非内网地址)"""
try:
parsed = urlparse(url)
except Exception as e:
return False, f'Invalid URL: {e}'
# 检查协议
scheme = parsed.scheme.lower()
if scheme in _DANGEROUS_SCHEMES:
return False, f'Dangerous scheme: {scheme}'
if scheme not in ('http', 'https'):
return False, f'Unsupported scheme: {scheme}'
# 检查主机名
hostname = parsed.hostname
if not hostname:
return False, 'Missing hostname'
# 检查是否是危险主机名
dangerous_hosts = {'localhost', '0.0.0.0', '127.0.0.1', '::1'}
if hostname.lower() in dangerous_hosts:
return False, f'Dangerous hostname: {hostname}'
# 解析 IP 地址并检查是否在私有网络
try:
ip = ipaddress.ip_address(hostname)
for network in _PRIVATE_NETWORKS:
if ip in network:
return False, f'Private network address: {ip}'
except ValueError:
# 不是 IP 地址,尝试 DNS 解析检查
# 这里可以添加 DNS 解析检查,但为了避免复杂性,暂时跳过
pass
return True, ''
@workflow_node('http_request')
class HTTPRequestNode(WorkflowNode):
"""HTTP request node - make HTTP API calls"""
category = 'action'
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
import aiohttp
url = self.get_config('url', '')
method = self.get_config('method', 'GET').upper()
timeout = self.get_config('timeout', 30)
content_type = self.get_config('content_type', 'application/json')
allow_redirects = self.get_config('allow_redirects', False) # 默认禁用重定向
# 限制超时时间
timeout = min(max(timeout, 1), 120)
if not url:
return {'response': None, 'status_code': 0, 'headers': {}, 'error': 'No URL provided'}
# 安全检查 URL
is_safe, error_msg = _is_safe_url(url)
if not is_safe:
logger.warning('Unsafe URL blocked: %s - %s', url, error_msg)
return {'response': None, 'status_code': 0, 'headers': {}, 'error': f'Unsafe URL: {error_msg}'}
# 验证 HTTP 方法
allowed_methods = {'GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'}
if method not in allowed_methods:
return {'response': None, 'status_code': 0, 'headers': {}, 'error': f'Invalid method: {method}'}
# 创建 headers 副本,避免修改输入
headers = dict(inputs.get('headers', {}))
headers['Content-Type'] = content_type
auth_type = self.get_config('auth_type', 'none')
auth_config = self.get_config('auth_config', {})
if auth_type == 'bearer':
headers['Authorization'] = f'Bearer {auth_config.get("token", "")}'
elif auth_type == 'api_key':
header_name = auth_config.get('header', 'X-API-Key')
headers[header_name] = auth_config.get('key', '')
body = inputs.get('body')
logger.info('HTTP %s %s (timeout=%s)', method, url, timeout)
try:
async with aiohttp.ClientSession() as session:
async with session.request(
method=method,
url=url,
json=body if content_type == 'application/json' else None,
data=body if content_type != 'application/json' else None,
headers=headers,
timeout=aiohttp.ClientTimeout(total=timeout),
allow_redirects=allow_redirects,
) as response:
try:
response_data = await response.json()
except Exception:
response_data = await response.text()
logger.info('HTTP %s %s -> %d', method, url, response.status)
return {
'response': response_data,
'status_code': response.status,
'headers': dict(response.headers),
'error': None,
}
except aiohttp.ClientError as e:
logger.error('HTTP request failed: %s', e)
return {'response': None, 'status_code': 0, 'headers': {}, 'error': f'HTTP error: {e}'}
except Exception as e:
logger.error('HTTP request unexpected error: %s', e)
return {'response': None, 'status_code': 0, 'headers': {}, 'error': f'Unexpected error: {e}'}

View File

@@ -0,0 +1,32 @@
"""Iterator Node - Dify-style iterator for processing array items"""
from __future__ import annotations
from typing import Any
from langbot_plugin.api.entities.builtin.workflow.entities import ExecutionContext
from ..node import WorkflowNode, workflow_node
@workflow_node('iterator')
class IteratorNode(WorkflowNode):
"""Iterator node - iterate over array items one by one"""
category = 'control'
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
items = inputs.get('items', [])
if not isinstance(items, list):
items = [items] if items else []
max_iterations = self.get_config('max_iterations', 1000)
items = items[:max_iterations]
return {
'item': items[0] if items else None,
'index': 0,
'is_first': True,
'is_last': len(items) <= 1,
'results': [],
'completed': len(items) == 0,
'_items': items,
}

View File

@@ -0,0 +1,21 @@
"""Knowledge Retrieval Node - search in knowledge base
Node metadata is loaded from: ../../templates/metadata/nodes/knowledge_retrieval.yaml
"""
from __future__ import annotations
from typing import Any
from langbot_plugin.api.entities.builtin.workflow.entities import ExecutionContext
from ..node import WorkflowNode, workflow_node
@workflow_node('knowledge_retrieval')
class KnowledgeRetrievalNode(WorkflowNode):
"""Knowledge retrieval node - search in knowledge base"""
category = 'process'
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
query = inputs.get('query', '')
return {'documents': [], 'citations': [], 'context': f'[Knowledge base search for: {query}]'}

View File

@@ -0,0 +1,37 @@
"""Langflow Flow Node - call Langflow API
Node metadata is loaded from: ../../templates/metadata/nodes/langflow_flow.yaml
"""
from __future__ import annotations
from typing import Any
from langbot_plugin.api.entities.builtin.workflow.entities import ExecutionContext
from ..node import WorkflowNode, workflow_node
@workflow_node('langflow_flow')
class LangflowFlowNode(WorkflowNode):
"""Langflow flow node - call Langflow API"""
category = 'integration'
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
base_url = self.get_config('base_url', 'http://localhost:7860')
api_key = self.get_config('api_key', '')
flow_id = self.get_config('flow_id', '')
input_value = inputs.get('input_value', '')
# Safe API key truncation
masked_key = f'{api_key[:4]}...{api_key[-4:]}' if len(api_key) > 8 else '***' if api_key else ''
return {
'result': None,
'success': False,
'_debug': {
'base_url': base_url,
'api_key': masked_key,
'flow_id': flow_id,
'input_value': input_value,
},
}

View File

@@ -0,0 +1,829 @@
"""LLM Call Node - invoke large language model with Agent capabilities.
Supports:
- Primary model with fallback models
- Knowledge base retrieval with reranking
- Max round context control
- Streaming output
"""
from __future__ import annotations
import json
import logging
import re
import time
from typing import Any, AsyncGenerator
import langbot_plugin.api.entities.builtin.provider.message as provider_message
import langbot_plugin.api.entities.builtin.rag.context as rag_context
from langbot_plugin.api.entities.builtin.workflow.entities import ExecutionContext
from ..node import WorkflowNode, workflow_node
from .. import monitoring_helper
logger = logging.getLogger(__name__)
# Pre-compiled regex patterns for CoT content removal (performance optimization)
_THINK_PATTERNS = [
re.compile(r'<think>.*?</think>', re.DOTALL | re.IGNORECASE),
re.compile(r'<thought>.*?</thought>', re.DOTALL | re.IGNORECASE),
re.compile(r'<reasoning>.*?</reasoning>', re.DOTALL | re.IGNORECASE),
re.compile(r'<\u601d\u8003>.*?</\u601d\u8003>', re.DOTALL | re.IGNORECASE),
re.compile(r'<\u63a8\u7406>.*?</\u63a8\u7406>', re.DOTALL | re.IGNORECASE),
]
# Template variable regex
_TEMPLATE_VAR_RE = re.compile(r'\{\{([^}]+)\}\}')
@workflow_node('llm_call')
class LLMCallNode(WorkflowNode):
"""LLM call node - invoke large language model"""
category = 'process'
def _resolve_template(self, template: str, inputs: dict[str, Any], context: ExecutionContext) -> str:
"""Resolve {{variable}} placeholders in a template string."""
if not template:
return ''
unresolved_vars = []
def replacer(match: re.Match) -> str:
expr = match.group(1).strip()
# Try inputs first
if expr in inputs:
return str(inputs[expr])
# Try context variables
if expr.startswith('variables.'):
var_name = expr[len('variables.'):]
return str(context.variables.get(var_name, ''))
# Try message context
if expr.startswith('message.') and context.message_context:
attr = expr[len('message.'):]
return str(getattr(context.message_context, attr, ''))
unresolved_vars.append(expr)
return match.group(0) # leave unresolved
result = _TEMPLATE_VAR_RE.sub(replacer, template)
# Log warning for unresolved variables
if unresolved_vars:
logger.warning(
f'LLM call node {self.node_id}: unresolved template variables: {unresolved_vars}'
)
return result
def _remove_think_content(self, text: str) -> str:
"""Remove CoT (Chain of Thought) thinking content from response."""
if not text:
return text
result = text
for pattern in _THINK_PATTERNS:
result = pattern.sub('', result)
return result.strip()
def _apply_content_filter(self, text: str) -> tuple[str, bool, str]:
"""Apply content safety filter to text.
Returns:
(filtered_text, is_blocked, user_notice)
"""
if not text or not self.ap:
return text, False, ''
# Check if content filter is enabled
safety_config = getattr(self.ap, 'pipeline_cfg', None)
if not safety_config:
return text, False, ''
# Check sensitive words
sensitive_words = []
try:
if hasattr(self.ap, 'sensitive_meta') and hasattr(self.ap.sensitive_meta, 'data'):
sensitive_words = self.ap.sensitive_meta.data.get('words', [])
except Exception as e:
logger.warning("Failed to load sensitive words from sensitive_meta: %s", e)
sensitive_words = []
if not sensitive_words:
return text, False, ''
found = False
filtered_text = text
for word in sensitive_words:
try:
matches = re.findall(word, filtered_text, re.IGNORECASE)
if matches:
found = True
mask_word = ''
mask = '*'
try:
if hasattr(self.ap, 'sensitive_meta') and hasattr(self.ap.sensitive_meta, 'data'):
mask_word = self.ap.sensitive_meta.data.get('mask_word', '')
mask = self.ap.sensitive_meta.data.get('mask', '*')
except Exception as e:
# Keep default mask settings when sensitive metadata is unavailable or malformed.
logger.debug(
f'LLM call node {self.node_id}: failed to read sensitive mask config, using defaults: {e}'
)
for m in matches:
if mask_word:
filtered_text = filtered_text.replace(m, mask_word)
else:
filtered_text = filtered_text.replace(m, mask * len(m))
except re.error:
# Invalid regex pattern, skip
continue
if found:
return filtered_text, False, '消息中存在不合适的内容, 请修改'
return text, False, ''
# RAG combined prompt template (same as localagent.py)
RAG_COMBINED_PROMPT_TEMPLATE = """
The following are relevant context entries retrieved from the knowledge base.
Please use them to answer the user's message.
Respond in the same language as the user's input.
<context>
{rag_context}
</context>
<user_message>
{user_message}
</user_message>
"""
def _build_system_prompt_with_format(self, base_prompt: str, output_format: str, json_schema: str) -> str:
"""Build system prompt with output format instructions."""
prompt = base_prompt
if output_format == 'json':
prompt += '\n\nPlease respond in valid JSON format.'
if json_schema:
prompt += f'\nFollow this JSON schema:\n{json_schema}'
elif output_format == 'markdown':
prompt += '\n\nPlease respond in Markdown format.'
return prompt
def _build_messages_from_prompt_array(
self,
prompt_array: list[dict],
inputs: dict[str, Any],
context: ExecutionContext,
output_format: str,
json_schema: str,
) -> list[provider_message.Message]:
"""Build messages list from prompt array (same format as pipeline).
Each item in prompt_array is {role: str, content: str}.
Resolves template variables in content.
"""
messages: list[provider_message.Message] = []
for item in prompt_array:
role = item.get('role', 'user')
content = item.get('content', '')
# Resolve template variables in content
resolved_content = self._resolve_template(content, inputs, context)
# Apply format instructions to system prompt
if role == 'system':
resolved_content = self._build_system_prompt_with_format(
resolved_content, output_format, json_schema
)
messages.append(provider_message.Message(role=role, content=resolved_content))
return messages
async def _get_model_candidates(self, model_uuid: str, fallback_models: list) -> list:
"""Build ordered list of models to try: primary model + fallback models."""
candidates = []
# Primary model
if model_uuid:
try:
primary = await self.ap.model_mgr.get_model_by_uuid(model_uuid)
candidates.append(primary)
except ValueError:
logger.warning(f'[LLM:{self.node_id}] Primary model {model_uuid} not found')
# Fallback models
for fb_uuid in fallback_models:
try:
fb_model = await self.ap.model_mgr.get_model_by_uuid(fb_uuid)
candidates.append(fb_model)
except ValueError:
logger.warning(f'[LLM:{self.node_id}] Fallback model {fb_uuid} not found, skipping')
return candidates
async def _invoke_with_fallback(
self,
candidates: list,
messages: list,
funcs: list | None,
extra_args: dict,
) -> tuple[Any, Any]:
"""Try non-streaming invocation with sequential fallback. Returns (message, model_used)."""
last_error = None
for model in candidates:
try:
msg = await model.provider.invoke_llm(
query=None,
model=model,
messages=messages,
funcs=funcs if model.model_entity.abilities.__contains__('func_call') else [],
extra_args=extra_args,
)
return msg, model
except Exception as e:
last_error = e
logger.warning(f'[LLM:{self.node_id}] Model {model.model_entity.name} failed: {e}, trying next...')
raise last_error or RuntimeError('No model candidates available')
async def _retrieve_knowledge(
self,
user_message_text: str,
knowledge_bases: list[str],
rerank_model_uuid: str,
rerank_top_k: int,
) -> str:
"""Retrieve from knowledge bases and optionally rerank results.
Returns the enhanced user message text with RAG context, or original text if no results.
"""
if not knowledge_bases or not user_message_text:
return user_message_text
all_results: list[rag_context.RetrievalResultEntry] = []
# Retrieve from each knowledge base
for kb_uuid in knowledge_bases:
try:
kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(kb_uuid)
if not kb:
logger.warning(f'[LLM:{self.node_id}] Knowledge base {kb_uuid} not found, skipping')
continue
result = await kb.retrieve(user_message_text, settings={})
if result:
all_results.extend(result)
except Exception as e:
logger.warning(f'[LLM:{self.node_id}] Failed to retrieve from KB {kb_uuid}: {e}')
# Rerank step: re-score results using a rerank model if configured
if all_results and rerank_model_uuid:
try:
rerank_model = await self.ap.model_mgr.get_rerank_model_by_uuid(rerank_model_uuid)
doc_texts = []
for entry in all_results:
text = ' '.join(c.text for c in entry.content if c.type == 'text' and c.text)
doc_texts.append(text)
doc_texts_capped = doc_texts[:64] # Cap for reranker input
scores = await rerank_model.provider.invoke_rerank(
model=rerank_model,
query=user_message_text,
documents=doc_texts_capped,
)
scored = sorted(scores, key=lambda x: x.get('relevance_score', 0), reverse=True)
top_indices = [s['index'] for s in scored[:rerank_top_k] if s['index'] < len(all_results)]
all_results = [all_results[i] for i in top_indices]
logger.info(
f'[LLM:{self.node_id}] Rerank complete: {len(doc_texts)} docs -> top {len(all_results)} kept (top_k={rerank_top_k})'
)
except ValueError:
logger.warning(f'[LLM:{self.node_id}] Rerank model {rerank_model_uuid} not found, skipping rerank')
except Exception as e:
logger.warning(f'[LLM:{self.node_id}] Rerank failed, using original order: {e}')
# Build RAG context text
if all_results:
texts = []
idx = 1
for entry in all_results:
for content in entry.content:
if content.type == 'text' and content.text is not None:
texts.append(f'[{idx}] {content.text}')
idx += 1
rag_context_text = '\n\n'.join(texts)
return self.RAG_COMBINED_PROMPT_TEMPLATE.format(
rag_context=rag_context_text,
user_message=user_message_text,
)
return user_message_text
def _build_messages_with_history(
self,
system_prompt: str,
user_message_text: str,
context: ExecutionContext,
max_round: int,
) -> list[provider_message.Message]:
"""Build messages list with conversation history up to max_round."""
messages: list[provider_message.Message] = []
# Add system prompt
if system_prompt:
messages.append(provider_message.Message(role='system', content=system_prompt))
# Get conversation history from context
conversation_history = context.variables.get('_conversation_history', [])
# Apply max_round limit (each round = 1 user + 1 assistant message)
if max_round > 0 and conversation_history:
# Keep only the last max_round * 2 messages (user + assistant pairs)
max_messages = max_round * 2
if len(conversation_history) > max_messages:
conversation_history = conversation_history[-max_messages:]
# Add conversation history
for msg in conversation_history:
if isinstance(msg, dict):
role = msg.get('role', 'user')
content = msg.get('content', '')
messages.append(provider_message.Message(role=role, content=content))
elif hasattr(msg, 'role') and hasattr(msg, 'content'):
messages.append(provider_message.Message(role=msg.role, content=msg.content))
# Add current user message
messages.append(provider_message.Message(role='user', content=user_message_text))
return messages
def _save_to_conversation_history(
self,
context: ExecutionContext,
user_message_text: str,
response_text: str,
max_round: int,
) -> None:
"""Save the exchange to conversation history."""
if max_round <= 0:
return
history = context.variables.get('_conversation_history', [])
history.append({'role': 'user', 'content': user_message_text})
history.append({'role': 'assistant', 'content': response_text})
# Enforce max_round limit
max_messages = max_round * 2
if len(history) > max_messages:
history = history[-max_messages:]
context.variables['_conversation_history'] = history
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
# Support both new model_config format and legacy model + fallback_models format
model_config = self.get_config('model_config', None)
if model_config and isinstance(model_config, dict):
# New format: {primary: uuid, fallbacks: [uuid1, uuid2, ...]}
model_uuid = model_config.get('primary', '')
fallback_models = model_config.get('fallbacks', [])
else:
# Legacy format: separate model and fallback_models
model_uuid = self.get_config('model', '')
fallback_models = self.get_config('fallback_models', [])
if not model_uuid:
raise ValueError('No model configured for LLM call node')
if not self.ap:
raise RuntimeError('Application instance not available - cannot call LLM')
# Get error handling config
exception_handling = self.get_config('exception_handling', 'show-error')
failure_hint = self.get_config('failure_hint', 'Request failed.')
track_function_calls = self.get_config('track_function_calls', False)
# Get output format and json_schema config
output_format = self.get_config('output_format', 'text')
json_schema = self.get_config('json_schema', '')
# Agent config: knowledge bases, rerank, max_round
# (fallback_models already resolved above from model_config or fallback_models)
knowledge_bases = self.get_config('knowledge_bases', [])
rerank_model = self.get_config('rerank_model', '')
rerank_top_k = self.get_config('rerank_top_k', 5)
max_round = self.get_config('max_round', 10)
# Resolve prompts - support both new prompt array format and legacy format
prompt_array = self.get_config('prompt')
user_prompt = '' # Initialize for later use in _save_to_conversation_history
if prompt_array and isinstance(prompt_array, list):
# New format: prompt array like pipeline
messages = self._build_messages_from_prompt_array(
prompt_array, inputs, context, output_format, json_schema
)
# Get user input text for knowledge retrieval
user_input = inputs.get('input', '')
# Knowledge retrieval: enhance user input with RAG context
user_input = await self._retrieve_knowledge(
user_message_text=user_input,
knowledge_bases=knowledge_bases,
rerank_model_uuid=rerank_model,
rerank_top_k=rerank_top_k,
)
# Track user_prompt for conversation history
user_prompt = user_input
# Add user input as last message
if user_input:
messages.append(provider_message.Message(role='user', content=user_input))
# Apply max_round to conversation history
conversation_history = context.variables.get('_conversation_history', [])
if max_round > 0 and conversation_history:
max_messages = max_round * 2
if len(conversation_history) > max_messages:
conversation_history = conversation_history[-max_messages:]
# Insert conversation history before user input
history_messages = []
for msg in conversation_history:
if isinstance(msg, dict):
role = msg.get('role', 'user')
content = msg.get('content', '')
history_messages.append(provider_message.Message(role=role, content=content))
elif hasattr(msg, 'role') and hasattr(msg, 'content'):
history_messages.append(provider_message.Message(role=msg.role, content=msg.content))
# Insert history before user message
if history_messages and len(messages) > 0:
messages = messages[:-1] + history_messages + [messages[-1]]
else:
# Legacy format: separate system_prompt and user_prompt_template
system_prompt = self._resolve_template(self.get_config('system_prompt') or '', inputs, context)
user_prompt_template = self.get_config('user_prompt_template')
if user_prompt_template is None:
user_prompt_template = '{{input}}'
user_prompt = self._resolve_template(user_prompt_template, inputs, context)
# Build system prompt with format instructions
system_prompt = self._build_system_prompt_with_format(system_prompt, output_format, json_schema)
# Knowledge retrieval: enhance user prompt with RAG context
user_prompt = await self._retrieve_knowledge(
user_message_text=user_prompt,
knowledge_bases=knowledge_bases,
rerank_model_uuid=rerank_model,
rerank_top_k=rerank_top_k,
)
# Build messages with conversation history
messages = self._build_messages_with_history(
system_prompt=system_prompt,
user_message_text=user_prompt,
context=context,
max_round=max_round,
)
# Get model candidates (primary + fallbacks)
candidates = await self._get_model_candidates(model_uuid, fallback_models)
if not candidates:
raise ValueError('No valid model candidates available')
# Build extra args from config
extra_args: dict[str, Any] = {}
temperature = self.get_config('temperature')
if temperature is not None:
extra_args['temperature'] = float(temperature)
max_tokens = self.get_config('max_tokens', 0)
if max_tokens and int(max_tokens) > 0:
extra_args['max_tokens'] = int(max_tokens)
# Track start time for duration calculation
self._llm_start_time = time.time()
# Invoke LLM with fallback
try:
result_message, used_model = await self._invoke_with_fallback(
candidates=candidates,
messages=messages,
funcs=None,
extra_args=extra_args,
)
except Exception as e:
logger.warning(f'[LLM:{self.node_id}] LLM call failed: {e}')
# Handle based on exception handling strategy
if exception_handling == 'show-error':
raise
elif exception_handling == 'show-hint':
return {
'response': failure_hint,
'usage': {
'prompt_tokens': 0,
'completion_tokens': 0,
'total_tokens': 0,
},
'error': str(e),
'error_hint_shown': True,
}
else: # hide
return {
'response': '',
'usage': {
'prompt_tokens': 0,
'completion_tokens': 0,
'total_tokens': 0,
},
'error': str(e),
}
# Extract response text
response_text = ''
if isinstance(result_message.content, str):
response_text = result_message.content
elif isinstance(result_message.content, list):
for elem in result_message.content:
if hasattr(elem, 'text') and elem.text:
response_text += elem.text
elif isinstance(elem, str):
response_text += elem
# Remove CoT content (always remove to avoid leaking internal reasoning)
response_text = self._remove_think_content(response_text)
# Initialize usage default
usage = {
'prompt_tokens': 0,
'completion_tokens': 0,
'total_tokens': 0,
}
# Apply content safety filter
response_text, is_blocked, filter_notice = self._apply_content_filter(response_text)
if is_blocked:
logger.warning(f'[LLM:{self.node_id}] Response blocked by content filter: {filter_notice}')
return {
'response': filter_notice,
'usage': usage,
'blocked_by_filter': True,
}
# Extract usage info
if hasattr(result_message, 'usage') and result_message.usage:
u = result_message.usage
# Handle both object and dict usage
if isinstance(u, dict):
usage = {
'prompt_tokens': u.get('prompt_tokens', 0) or 0,
'completion_tokens': u.get('completion_tokens', 0) or 0,
'total_tokens': u.get('total_tokens', 0) or 0,
}
else:
usage = {
'prompt_tokens': getattr(u, 'prompt_tokens', 0) or 0,
'completion_tokens': getattr(u, 'completion_tokens', 0) or 0,
'total_tokens': getattr(u, 'total_tokens', 0) or 0,
}
elif hasattr(result_message, 'token_usage') and result_message.token_usage:
u = result_message.token_usage
# Handle both object and dict token_usage
if isinstance(u, dict):
usage = {
'prompt_tokens': u.get('prompt_tokens', 0) or 0,
'completion_tokens': u.get('completion_tokens', 0) or 0,
'total_tokens': u.get('total_tokens', 0) or 0,
}
else:
usage = {
'prompt_tokens': getattr(u, 'prompt_tokens', 0) or 0,
'completion_tokens': getattr(u, 'completion_tokens', 0) or 0,
'total_tokens': getattr(u, 'total_tokens', 0) or 0,
}
# Log successful response (matching Pipeline's cut_str behavior)
def _cut_str(s: str) -> str:
s0 = s.split('\n')[0]
if len(s0) > 20 or '\n' in s:
s0 = s0[:20] + '...'
return s0
logger.info(f'[LLM:{self.node_id}] Response: {_cut_str(response_text)}')
# Record LLM call log only (response log is redundant)
try:
if self.ap and context.query:
workflow_id = context.workflow_id or ''
workflow_name = context.variables.get('_workflow_name', 'Workflow')
bot_name = context.variables.get('_bot_name', 'Workflow')
node_name = self.get_config('name', self.node_id)
model_name = used_model.model_entity.name if used_model else 'unknown'
# Calculate duration
duration_ms = 0
if hasattr(self, '_llm_start_time'):
duration_ms = int((time.time() - self._llm_start_time) * 1000)
# Get message_id for LLM call association
message_id = context.variables.get('_monitoring_message_id')
# Record LLM call log with message_id association
await monitoring_helper.WorkflowMonitoringHelper.record_llm_call_log(
ap=self.ap,
query=context.query,
workflow_id=workflow_id,
workflow_name=workflow_name,
node_name=node_name,
model_name=model_name,
input_tokens=usage.get('prompt_tokens', 0),
output_tokens=usage.get('completion_tokens', 0),
duration_ms=duration_ms,
status='success',
bot_name=bot_name,
context_vars=context.variables,
message_id=message_id,
)
except Exception as e:
logger.warning(f'[LLM:{self.node_id}] Failed to record LLM logs: {e}')
# Save to conversation history
self._save_to_conversation_history(
context=context,
user_message_text=user_prompt,
response_text=response_text,
max_round=max_round,
)
# Build result
result: dict[str, Any] = {
'response': response_text,
'usage': usage,
'model_used': used_model.model_entity.name if used_model else None,
'model_uuid': used_model.model_entity.uuid if used_model else None,
}
# Parse JSON output if format is json
if output_format == 'json' and response_text:
try:
result['parsed'] = json.loads(response_text)
except json.JSONDecodeError as e:
logger.warning(f'[LLM:{self.node_id}] Failed to parse JSON: {e}')
result['parsed'] = None
result['parse_error'] = str(e)
# Add function call tracking info if configured
if track_function_calls:
result['function_calls'] = []
return result
async def execute_stream(
self, inputs: dict[str, Any], context: ExecutionContext
) -> AsyncGenerator[str, None]:
"""Execute the LLM call with streaming output.
Yields chunks of response text as they arrive.
Falls back to non-streaming if streaming is not available.
"""
# Support both new model_config format and legacy model + fallback_models format
model_config = self.get_config('model_config', None)
if model_config and isinstance(model_config, dict):
model_uuid = model_config.get('primary', '')
else:
model_uuid = self.get_config('model', '')
if not model_uuid:
raise ValueError('No model configured for LLM call node')
if not self.ap:
raise RuntimeError('Application instance not available - cannot call LLM')
exception_handling = self.get_config('exception_handling', 'show-error')
failure_hint = self.get_config('failure_hint', 'Request failed.')
# Resolve prompts - support both new prompt array format and legacy format
prompt_array = self.get_config('prompt')
if prompt_array and isinstance(prompt_array, list):
# New format: prompt array like pipeline
messages = self._build_messages_from_prompt_array(
prompt_array, inputs, context, 'text', '' # No format instructions for streaming
)
# Add user input
user_input = inputs.get('input', '')
if user_input:
messages.append(provider_message.Message(role='user', content=user_input))
else:
# Legacy format
system_prompt = self._resolve_template(self.get_config('system_prompt') or '', inputs, context)
user_prompt_template = self.get_config('user_prompt_template')
if user_prompt_template is None:
user_prompt_template = '{{input}}'
user_prompt = self._resolve_template(user_prompt_template, inputs, context)
# Build messages
messages = []
if system_prompt:
messages.append(provider_message.Message(role='system', content=system_prompt))
messages.append(provider_message.Message(role='user', content=user_prompt))
# Get model
runtime_model = await self.ap.model_mgr.get_model_by_uuid(model_uuid)
# Build extra args
extra_args: dict[str, Any] = {}
temperature = self.get_config('temperature')
if temperature is not None:
extra_args['temperature'] = float(temperature)
max_tokens = self.get_config('max_tokens', 0)
if max_tokens and int(max_tokens) > 0:
extra_args['max_tokens'] = int(max_tokens)
logger.info(f'[LLM:{self.node_id}] Streaming model {model_uuid}')
try:
# Try streaming first
stream = runtime_model.provider.invoke_llm_stream(
query=None,
model=runtime_model,
messages=messages,
funcs=None,
extra_args=extra_args,
)
full_response = ''
in_think_block = False
async for chunk in stream:
chunk_text = ''
if hasattr(chunk, 'content'):
if isinstance(chunk.content, str):
chunk_text = chunk.content
elif isinstance(chunk.content, list):
for elem in chunk.content:
if hasattr(elem, 'text') and elem.text:
chunk_text += elem.text
elif isinstance(elem, str):
chunk_text += elem
if chunk_text:
# Filter <think> blocks in streaming mode
if '<think>' in chunk_text or '<thought>' in chunk_text:
in_think_block = True
if in_think_block:
if '</think>' in chunk_text or '</thought>' in chunk_text:
in_think_block = False
chunk_text = chunk_text.split('</think>')[-1].split('</thought>')[-1]
else:
chunk_text = ''
if chunk_text:
full_response += chunk_text
yield chunk_text
# Store in context for downstream nodes
context.variables['_last_llm_response'] = full_response
except Exception as e:
logger.warning(f'[LLM:{self.node_id}] Streaming failed, falling back - {e}')
# Fallback to non-streaming
try:
result_message = await runtime_model.provider.invoke_llm(
query=None,
model=runtime_model,
messages=messages,
funcs=None,
extra_args=extra_args,
)
response_text = self._extract_response_text(result_message)
# Always remove <think> content in fallback
response_text = self._remove_think_content(response_text)
yield response_text
context.variables['_last_llm_response'] = response_text
except Exception as e2:
logger.error(f'[LLM:{self.node_id}] Fallback also failed - {e2}')
if exception_handling == 'show-hint':
yield failure_hint
elif exception_handling != 'hide':
raise
def _extract_response_text(self, result_message: provider_message.Message) -> str:
"""Extract response text from LLM result message."""
response_text = ''
if isinstance(result_message.content, str):
response_text = result_message.content
elif isinstance(result_message.content, list):
for elem in result_message.content:
if hasattr(elem, 'text') and elem.text:
response_text += elem.text
elif isinstance(elem, str):
response_text += elem
return response_text

View File

@@ -0,0 +1,30 @@
"""Loop Node - iterate over items"""
from __future__ import annotations
from typing import Any
from langbot_plugin.api.entities.builtin.workflow.entities import ExecutionContext
from ..node import WorkflowNode, workflow_node
@workflow_node('loop')
class LoopNode(WorkflowNode):
"""Loop node - iterate over items"""
category = 'control'
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
items = inputs.get('items', [])
if not isinstance(items, list):
items = [items] if items else []
max_iterations = self.get_config('max_iterations', 100)
items = items[:max_iterations]
return {
'item': items[0] if items else None,
'index': 0,
'results': [],
'completed': len(items) == 0,
'_items': items,
}

View File

@@ -0,0 +1,58 @@
"""MCP Tool Node - Invoke MCP (Model Context Protocol) tools
This module contains the implementation for the MCP Tool workflow node.
Node metadata (label, description, inputs, outputs, config) is loaded from:
../../templates/metadata/nodes/mcp_tool.yaml
The i18n for label and description is handled on the frontend side.
"""
from __future__ import annotations
from typing import Any
from langbot_plugin.api.entities.builtin.workflow.entities import ExecutionContext
from ..node import WorkflowNode, workflow_node
@workflow_node('mcp_tool')
class MCPToolNode(WorkflowNode):
"""MCP tool node - invoke MCP (Model Context Protocol) tools"""
# Node type for registration
# Category and icon - these are not i18n
category = 'integration'
# Name and description - i18n handled on frontend side
# Frontend will use node type key to look up translation
# Inputs/outputs/config - loaded from YAML at runtime
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
"""Execute the MCP tool node
Args:
inputs: Input data from connected nodes
context: Execution context with workflow state
Returns:
Dictionary of output values
"""
server_name = self.get_config('server_name', '')
tool_name = self.get_config('tool_name', '')
arguments_template = self.get_config('arguments_template', '')
timeout = self.get_config('timeout', 30)
arguments = inputs.get('arguments', arguments_template)
return {
'result': None,
'success': False,
'error': f"MCP tool '{server_name}/{tool_name}' not implemented yet",
'_debug': {
'server_name': server_name,
'tool_name': tool_name,
'arguments': arguments,
'timeout': timeout,
},
}

View File

@@ -0,0 +1,85 @@
"""Memory Store Node - store and retrieve from workflow memory
Node metadata is loaded from: ../../templates/metadata/nodes/memory_store.yaml
"""
from __future__ import annotations
from typing import Any
from langbot_plugin.api.entities.builtin.workflow.entities import ExecutionContext
from ..node import WorkflowNode, workflow_node
class MemoryHelper:
"""Helper class wrapping context.memory dict with get/set/delete/list_all/append operations"""
def __init__(self, memory_dict: dict[str, Any]):
self._data = memory_dict
def get(self, key: str, scope: str = 'execution', default: Any = None) -> Any:
"""Get a value from memory by key"""
scoped_key = f'{scope}:{key}' if scope else key
return self._data.get(scoped_key, default)
def set(self, key: str, value: Any, scope: str = 'execution', ttl: int = 0) -> None:
"""Set a value in memory"""
scoped_key = f'{scope}:{key}' if scope else key
self._data[scoped_key] = value
def delete(self, key: str, scope: str = 'execution') -> None:
"""Delete a value from memory"""
scoped_key = f'{scope}:{key}' if scope else key
self._data.pop(scoped_key, None)
def list_all(self, scope: str = 'execution') -> dict[str, Any]:
"""List all values in the given scope"""
prefix = f'{scope}:'
return {k[len(prefix) :]: v for k, v in self._data.items() if k.startswith(prefix)}
def append(self, key: str, value: Any, scope: str = 'execution', ttl: int = 0) -> list:
"""Append a value to a list in memory"""
current = self.get(key, scope=scope, default=[])
if isinstance(current, list):
current.append(value)
else:
current = [current, value]
self.set(key, current, scope=scope, ttl=ttl)
return current
@workflow_node('memory_store')
class MemoryStoreNode(WorkflowNode):
"""Memory store node - store and retrieve from workflow memory"""
category = 'integration'
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
operation = self.get_config('operation', 'get')
key = self.get_config('key', '')
scope = self.get_config('scope', 'execution')
ttl = self.get_config('ttl', 0)
value = inputs.get('value')
# Wrap context.memory dict with MemoryHelper for structured operations
memory = MemoryHelper(context.memory)
try:
if operation == 'get':
result = memory.get(key, scope=scope)
return {'result': result, 'success': True}
elif operation == 'set':
memory.set(key, value, scope=scope, ttl=ttl)
return {'result': value, 'success': True}
elif operation == 'delete':
memory.delete(key, scope=scope)
return {'result': None, 'success': True}
elif operation == 'append':
result = memory.append(key, value, scope=scope, ttl=ttl)
return {'result': result, 'success': True}
elif operation == 'list':
result = memory.list_all(scope=scope)
return {'result': result, 'success': True}
else:
return {'result': None, 'success': False, 'error': f'Unknown operation: {operation}'}
except Exception as e:
return {'result': None, 'success': False, 'error': str(e)}

View File

@@ -0,0 +1,52 @@
"""Merge Node - combine multiple inputs
Node metadata is loaded from: ../../templates/metadata/nodes/merge.yaml
"""
from __future__ import annotations
from typing import Any
from langbot_plugin.api.entities.builtin.workflow.entities import ExecutionContext
from ..node import WorkflowNode, workflow_node
@workflow_node('merge')
class MergeNode(WorkflowNode):
"""Merge node - combine multiple inputs"""
category = 'control'
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
strategy = self.get_config('merge_strategy', 'object')
values = [inputs.get('input_1'), inputs.get('input_2'), inputs.get('input_3'), inputs.get('input_4')]
non_null_values = [v for v in values if v is not None]
if strategy == 'object':
merged = {}
for i, v in enumerate(non_null_values):
if isinstance(v, dict):
merged.update(v)
else:
merged[f'value_{i}'] = v
return {'merged': merged, 'array': non_null_values}
elif strategy == 'array':
return {'merged': non_null_values, 'array': non_null_values}
elif strategy == 'first_non_null':
first = non_null_values[0] if non_null_values else None
return {'merged': first, 'array': non_null_values}
elif strategy == 'concat':
if all(isinstance(v, str) for v in non_null_values):
return {'merged': ''.join(non_null_values), 'array': non_null_values}
elif all(isinstance(v, list) for v in non_null_values):
merged_list = []
for v in non_null_values:
merged_list.extend(v)
return {'merged': merged_list, 'array': merged_list}
else:
return {'merged': non_null_values, 'array': non_null_values}
return {'merged': non_null_values, 'array': non_null_values}

View File

@@ -0,0 +1,69 @@
"""Message Trigger Node - triggers workflow on message arrival
This module contains the implementation for the Message Trigger workflow node.
Node metadata (label, description, inputs, outputs, config) is loaded from:
../../templates/metadata/nodes/message_trigger.yaml
"""
from __future__ import annotations
import logging
from typing import Any
from langbot_plugin.api.entities.builtin.workflow.entities import ExecutionContext
from ..node import WorkflowNode, workflow_node
from .. import monitoring_helper
logger = logging.getLogger(__name__)
@workflow_node('message_trigger')
class MessageTriggerNode(WorkflowNode):
"""Message trigger node - triggers workflow on message arrival"""
category = 'trigger'
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
msg_ctx = context.message_context
# Record trigger log and store message_id for LLM call association
try:
if self.ap and context.query:
workflow_id = context.workflow_id or ''
workflow_name = context.variables.get('_workflow_name', 'Workflow')
bot_name = context.variables.get('_bot_name', 'Workflow')
message_id = await monitoring_helper.WorkflowMonitoringHelper.record_trigger_log(
ap=self.ap,
query=context.query,
workflow_id=workflow_id,
workflow_name=workflow_name,
bot_name=bot_name,
context_vars=context.variables,
)
# Store message_id for LLM call monitoring association
if message_id:
context.variables['_monitoring_message_id'] = message_id
except Exception as e:
logger.warning(f'[MessageTrigger:{self.node_id}] Failed to record trigger log: {e}')
if msg_ctx:
return {
'message': msg_ctx.message_content,
'sender_id': msg_ctx.sender_id,
'sender_name': msg_ctx.sender_name,
'platform': msg_ctx.platform,
'conversation_id': msg_ctx.conversation_id,
'is_group': msg_ctx.is_group,
'context': msg_ctx.model_dump(),
}
# Use safe variable access with fallback
return {
'message': context.get_variable('message') or '',
'sender_id': context.get_variable('sender_id') or '',
'sender_name': context.get_variable('sender_name') or '',
'platform': context.get_variable('platform') or '',
'conversation_id': context.get_variable('conversation_id') or '',
'is_group': context.get_variable('is_group') or False,
'context': context.trigger_data or {},
}

View File

@@ -0,0 +1,34 @@
"""N8n Workflow Node - call n8n workflow API
Node metadata is loaded from: ../../templates/metadata/nodes/n8n_workflow.yaml
"""
from __future__ import annotations
from typing import Any
from langbot_plugin.api.entities.builtin.workflow.entities import ExecutionContext
from ..node import WorkflowNode, workflow_node
@workflow_node('n8n_workflow')
class N8nWorkflowNode(WorkflowNode):
"""n8n workflow node - call n8n workflow API"""
category = 'integration'
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
webhook_url = self.get_config('webhook_url', '')
auth_type = self.get_config('auth_type', 'none')
timeout = self.get_config('timeout', 120)
payload = inputs.get('payload', {})
return {
'result': None,
'success': False,
'_debug': {
'webhook_url': webhook_url,
'auth_type': auth_type,
'timeout': timeout,
'payload': payload,
},
}

View File

@@ -0,0 +1,24 @@
"""Opening Statement Node - provide conversation opener and suggested questions
Node metadata is loaded from: ../../templates/metadata/nodes/opening_statement.yaml
"""
from __future__ import annotations
from typing import Any
from langbot_plugin.api.entities.builtin.workflow.entities import ExecutionContext
from ..node import WorkflowNode, workflow_node
@workflow_node('opening_statement')
class OpeningStatementNode(WorkflowNode):
"""Opening statement node - provide conversation opener and suggested questions"""
category = 'action'
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
statement = self.get_config('statement', '')
suggestions = self.get_config('suggested_questions', [])
show = self.get_config('show_suggestions', True)
return {'statement': statement, 'suggested_questions': suggestions if show else []}

View File

@@ -0,0 +1,20 @@
"""Parallel Node - execute multiple branches simultaneously"""
from __future__ import annotations
from typing import Any
from langbot_plugin.api.entities.builtin.workflow.entities import ExecutionContext
from ..node import WorkflowNode, workflow_node
@workflow_node('parallel')
class ParallelNode(WorkflowNode):
"""Parallel node - execute multiple branches simultaneously"""
category = 'control'
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
return {
'results': {},
'errors': [],
}

View File

@@ -0,0 +1,149 @@
"""Parameter Extractor Node - extract structured parameters from text
Node metadata is loaded from: ../../templates/metadata/nodes/parameter_extractor.yaml
"""
from __future__ import annotations
import json
import logging
from typing import Any
from langbot_plugin.api.entities.builtin.workflow.entities import ExecutionContext
from ..node import WorkflowNode, workflow_node
logger = logging.getLogger(__name__)
@workflow_node('parameter_extractor')
class ParameterExtractorNode(WorkflowNode):
"""Parameter extractor node - extract structured parameters from text"""
category = 'process'
icon: str = 'Variable'
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
# Get input text
input_text = inputs.get('input') or inputs.get('message') or inputs.get('content') or ''
input_text = str(input_text) if input_text is not None else ''
# Get configuration
param_defs = self.get_config('parameters', [])
model_id = self.get_config('model', '')
system_prompt = self.get_config('system_prompt', '')
if not input_text.strip():
return {
'parameters': {},
'extraction_success': False,
'error': 'Empty input',
}
if not param_defs:
return {
'parameters': {},
'extraction_success': False,
'error': 'No parameters configured',
}
# Build parameter schema for LLM prompt
param_schema = []
for param in param_defs:
schema_item = {
'name': param.get('name', ''),
'type': param.get('type', 'string'),
'description': param.get('description', ''),
'required': param.get('required', False),
}
param_schema.append(schema_item)
# Build extraction prompt
if not system_prompt:
system_prompt = (
f'Extract the following parameters from the user\'s text as JSON. '
f'Respond with ONLY a valid JSON object containing the extracted parameters.\n\n'
f'Parameters to extract:\n'
f'{json.dumps(param_schema, indent=2, ensure_ascii=False)}\n\n'
f'Respond with a JSON object like: {{"param_name": "value", ...}}'
)
# Call LLM for extraction
if self.ap and model_id:
try:
# Get model (same as llm_call.py)
runtime_model = await self.ap.model_mgr.get_model_by_uuid(model_id)
# Build messages
from langbot_plugin.api.entities.builtin.provider.message import Message
messages = []
if system_prompt:
messages.append(Message(role='system', content=system_prompt))
messages.append(Message(role='user', content=input_text))
# Invoke LLM (same as llm_call.py)
result_message = await runtime_model.provider.invoke_llm(
query=None,
model=runtime_model,
messages=messages,
funcs=None,
extra_args={},
)
# Log successful response (matching Pipeline's cut_str behavior)
response_preview = ''
if isinstance(result_message.content, str):
response_preview = result_message.content
elif isinstance(result_message.content, list):
for elem in result_message.content:
if hasattr(elem, 'text') and elem.text:
response_preview += elem.text
response_preview = response_preview.strip()
def _cut_str(s: str) -> str:
s0 = s.split('\n')[0]
if len(s0) > 20 or '\n' in s:
s0 = s0[:20] + '...'
return s0
logger.info(f'[ParameterExtractor:{self.node_id}] Response: {_cut_str(response_preview)}')
# Extract response text
response_text = ''
if isinstance(result_message.content, str):
response_text = result_message.content
elif isinstance(result_message.content, list):
for elem in result_message.content:
if hasattr(elem, 'text') and elem.text:
response_text += elem.text
elif isinstance(elem, str):
response_text += elem
response_text = response_text.strip()
# Parse JSON response
try:
extracted = json.loads(response_text)
return {
'parameters': extracted,
'extraction_success': True,
'raw_response': response_text[:500],
}
except json.JSONDecodeError as e:
logger.error('ParameterExtractorNode JSON parse error: %s', e)
return {
'parameters': {},
'extraction_success': False,
'error': f'Failed to parse JSON: {e}',
'raw_response': response_text[:500],
}
except Exception as e:
logger.error('ParameterExtractorNode LLM error: %s', e, exc_info=True)
return {
'parameters': {},
'extraction_success': False,
'error': f'LLM error: {e}',
}
else:
return {
'parameters': {},
'extraction_success': False,
'error': 'Missing model configuration',
}

View File

@@ -0,0 +1,41 @@
# """Plugin Call Node - invoke a plugin
# Node metadata is loaded from: ../../templates/metadata/nodes/plugin_call.yaml
# """
# from __future__ import annotations
# from typing import Any
# from langbot_plugin.api.entities.builtin.workflow.entities import ExecutionContext
# from ..node import WorkflowNode, workflow_node
# @workflow_node('plugin_call')
# class PluginCallNode(WorkflowNode):
# """Plugin call node - invoke a plugin"""
# type_name = "plugin_call"
# category = "action"
# icon = "🔌"
# name = "plugin_call"
# description = "plugin_call"
# inputs: ClassVar[list[NodePort]] = []
# outputs: ClassVar[list[NodePort]] = []
# config_schema: ClassVar[list[NodeConfig]] = []
# async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
# plugin_name = self.get_config("plugin_name", "")
# method_name = self.get_config("method_name", "")
# arguments = inputs.get("arguments", {})
# return {
# "result": None,
# "success": False,
# "error": f"Plugin call '{plugin_name}/{method_name}' not implemented yet",
# "_debug": {
# "plugin_name": plugin_name,
# "method_name": method_name,
# "arguments": arguments,
# },
# }

View File

@@ -0,0 +1,145 @@
"""Question Classifier Node - classify user questions into categories
Node metadata is loaded from: ../../templates/metadata/nodes/question_classifier.yaml
"""
from __future__ import annotations
import logging
from typing import Any
from langbot_plugin.api.entities.builtin.workflow.entities import ExecutionContext
from ..node import WorkflowNode, workflow_node
logger = logging.getLogger(__name__)
@workflow_node('question_classifier')
class QuestionClassifierNode(WorkflowNode):
"""Question classifier node - classify user questions into categories"""
category = 'process'
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
# Get input text
input_text = inputs.get('input') or inputs.get('message') or inputs.get('content') or ''
input_text = str(input_text) if input_text is not None else ''
# Get configuration
categories = self.get_config('categories', [])
model_id = self.get_config('model', '')
system_prompt = self.get_config('system_prompt', '')
if not input_text.strip():
return {
'category': 'unknown',
'confidence': 0.0,
'all_scores': {},
'error': 'Empty input',
}
if not categories:
return {
'category': 'unknown',
'confidence': 0.0,
'all_scores': {},
'error': 'No categories configured',
}
# Build category list for LLM prompt
category_names = [cat.get('name', '') for cat in categories if cat.get('name')]
# Build classification prompt
if not system_prompt:
system_prompt = (
f'You are a question classifier. Classify the user\'s question into one of these categories: '
f'{", ".join(category_names)}. '
f'Respond with ONLY the category name, nothing else.'
)
# Call LLM for classification
if self.ap and model_id:
try:
# Get model (same as llm_call.py)
runtime_model = await self.ap.model_mgr.get_model_by_uuid(model_id)
# Build messages
from langbot_plugin.api.entities.builtin.provider.message import Message
messages = []
if system_prompt:
messages.append(Message(role='system', content=system_prompt))
messages.append(Message(role='user', content=input_text))
# Invoke LLM (same as llm_call.py)
result_message = await runtime_model.provider.invoke_llm(
query=None,
model=runtime_model,
messages=messages,
funcs=None,
extra_args={},
)
# Log successful response (matching Pipeline's cut_str behavior)
response_preview = ''
if isinstance(result_message.content, str):
response_preview = result_message.content
elif isinstance(result_message.content, list):
for elem in result_message.content:
if hasattr(elem, 'text') and elem.text:
response_preview += elem.text
response_preview = response_preview.strip()
def _cut_str(s: str) -> str:
s0 = s.split('\n')[0]
if len(s0) > 20 or '\n' in s:
s0 = s0[:20] + '...'
return s0
logger.info(f'[QuestionClassifier:{self.node_id}] Response: {_cut_str(response_preview)}')
# Extract response text
response_text = ''
if isinstance(result_message.content, str):
response_text = result_message.content
elif isinstance(result_message.content, list):
for elem in result_message.content:
if hasattr(elem, 'text') and elem.text:
response_text += elem.text
elif isinstance(elem, str):
response_text += elem
response_text = response_text.strip()
# Find matching category
matched_category = None
for cat in categories:
if cat.get('name', '').lower() == response_text.lower():
matched_category = cat
break
if matched_category:
return {
'category': matched_category['name'],
'confidence': 0.9,
'all_scores': {cat.get('name', ''): 0.1 for cat in categories},
}
else:
# Default to first category if no match
return {
'category': category_names[0] if category_names else 'unknown',
'confidence': 0.5,
'all_scores': {cat.get('name', ''): 0.1 for cat in categories},
}
except Exception as e:
logger.error('QuestionClassifierNode LLM error: %s', e, exc_info=True)
return {
'category': category_names[0] if category_names else 'unknown',
'confidence': 0.0,
'all_scores': {},
'error': f'LLM error: {e}',
}
else:
return {
'category': category_names[0] if category_names else 'unknown',
'confidence': 0.0,
'all_scores': {},
'error': 'Missing model configuration',
}

View File

@@ -0,0 +1,40 @@
"""Redis Operation Node - perform Redis cache operations
Node metadata is loaded from: ../../templates/metadata/nodes/redis_operation.yaml
"""
from __future__ import annotations
from typing import Any
from langbot_plugin.api.entities.builtin.workflow.entities import ExecutionContext
from ..node import WorkflowNode, workflow_node
@workflow_node('redis_operation')
class RedisOperationNode(WorkflowNode):
"""Redis operation node - perform Redis cache operations"""
category = 'integration'
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
connection_url = self.get_config('connection_url', 'redis://localhost:6379')
operation = self.get_config('operation', 'get')
key_template = self.get_config('key_template', '')
hash_field = self.get_config('hash_field', '')
ttl = self.get_config('ttl', 0)
key = inputs.get('key', key_template)
value = inputs.get('value')
return {
'result': None,
'success': False,
'_debug': {
'connection_url': connection_url,
'operation': operation,
'key': key,
'hash_field': hash_field,
'ttl': ttl,
'value': value,
},
}

View File

@@ -0,0 +1,141 @@
"""Reply Message Node - reply to the triggering message
Node metadata is loaded from: ../../templates/metadata/nodes/reply_message.yaml
"""
from __future__ import annotations
import logging
from typing import Any
from langbot_plugin.api.entities.builtin.workflow.entities import ExecutionContext
from ..node import WorkflowNode, workflow_node
from .. import monitoring_helper
logger = logging.getLogger(__name__)
@workflow_node('reply_message')
class ReplyMessageNode(WorkflowNode):
"""Reply message node - reply to the triggering message"""
category = 'action'
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
# Priority: content/response (from upstream nodes) > message (original input) > context
message = inputs.get('content')
if message in (None, ''):
message = inputs.get('response')
if message in (None, ''):
message = inputs.get('input')
if message in (None, ''):
message = inputs.get('message')
if message in (None, '') and context.message_context:
message = context.message_context.message_content
if message is None:
message = ''
template = self.get_config('message_template')
if template:
message = template
for key, value in inputs.items():
try:
message = message.replace(f'{{{{{key}}}}}', str(value) if value is not None else '')
except Exception as e:
logger.debug(
'ReplyMessageNode failed to replace input template variable',
extra={'node_id': self.node_id, 'key': str(key), 'error': str(e)},
)
for key, value in context.variables.items():
try:
message = message.replace(f'{{{{variables.{key}}}}}', str(value) if value is not None else '')
except Exception as e:
logger.debug(
'ReplyMessageNode failed to replace context template variable',
extra={'node_id': self.node_id, 'key': str(key), 'error': str(e)},
)
message_str = str(message) if message is not None else ''
logger.info(
'ReplyMessageNode resolved message',
extra={
'node_id': self.node_id,
'execution_id': context.execution_id,
'input_keys': list(inputs.keys()),
'message_preview': message_str[:200],
'has_template': bool(template),
'session_id': context.session_id,
},
)
if not message_str.strip():
logger.warning(
'ReplyMessageNode has empty message after resolution',
extra={
'node_id': self.node_id,
'execution_id': context.execution_id,
'input_keys': list(inputs.keys()),
},
)
# 实际发送消息
send_success = False
send_error = None
if self.ap:
try:
from langbot_plugin.api.entities.builtin.platform.message import MessageChain, Plain
message_chain = MessageChain([Plain(text=message_str)])
# 从 trigger_data 中获取 session_type而不是从未设置的 context.target_type
target_type = 'person'
if context.trigger_data:
target_type = context.trigger_data.get('session_type', 'person') or 'person'
session_id = context.session_id or 'unknown'
target_id = f'websocket_{session_id}'
await self.ap.platform_mgr.websocket_proxy_bot.adapter.send_message(
target_type=target_type,
target_id=target_id,
message=message_chain,
)
send_success = True
except Exception as e:
send_error = str(e)
logger.error('ReplyMessageNode send message failed: %s', e, exc_info=True)
else:
send_error = 'Missing application instance'
logger.warning(
'ReplyMessageNode missing application instance',
extra={
'node_id': self.node_id,
'execution_id': context.execution_id,
},
)
# Record reply log
try:
if self.ap and context.query and send_success:
workflow_id = context.workflow_id or ''
workflow_name = context.variables.get('_workflow_name', 'Workflow')
bot_name = context.variables.get('_bot_name', 'Workflow')
node_name = self.get_config('name', self.node_id)
await monitoring_helper.WorkflowMonitoringHelper.record_reply_log(
ap=self.ap,
query=context.query,
workflow_id=workflow_id,
workflow_name=workflow_name,
node_name=node_name,
reply_content=message_str,
bot_name=bot_name,
context_vars=context.variables,
)
except Exception as e:
logger.warning(f'[ReplyMessage:{self.node_id}] Failed to record reply log: {e}')
return {
'status': 'sent' if send_success else 'failed',
'message_content': message_str,
'message_preview': message_str[:200],
'error': send_error,
}

View File

@@ -0,0 +1,79 @@
"""Send Message Node - send message to a target
Node metadata is loaded from: ../../templates/metadata/nodes/send_message.yaml
"""
from __future__ import annotations
import logging
from typing import Any
from langbot_plugin.api.entities.builtin.workflow.entities import ExecutionContext
from ..node import WorkflowNode, workflow_node
logger = logging.getLogger(__name__)
@workflow_node('send_message')
class SendMessageNode(WorkflowNode):
"""Send message node - send message to a target"""
category = 'action'
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
# Get message content from inputs
message = inputs.get('message') or inputs.get('content') or inputs.get('input') or ''
message = str(message) if message is not None else ''
# Get target configuration - fallback to session_type from context if not configured
target_type = self.get_config('target_type', '')
if not target_type:
# Inherit from current session context
if context.trigger_data:
target_type = context.trigger_data.get('session_type', 'person') or 'person'
else:
target_type = 'person'
target_id = self.get_config('target_id', '')
# If no target_id configured, use session_id from context
if not target_id:
target_id = f'{context.session_id or "unknown"}'
if not message.strip():
logger.warning('SendMessageNode has empty message')
return {
'status': 'failed',
'error': 'Empty message',
'message_preview': '',
}
# Send message if application instance is available
send_success = False
send_error = None
if self.ap:
try:
from langbot_plugin.api.entities.builtin.platform.message import MessageChain, Plain
message_chain = MessageChain([Plain(text=message)])
await self.ap.platform_mgr.websocket_proxy_bot.adapter.send_message(
target_type=target_type,
target_id=target_id,
message=message_chain,
)
send_success = True
logger.info('SendMessageNode sent message to %s:%s', target_type, target_id)
except Exception as e:
send_error = str(e)
logger.error('SendMessageNode send failed: %s', e, exc_info=True)
else:
send_error = 'Missing application instance'
logger.warning('SendMessageNode missing application instance')
return {
'status': 'sent' if send_success else 'failed',
'message_content': message,
'message_preview': message[:200],
'target_type': target_type,
'target_id': target_id,
'error': send_error,
}

View File

@@ -0,0 +1,51 @@
"""Set Variable Node - set workflow or conversation variable
Node metadata is loaded from: ../../templates/metadata/nodes/set_variable.yaml
"""
from __future__ import annotations
from typing import Any
from langbot_plugin.api.entities.builtin.workflow.entities import ExecutionContext
from ..node import WorkflowNode, workflow_node
@workflow_node('set_variable')
class SetVariableNode(WorkflowNode):
"""Set variable node - set workflow or conversation variable"""
category = 'action'
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
value = inputs.get('value')
name = self.get_config('variable_name', '')
scope = self.get_config('variable_scope', 'workflow')
operation = self.get_config('operation', 'set')
if scope == 'conversation':
current = context.get_conversation_variable(name)
else:
current = context.get_variable(name)
if operation == 'set':
final_value = value
elif operation == 'append':
if isinstance(current, list):
final_value = current + [value]
elif isinstance(current, str):
final_value = current + str(value)
else:
final_value = [current, value] if current else [value]
elif operation == 'increment':
final_value = (current or 0) + (value if isinstance(value, (int, float)) else 1)
elif operation == 'decrement':
final_value = (current or 0) - (value if isinstance(value, (int, float)) else 1)
else:
final_value = value
if scope == 'conversation':
context.set_conversation_variable(name, final_value)
else:
context.set_variable(name, final_value)
return {'value': final_value}

View File

@@ -0,0 +1,32 @@
"""Store Data Node - save data to storage
Node metadata is loaded from: ../../templates/metadata/nodes/store_data.yaml
"""
from __future__ import annotations
from typing import Any
from langbot_plugin.api.entities.builtin.workflow.entities import ExecutionContext
from ..node import WorkflowNode, workflow_node
@workflow_node('store_data')
class StoreDataNode(WorkflowNode):
"""Store data node - save data to storage"""
category = 'action'
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
key = inputs.get('key', '')
value = inputs.get('value')
storage_type = self.get_config('storage_type', 'session')
prefix = self.get_config('key_prefix', '')
full_key = f'{prefix}{key}' if prefix else key
if storage_type == 'session':
context.set_conversation_variable(full_key, value)
else:
context.set_variable(full_key, value)
return {'status': 'stored'}

View File

@@ -0,0 +1,51 @@
"""Switch Node - multi-way branch based on value
Node metadata is loaded from: ../../templates/metadata/nodes/switch.yaml
"""
from __future__ import annotations
from typing import Any
from langbot_plugin.api.entities.builtin.workflow.entities import ExecutionContext
from ..node import WorkflowNode, workflow_node
@workflow_node('switch')
class SwitchNode(WorkflowNode):
"""Switch node - multi-way branch based on value"""
category = 'control'
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
expression = self.get_config('expression', '')
cases = self.get_config('cases', [])
input_data = inputs.get('input')
value = await self._evaluate_expression(expression, input_data, context)
for case in cases:
if str(case.get('value')) == str(value):
return {'matched_case': input_data, 'default': None, '_matched_output': case.get('output')}
return {'matched_case': None, 'default': input_data}
async def _evaluate_expression(self, expression: str, data: Any, context: ExecutionContext) -> Any:
if not expression:
return data
if expression.startswith('{{') and expression.endswith('}}'):
var_path = expression[2:-2].strip()
parts = var_path.split('.')
if parts[0] == 'input':
result = data
for part in parts[1:]:
if isinstance(result, dict):
result = result.get(part)
else:
return None
return result
elif parts[0] == 'variables':
return context.variables.get('.'.join(parts[1:]))
return expression

View File

@@ -0,0 +1,38 @@
"""Variable Aggregator Node - aggregate variables from multiple branches
Node metadata is loaded from: ../../templates/metadata/nodes/variable_aggregator.yaml
"""
from __future__ import annotations
from typing import Any
from langbot_plugin.api.entities.builtin.workflow.entities import ExecutionContext
from ..node import WorkflowNode, workflow_node
@workflow_node('variable_aggregator')
class VariableAggregatorNode(WorkflowNode):
"""Variable aggregator node - aggregate variables from multiple branches"""
category = 'control'
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
variables = inputs.get('variables', {})
mode = self.get_config('aggregation_mode', 'merge')
aggregated = {}
if mode == 'merge':
if isinstance(variables, dict):
aggregated.update(variables)
elif mode == 'override':
if isinstance(variables, dict):
aggregated = variables.copy()
elif mode == 'append':
for key, value in (variables if isinstance(variables, dict) else {}).items():
if key in aggregated and isinstance(aggregated[key], list):
aggregated[key].append(value)
else:
aggregated[key] = [value]
return {'aggregated': aggregated}

View File

@@ -0,0 +1,50 @@
"""Wait Node - pause execution for a duration
Node metadata is loaded from: ../../templates/metadata/nodes/wait.yaml
"""
from __future__ import annotations
import logging
from typing import Any
from langbot_plugin.api.entities.builtin.workflow.entities import ExecutionContext
from ..node import WorkflowNode, workflow_node
logger = logging.getLogger(__name__)
# 最大等待时间(秒)
_MAX_WAIT_SECONDS = 300 # 5 分钟
@workflow_node('wait')
class WaitNode(WorkflowNode):
"""Wait node - pause execution for a duration"""
category = 'control'
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
import asyncio
duration = self.get_config('duration', 1)
duration_type = self.get_config('duration_type', 'seconds')
# 转换为秒
if duration_type == 'minutes':
duration *= 60
elif duration_type == 'hours':
duration *= 3600
# 限制最大等待时间
if duration > _MAX_WAIT_SECONDS:
logger.warning('Wait duration %s exceeds maximum %s, capping to %s',
duration, _MAX_WAIT_SECONDS, _MAX_WAIT_SECONDS)
duration = _MAX_WAIT_SECONDS
# 确保 duration 为正数
duration = max(0, duration)
logger.info('Waiting for %.2f seconds', duration)
await asyncio.sleep(duration)
return {'output': inputs.get('input'), 'waited_seconds': duration}

View File

@@ -0,0 +1,33 @@
"""Webhook Trigger Node - triggers workflow via HTTP request
Node metadata is loaded from: ../../templates/metadata/nodes/webhook_trigger.yaml
"""
from __future__ import annotations
from typing import Any
from langbot_plugin.api.entities.builtin.workflow.entities import ExecutionContext
from ..node import WorkflowNode, workflow_node
@workflow_node('webhook_trigger')
class WebhookTriggerNode(WorkflowNode):
"""Webhook trigger node - triggers workflow via HTTP request"""
category = 'trigger'
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
# Safe access to trigger_data which may be None
trigger_data = context.trigger_data or {}
# Filter sensitive headers (Authorization, Cookie, etc.)
headers = trigger_data.get('headers', {})
safe_headers = {k: v for k, v in headers.items()
if k.lower() not in ('authorization', 'cookie', 'x-api-key', 'x-secret')}
return {
'body': trigger_data.get('body', {}),
'headers': safe_headers,
'query': trigger_data.get('query', {}),
'method': trigger_data.get('method', 'POST'),
}

View File

@@ -0,0 +1,443 @@
"""Workflow node type registry."""
from __future__ import annotations
import copy
import logging
from typing import Any, Optional, TYPE_CHECKING
from .metadata import build_node_type
from .node import WorkflowNode
if TYPE_CHECKING:
from langbot.pkg.discover.engine import ComponentDiscoveryEngine
logger = logging.getLogger(__name__)
class NodeConflictError(Exception):
"""Raised when two workflow node metadata definitions conflict."""
class NodeTypeRegistry:
"""
Central registry for workflow node types.
YAML metadata is the UI-facing source of truth. Python node classes are
registered separately and provide execution logic only.
"""
_instance: Optional['NodeTypeRegistry'] = None
def __init__(self):
self._nodes: dict[str, type[WorkflowNode]] = {}
self._metadata: dict[str, dict[str, Any]] = {}
self._metadata_sources: dict[str, str] = {}
self._categories: dict[str, list[str]] = {
'trigger': [],
'process': [],
'control': [],
'action': [],
'integration': [],
'misc': [],
}
self._conflicts: list[dict[str, str]] = []
@classmethod
def instance(cls) -> 'NodeTypeRegistry':
"""Get singleton instance."""
if cls._instance is None:
cls._instance = cls()
return cls._instance
def register_metadata(self, metadata: dict[str, Any], source: str = 'core') -> bool:
"""Register YAML metadata for a workflow node type.
Core metadata cannot be overridden by plugin metadata. Plugin-plugin
conflicts are allowed with a warning so hot-reload/development flows can
replace plugin definitions.
"""
node_type = build_node_type(metadata)
existing_source = self._metadata_sources.get(node_type)
if existing_source:
conflict = {'type': node_type, 'existing_source': existing_source, 'new_source': source}
if existing_source == 'core' and source != 'core':
self._conflicts.append(conflict)
logger.error('Plugin source %s attempted to override core workflow node %s', source, node_type)
return False
logger.warning(
'Workflow node metadata %s from %s overrides previous source %s', node_type, source, existing_source
)
cached_metadata = copy.deepcopy(metadata)
cached_metadata['_source'] = source
self._metadata[node_type] = cached_metadata
self._metadata_sources[node_type] = source
self._add_to_category(metadata.get('category', 'misc'), node_type)
return True
def register(self, node_type: str, node_class: type[WorkflowNode]):
"""Register a Python workflow node implementation class."""
canonical_type = self._canonical_type_for_class(node_type, node_class)
self._nodes[canonical_type] = node_class
metadata = self.get_metadata(canonical_type)
if metadata:
category = metadata.get('category', getattr(node_class, 'category', 'misc'))
else:
category = getattr(node_class, 'category', 'misc')
logger.warning('Workflow node implementation %s has no YAML metadata', canonical_type)
self._add_to_category(category, canonical_type)
def unregister(self, node_type: str):
"""Unregister a Python workflow node implementation."""
canonical_type = self._resolve_registered_node_key(node_type)
if canonical_type is None:
return
node_class = self._nodes[canonical_type]
metadata = self.get_metadata(canonical_type)
category = metadata.get('category') if metadata else getattr(node_class, 'category', 'misc')
self._remove_from_category(category or 'misc', canonical_type)
del self._nodes[canonical_type]
def unregister_metadata(self, node_type: str):
"""Unregister YAML metadata for a node type, primarily for plugin unload."""
canonical_type = self._resolve_metadata_key(node_type)
if canonical_type is None:
return
metadata = self._metadata[canonical_type]
self._remove_from_category(metadata.get('category', 'misc'), canonical_type)
del self._metadata[canonical_type]
self._metadata_sources.pop(canonical_type, None)
def get(self, node_type: str) -> Optional[type[WorkflowNode]]:
"""Get node class by type. Supports both ``category.name`` and short names."""
canonical_type = self._resolve_registered_node_key(node_type)
if canonical_type:
return self._nodes[canonical_type]
return None
def get_metadata(self, node_type: str) -> Optional[dict[str, Any]]:
"""Get YAML metadata by full type or short node name."""
canonical_type = self._resolve_metadata_key(node_type)
if canonical_type:
return copy.deepcopy(self._metadata[canonical_type])
return None
def create_instance(
self, node_type: str, node_id: str, config: dict[str, Any], ap: Optional['app.Application'] = None
) -> Optional[WorkflowNode]:
"""Create a node instance. Supports both ``category.name`` and short names."""
node_class = self.get(node_type)
if node_class:
return node_class(node_id, config, ap=ap)
logger.warning('No workflow node implementation registered for type: %s', node_type)
return None
def get_merged_schema(self, node_type: str) -> Optional[dict[str, Any]]:
"""Get frontend schema from YAML metadata.
Python node classes no longer carry UI metadata. If a node class is
registered but has no YAML metadata, a minimal schema is generated
from the class attributes (category, type_name) so it still appears
in the editor.
"""
metadata = self.get_metadata(node_type)
node_class = self.get(node_type)
if metadata:
schema = self._metadata_to_schema(metadata)
if node_class:
# Supplement pipeline config reuse fields from Python class
for key in ('config_schema_source', 'config_stages'):
if not schema.get(key) and getattr(node_class, key, None):
schema[key] = getattr(node_class, key)
return schema
if node_class:
# Fallback: node has Python class but no YAML metadata
short_name = getattr(node_class, 'type_name', '') or node_type.split('.')[-1]
category = getattr(node_class, 'category', 'misc')
return {
'type': f'{category}.{short_name}',
'name': short_name,
'label': self._normalize_i18n(None, self._prettify_name(short_name)),
'description': self._normalize_i18n(None, ''),
'category': category,
'icon': '',
'color': '',
'inputs': [],
'outputs': [],
'config_schema': [],
'config_schema_source': getattr(node_class, 'config_schema_source', None),
'config_stages': getattr(node_class, 'config_stages', []),
'source': 'python-only',
}
return None
def list_all(self) -> list[dict[str, Any]]:
"""Get all registered node type schemas, including metadata-only nodes."""
node_types = self._ordered_node_types(set(self._metadata.keys()) | set(self._nodes.keys()))
return [schema for node_type in node_types if (schema := self.get_merged_schema(node_type)) is not None]
def list_by_category(self, category: str) -> list[dict[str, Any]]:
"""Get node type schemas by category."""
if category not in self._categories:
return []
return [schema for node_type in self._categories[category] if (schema := self.get_merged_schema(node_type)) is not None]
def get_categories(self) -> dict[str, list[dict[str, Any]]]:
"""Get all nodes organized by category."""
return {category: self.list_by_category(category) for category in self._categories.keys()}
def has_type(self, node_type: str) -> bool:
"""Check whether a node has metadata or an implementation registered."""
return self.get_metadata(node_type) is not None or self.get(node_type) is not None
def discover_nodes(self, discover_engine: 'ComponentDiscoveryEngine', nodes_dir: str = 'pkg/workflow/nodes/'):
"""Discover and register workflow nodes from the discovery engine.
This method uses the ComponentDiscoveryEngine to find all WorkflowNode
subclasses in the specified directory and registers them automatically,
replacing the old decorator-based registration mechanism.
Args:
discover_engine: The ComponentDiscoveryEngine instance
nodes_dir: Directory path to scan for workflow nodes
"""
node_classes = discover_engine.discover_workflow_nodes(nodes_dir)
for node_class in node_classes:
type_name = getattr(node_class, 'type_name', '')
if type_name:
self.register(type_name, node_class)
logger.debug(f'Auto-registered workflow node: {type_name}')
else:
logger.warning(f'Workflow node class {node_class.__name__} missing type_name attribute')
def count(self) -> int:
"""Get total number of node types exposed by metadata or implementation."""
return len(set(self._metadata.keys()) | set(self._nodes.keys()))
def metadata_count(self) -> int:
"""Get number of registered YAML metadata definitions."""
return len(self._metadata)
def get_conflicts(self) -> list[dict[str, str]]:
"""Return metadata registration conflicts."""
return copy.deepcopy(self._conflicts)
def clear(self):
"""Clear all registrations (for testing)."""
self._nodes.clear()
self._metadata.clear()
self._metadata_sources.clear()
self._conflicts.clear()
for category in self._categories:
self._categories[category] = []
def _canonical_type_for_class(self, node_type: str, node_class: type[WorkflowNode]) -> str:
short_name = node_type.split('.')[-1]
metadata_key = self._resolve_metadata_key(node_type) or self._resolve_metadata_key(short_name)
if metadata_key:
return metadata_key
category = getattr(node_class, 'category', 'misc')
return node_type if '.' in node_type else f'{category}.{short_name}'
def _resolve_registered_node_key(self, node_type: str) -> Optional[str]:
if node_type in self._nodes:
return node_type
short_name = node_type.split('.')[-1]
for registered_type, node_class in self._nodes.items():
if registered_type.split('.')[-1] == short_name or getattr(node_class, 'type_name', None) == short_name:
return registered_type
return None
def _resolve_metadata_key(self, node_type: str) -> Optional[str]:
if node_type in self._metadata:
return node_type
short_name = node_type.split('.')[-1]
for registered_type, metadata in self._metadata.items():
if registered_type.split('.')[-1] == short_name or metadata.get('name') == short_name:
return registered_type
return None
def _ordered_node_types(self, node_types: set[str]) -> list[str]:
ordered: list[str] = []
for category in self._categories:
for node_type in self._categories[category]:
if node_type in node_types and node_type not in ordered:
ordered.append(node_type)
for node_type in sorted(node_types):
if node_type not in ordered:
ordered.append(node_type)
return ordered
def _add_to_category(self, category: str, node_type: str) -> None:
if category not in self._categories:
self._categories[category] = []
if node_type not in self._categories[category]:
self._categories[category].append(node_type)
def _remove_from_category(self, category: str, node_type: str) -> None:
if category in self._categories and node_type in self._categories[category]:
self._categories[category].remove(node_type)
def _metadata_to_schema(self, metadata: dict[str, Any]) -> dict[str, Any]:
node_type = build_node_type(metadata)
node_name = metadata.get('name', node_type.split('.')[-1])
return {
'type': node_type,
'name': node_name,
'label': self._normalize_i18n(metadata.get('label'), self._prettify_name(node_name)),
'description': self._normalize_i18n(metadata.get('description'), ''),
'category': metadata.get('category', 'misc'),
'icon': metadata.get('icon', ''),
'color': metadata.get('color', ''),
'inputs': [self._normalize_port_item(item) for item in metadata.get('inputs', [])],
'outputs': [self._normalize_port_item(item) for item in metadata.get('outputs', [])],
'config_schema': [self._normalize_config_item(item) for item in metadata.get('config', [])],
'config_schema_source': metadata.get('config_schema_source'),
'config_stages': metadata.get('config_stages', []),
'source': metadata.get('_source', 'core'),
}
def _merge_missing_schema_fields(self, yaml_schema: dict[str, Any], python_schema: dict[str, Any]) -> dict[str, Any]:
result = copy.deepcopy(yaml_schema)
for key in ('config_schema_source', 'config_stages'):
if not result.get(key) and python_schema.get(key):
result[key] = python_schema[key]
return result
def _normalize_port_item(self, port: dict[str, Any]) -> dict[str, Any]:
item = copy.deepcopy(port)
name = item.get('name', '')
item['label'] = self._normalize_i18n(item.get('label'), self._prettify_name(name))
item['description'] = self._normalize_i18n(item.get('description'), '')
item.setdefault('type', 'any')
item.setdefault('required', True)
return item
def _normalize_config_item(self, config: dict[str, Any]) -> dict[str, Any]:
item = copy.deepcopy(config)
name = item.get('name', '')
frontend_type = self._normalize_config_type(item.get('type', 'string'))
item['id'] = item.get('id') or name
item['type'] = frontend_type
item['label'] = self._normalize_i18n(item.get('label'), self._prettify_name(name))
item['description'] = self._normalize_i18n(item.get('description'), '')
item['required'] = bool(item.get('required', False))
item['default'] = item.get('default', self._default_value_for_type(frontend_type))
if 'options' in item:
item['options'] = self._normalize_options(item.get('options'), name)
return item
def _normalize_options(self, options: Any, field_name: str) -> list[dict[str, Any]]:
if not isinstance(options, list):
return []
normalized: list[dict[str, Any]] = []
for option in options:
if isinstance(option, dict):
option_item = copy.deepcopy(option)
option_name = option_item.get('name', option_item.get('value', ''))
option_item['name'] = str(option_name)
option_item['label'] = self._normalize_i18n(option_item.get('label'), str(option_name))
normalized.append(option_item)
else:
option_name = str(option)
normalized.append({'name': option_name, 'label': self._normalize_i18n(None, option_name)})
return normalized
def _normalize_i18n(self, value: Any, fallback: str) -> dict[str, str]:
if isinstance(value, dict):
en_value = (
value.get('en_US')
or value.get('en-US')
or value.get('en')
or value.get('en_US'.replace('_', '-'))
or fallback
)
zh_value = value.get('zh_Hans') or value.get('zh-Hans') or value.get('zh-CN') or value.get('zh') or en_value
return {
'en_US': str(en_value),
'en': str(en_value),
'en-US': str(en_value),
'zh_Hans': str(zh_value),
'zh-Hans': str(zh_value),
'zh-CN': str(zh_value),
}
if isinstance(value, str) and value:
return {
'en_US': value,
'en': value,
'en-US': value,
'zh_Hans': value,
'zh-Hans': value,
'zh-CN': value,
}
return {
'en_US': fallback,
'en': fallback,
'en-US': fallback,
'zh_Hans': fallback,
'zh-Hans': fallback,
'zh-CN': fallback,
}
def _normalize_config_type(self, field_type: str) -> str:
type_map = {
'number': 'float',
'json': 'text',
'textarea': 'text',
}
return type_map.get(field_type, field_type)
def _default_value_for_type(self, field_type: str) -> Any:
if field_type == 'boolean':
return False
if field_type in {'integer', 'float'}:
return 0
if field_type in {'array[string]', 'knowledge-base-multi-selector', 'tools-selector'}:
return []
if field_type == 'model-fallback-selector':
return {'primary': '', 'fallbacks': []}
if field_type == 'prompt-editor':
return [{'role': 'system', 'content': ''}]
return ''
def _prettify_name(self, name: str) -> str:
return ' '.join(part.capitalize() for part in str(name).replace('-', '_').split('_') if part)
# Convenience functions for module-level access
def register_node(node_type: str, node_class: type[WorkflowNode]):
"""Register a node type to the global registry."""
NodeTypeRegistry.instance().register(node_type, node_class)
def get_node_class(node_type: str) -> Optional[type[WorkflowNode]]:
"""Get a node class from the global registry."""
return NodeTypeRegistry.instance().get(node_type)
def list_node_types() -> list[dict[str, Any]]:
"""List all registered node types."""
return NodeTypeRegistry.instance().list_all()

View File

@@ -0,0 +1,147 @@
"""Safe expression evaluator for workflow nodes.
Uses Python's ``ast`` module to whitelist only comparison, boolean, arithmetic,
and simple attribute / subscript access. No function calls, imports, or
arbitrary code execution.
The public API is :func:`safe_eval_with_vars` which accepts a mapping of
allowed variable names so that expressions like ``input == "hello"`` or
``data.x > 3`` work without resorting to :func:`eval`.
"""
from __future__ import annotations
import ast
import operator
from typing import Any
_SAFE_OPS = {
# Arithmetic
ast.Add: operator.add,
ast.Sub: operator.sub,
ast.Mult: operator.mul,
ast.Div: operator.truediv,
ast.FloorDiv: operator.floordiv,
ast.Mod: operator.mod,
ast.Pow: operator.pow,
# Unary
ast.USub: operator.neg,
ast.UAdd: operator.pos,
ast.Not: operator.not_,
# Comparison
ast.Eq: operator.eq,
ast.NotEq: operator.ne,
ast.Lt: operator.lt,
ast.LtE: operator.le,
ast.Gt: operator.gt,
ast.GtE: operator.ge,
ast.Is: operator.is_,
ast.IsNot: operator.is_not,
ast.In: lambda a, b: a in b,
ast.NotIn: lambda a, b: a not in b,
}
def safe_eval_with_vars(expr: str, variables: dict[str, Any] | None = None) -> Any:
"""Evaluate an expression safely with an optional variable mapping.
Supports:
- Literals (numbers, strings, booleans, None)
- Comparisons (==, !=, <, >, <=, >=, in, not in, is, is not)
- Boolean logic (and, or, not)
- Arithmetic (+, -, *, /, //, %, **)
- Ternary (x if cond else y)
- Variable references from *variables* dict (e.g. ``input``, ``data``)
- Attribute access on known variables (e.g. ``data.name``)
- Subscript access on known variables (e.g. ``data["key"]``, ``items[0]``)
Raises :class:`ValueError` on any disallowed construct (function calls,
starred expressions, walrus operator, etc.).
"""
variables = variables or {}
tree = ast.parse(expr.strip(), mode='eval')
return _eval_node(tree.body, variables)
def _eval_node(node: ast.AST, variables: dict[str, Any]) -> Any:
# Literals
if isinstance(node, ast.Constant):
return node.value
# Variable references
if isinstance(node, ast.Name):
if node.id in ('None', 'True', 'False'):
return {'None': None, 'True': True, 'False': False}[node.id]
if node.id in variables:
return variables[node.id]
raise ValueError(f'Unsupported variable reference: {node.id}')
# Attribute access: obj.attr (only on allowed variables)
if isinstance(node, ast.Attribute):
obj = _eval_node(node.value, variables)
attr = node.attr
if isinstance(obj, dict):
return obj.get(attr)
if hasattr(obj, attr):
return getattr(obj, attr)
return None
# Subscript access: obj[key] (only on allowed variables)
if isinstance(node, ast.Subscript):
obj = _eval_node(node.value, variables)
key = _eval_node(node.slice, variables)
try:
return obj[key]
except (KeyError, IndexError, TypeError):
return None
# Unary operators
if isinstance(node, ast.UnaryOp):
op_fn = _SAFE_OPS.get(type(node.op))
if op_fn is None:
raise ValueError(f'Unsupported unary op: {type(node.op).__name__}')
return op_fn(_eval_node(node.operand, variables))
# Binary operators
if isinstance(node, ast.BinOp):
op_fn = _SAFE_OPS.get(type(node.op))
if op_fn is None:
raise ValueError(f'Unsupported binary op: {type(node.op).__name__}')
return op_fn(_eval_node(node.left, variables), _eval_node(node.right, variables))
# Comparisons (chained)
if isinstance(node, ast.Compare):
left = _eval_node(node.left, variables)
for op, comparator in zip(node.ops, node.comparators):
op_fn = _SAFE_OPS.get(type(op))
if op_fn is None:
raise ValueError(f'Unsupported comparison: {type(op).__name__}')
right = _eval_node(comparator, variables)
if not op_fn(left, right):
return False
left = right
return True
# Boolean operators
if isinstance(node, ast.BoolOp):
if isinstance(node.op, ast.And):
return all(_eval_node(v, variables) for v in node.values)
if isinstance(node.op, ast.Or):
return any(_eval_node(v, variables) for v in node.values)
# Ternary
if isinstance(node, ast.IfExp):
return (
_eval_node(node.body, variables) if _eval_node(node.test, variables) else _eval_node(node.orelse, variables)
)
# Tuples / Lists (e.g. ``x in [1, 2, 3]``)
if isinstance(node, (ast.Tuple, ast.List)):
return [_eval_node(e, variables) for e in node.elts]
# Dict literals (e.g. ``{"a": 1}``)
if isinstance(node, ast.Dict):
return {_eval_node(k, variables): _eval_node(v, variables) for k, v in zip(node.keys, node.values)}
raise ValueError(f'Unsupported expression node: {type(node).__name__}')

View File

@@ -0,0 +1,185 @@
"""Variable management for workflow execution.
This module provides utilities for managing workflow variables, including:
- Reserved variable protection
- Variable namespace validation
- Variable inheritance between nodes
"""
from typing import Set, Dict, Any
# 保留变量列表 - 这些变量由系统管理,不能被用户覆盖
RESERVED_VARIABLES = {
'workflow_id',
'execution_id',
'node_id',
'timestamp',
'launcher_type',
'trigger_type',
'message_id',
'execution_status',
}
def get_reserved_variables() -> Set[str]:
"""获取保留变量列表
Returns:
保留变量名称的集合
"""
return RESERVED_VARIABLES.copy()
def validate_variable_namespace(
variables: Dict[str, Any],
namespace: str = 'workflow'
) -> bool:
"""验证变量命名空间
检查变量是否使用了保留的名称。允许不带前缀的变量,但建议使用命名空间前缀。
Args:
variables: 要验证的变量字典
namespace: 命名空间前缀(可选)
Returns:
True 如果验证通过
Raises:
ValueError: 如果变量名称与保留变量冲突
"""
reserved = get_reserved_variables()
for var_name in variables.keys():
if var_name in reserved:
raise ValueError(
f"Variable '{var_name}' is reserved and cannot be used. "
f"Reserved variables: {', '.join(sorted(reserved))}"
)
# 检查命名空间前缀(可选建议)
if namespace and not var_name.startswith(f"{namespace}_"):
# 允许不带前缀的变量,但建议使用前缀
pass
return True
def inherit_variables(
parent_variables: Dict[str, Any],
child_namespace: str
) -> Dict[str, Any]:
"""继承父节点变量到子节点
将父节点的变量继承到子节点,跳过保留变量,并添加命名空间前缀。
Args:
parent_variables: 父节点的变量字典
child_namespace: 子节点的命名空间前缀
Returns:
带有命名空间前缀的继承变量字典
"""
inherited = {}
for key, value in parent_variables.items():
# 跳过保留变量
if key in RESERVED_VARIABLES:
continue
# 添加命名空间前缀
namespaced_key = f"{child_namespace}_{key}"
inherited[namespaced_key] = value
return inherited
def merge_variables(
base_variables: Dict[str, Any],
override_variables: Dict[str, Any],
allow_reserved: bool = False
) -> Dict[str, Any]:
"""合并变量字典
将override_variables合并到base_variables中。
Args:
base_variables: 基础变量字典
override_variables: 要合并的变量字典
allow_reserved: 是否允许覆盖保留变量默认False
Returns:
合并后的变量字典
Raises:
ValueError: 如果尝试覆盖保留变量且allow_reserved=False
"""
result = base_variables.copy()
for key, value in override_variables.items():
if key in RESERVED_VARIABLES and not allow_reserved:
raise ValueError(
f"Cannot override reserved variable '{key}'. "
f"Set allow_reserved=True to override."
)
result[key] = value
return result
def extract_namespace_variables(
variables: Dict[str, Any],
namespace: str
) -> Dict[str, Any]:
"""提取特定命名空间的变量
从变量字典中提取具有特定命名空间前缀的变量。
Args:
variables: 变量字典
namespace: 命名空间前缀
Returns:
提取的变量字典(不包含命名空间前缀)
"""
prefix = f"{namespace}_"
result = {}
for key, value in variables.items():
if key.startswith(prefix):
# 移除命名空间前缀
clean_key = key[len(prefix):]
result[clean_key] = value
return result
def sanitize_variables(
variables: Dict[str, Any],
allowed_keys: Set[str] | None = None
) -> Dict[str, Any]:
"""清理变量字典
移除保留变量和不在允许列表中的变量。
Args:
variables: 要清理的变量字典
allowed_keys: 允许的键集合如果为None则允许所有非保留键
Returns:
清理后的变量字典
"""
result = {}
for key, value in variables.items():
# 跳过保留变量
if key in RESERVED_VARIABLES:
continue
# 检查允许列表
if allowed_keys is not None and key not in allowed_keys:
continue
result[key] = value
return result

View File

@@ -13,3 +13,6 @@ spec:
LLMAPIRequester:
fromDirs:
- path: pkg/provider/modelmgr/requesters/
WorkflowNode:
fromDirs:
- path: pkg/workflow/nodes/

View File

@@ -0,0 +1,79 @@
# Call Pipeline Node Configuration
name: call_pipeline
label:
en_US: Call Pipeline
zh_Hans: 调用 Pipeline
category: action
icon: Workflow
color: '#ef4444'
description:
en_US: Invoke an existing Pipeline for processing
zh_Hans: 调用现有的 Pipeline 进行处理
inputs:
- name: query
type: string
label:
en_US: Query
zh_Hans: 查询
description:
en_US: Query to send
zh_Hans: 要发送的查询
- name: context
type: object
label:
en_US: Context
zh_Hans: 上下文
description:
en_US: Context data
zh_Hans: 上下文数据
required: false
outputs:
- name: response
type: string
label:
en_US: Response
zh_Hans: 响应
description:
en_US: Pipeline response
zh_Hans: 流水线响应
- name: result
type: object
label:
en_US: Result
zh_Hans: 结果
description:
en_US: Pipeline result
zh_Hans: 流水线结果
config:
- name: pipeline_uuid
type: pipeline-selector
required: true
label:
en_US: Pipeline
zh_Hans: 流水线
description:
en_US: Pipeline to call
zh_Hans: 要调用的流水线
- name: inherit_context
type: boolean
default: true
label:
en_US: Inherit Context
zh_Hans: 继承上下文
description:
en_US: Whether to inherit context
zh_Hans: 是否继承上下文
- name: timeout
type: integer
default: 120
label:
en_US: Timeout (seconds)
zh_Hans: 超时时间(秒)
description:
en_US: Timeout in seconds
zh_Hans: 超时时间(秒)

View File

@@ -0,0 +1,79 @@
# Call Workflow Node Configuration
name: call_workflow
label:
en_US: Call Workflow
zh_Hans: 调用 工作流
category: action
icon: Workflow
color: '#8b5cf6'
description:
en_US: Invoke an existing Workflow for processing
zh_Hans: 调用现有的工作流进行处理
inputs:
- name: variables
type: object
label:
en_US: Variables
zh_Hans: 变量
description:
en_US: Variables to pass to the called workflow
zh_Hans: 传递给被调用工作流的变量
required: false
outputs:
- name: result
type: object
label:
en_US: Result
zh_Hans: 结果
description:
en_US: Workflow execution result (ExecutionContext)
zh_Hans: 工作流执行结果(执行上下文)
- name: status
type: string
label:
en_US: Status
zh_Hans: 状态
description:
en_US: Workflow execution status
zh_Hans: 工作流执行状态
- name: error
type: string
label:
en_US: Error
zh_Hans: 错误
description:
en_US: Error message if execution failed
zh_Hans: 执行失败时的错误信息
config:
- name: workflow_uuid
type: workflow-selector
required: true
label:
en_US: Workflow
zh_Hans: 工作流
description:
en_US: Workflow to call
zh_Hans: 要调用的工作流
- name: inherit_variables
type: boolean
default: true
label:
en_US: Inherit Variables
zh_Hans: 继承变量
description:
en_US: Whether to inherit current workflow variables
zh_Hans: 是否继承当前工作流的变量
- name: timeout
type: integer
default: 300
label:
en_US: Timeout (seconds)
zh_Hans: 超时时间(秒)
description:
en_US: Timeout in seconds
zh_Hans: 超时时间(秒)

View File

@@ -0,0 +1,78 @@
# Code Executor Node Configuration
# This file defines the metadata for the Code Executor workflow node
# The corresponding Python implementation is in: pkg/workflow/nodes/code_executor.py
name: code_executor
label:
en_US: Code Executor
zh_Hans: 代码执行
category: process
icon: Code
color: '#3b82f6'
description:
en_US: Execute custom code to process data
zh_Hans: 执行自定义代码处理数据
inputs:
- name: input
type: any
label:
en_US: Input
zh_Hans: 输入
description:
en_US: Input data for the code
zh_Hans: 代码的输入数据
outputs:
- name: output
type: any
label:
en_US: Output
zh_Hans: 输出
description:
en_US: Code execution result
zh_Hans: 代码执行结果
- name: logs
type: array
label:
en_US: Logs
zh_Hans: 日志
description:
en_US: Console logs from code execution
zh_Hans: 代码执行的控制台日志
config:
- name: language
type: select
required: true
default: javascript
options:
- javascript
- python
label:
en_US: Language
zh_Hans: 语言
description:
en_US: Programming language to use
zh_Hans: 要使用的编程语言
- name: code
type: textarea
required: true
default: "return input;"
label:
en_US: Code
zh_Hans: 代码
description:
en_US: Code to execute
zh_Hans: 要执行的代码
- name: timeout
type: integer
default: 5000
label:
en_US: Timeout (ms)
zh_Hans: 超时时间 (毫秒)
description:
en_US: Maximum execution time in milliseconds
zh_Hans: 最大执行时间(毫秒)

View File

@@ -0,0 +1,127 @@
# Condition Node Configuration
# This file defines the metadata for the Condition workflow node
# The corresponding Python implementation is in: pkg/workflow/nodes/condition.py
name: condition
label:
en_US: Condition
zh_Hans: 条件分支
category: control
icon: GitBranch
color: '#8b5cf6'
description:
en_US: Branch workflow based on a condition
zh_Hans: 根据条件分支工作流
inputs:
- name: input
type: any
label:
en_US: Input
zh_Hans: 输入
description:
en_US: Input data for condition evaluation
zh_Hans: 用于条件评估的输入数据
outputs:
- name: "true"
type: any
label:
en_US: True
zh_Hans:
description:
en_US: Output when condition is true
zh_Hans: 条件为真时的输出
- name: "false"
type: any
label:
en_US: False
zh_Hans:
description:
en_US: Output when condition is false
zh_Hans: 条件为假时的输出
config:
- name: condition_type
type: select
required: true
default: expression
options:
- expression
- comparison
- exists
- type_check
label:
en_US: Condition Type
zh_Hans: 条件类型
description:
en_US: Type of condition to evaluate
zh_Hans: 要评估的条件类型
- name: expression
type: string
default: ""
label:
en_US: Expression
zh_Hans: 表达式
description:
en_US: JavaScript expression that evaluates to true/false
zh_Hans: 评估为 true/false 的表达式
- name: left_value
type: string
default: "{{input}}"
label:
en_US: Left Value
zh_Hans: 左值
description:
en_US: Left side of comparison
zh_Hans: 比较的左侧
- name: operator
type: select
default: eq
options:
- eq
- neq
- gt
- gte
- lt
- lte
- contains
- starts_with
- ends_with
- matches
label:
en_US: Operator
zh_Hans: 运算符
description:
en_US: Comparison operator
zh_Hans: 比较运算符
- name: right_value
type: string
default: ""
label:
en_US: Right Value
zh_Hans: 右值
description:
en_US: Right side of comparison
zh_Hans: 比较的右侧
- name: expected_type
type: select
default: string
options:
- string
- number
- boolean
- object
- array
- "null"
label:
en_US: Expected Type
zh_Hans: 期望类型
description:
en_US: The type to check for
zh_Hans: 要检查的类型

View File

@@ -0,0 +1,122 @@
# Coze Bot Node Configuration
name: coze_bot
label:
en_US: Coze Bot
zh_Hans: Coze Bot
description:
en_US: Call Coze API bot
zh_Hans: 调用扣子 API 机器人
category: integration
icon: MessageSquare
color: '#3b82f6'
inputs:
- name: query
type: string
label:
en_US: Query
zh_Hans: 查询
description:
en_US: User input/query
zh_Hans: 用户输入/查询
required: true
- name: conversation_id
type: string
label:
en_US: Conversation ID
zh_Hans: 会话 ID
description:
en_US: Conversation ID
zh_Hans: 会话 ID
required: false
outputs:
- name: answer
type: string
label:
en_US: Answer
zh_Hans: 答案
description:
en_US: Bot response
zh_Hans: 机器人回复
- name: conversation_id
type: string
label:
en_US: Conversation ID
zh_Hans: 会话 ID
description:
en_US: Conversation ID
zh_Hans: 会话 ID
- name: success
type: boolean
label:
en_US: Success
zh_Hans: 成功
description:
en_US: Whether the call was successful
zh_Hans: 调用是否成功
config:
- name: api-key
label:
en_US: API Key
zh_Hans: API 密钥
description:
en_US: Coze API key
zh_Hans: Coze API 密钥
type: string
required: true
default: ''
- name: bot-id
label:
en_US: Bot ID
zh_Hans: 机器人 ID
description:
en_US: ID of the bot to run
zh_Hans: 要运行的机器人 ID
type: string
required: true
default: ''
- name: api-base
label:
en_US: API Base URL
zh_Hans: API 基础 URL
description:
en_US: Base URL for Coze API
zh_Hans: Coze API 基础 URL
type: string
required: true
default: 'https://api.coze.cn'
options:
- name: 'https://api.coze.cn'
label:
en_US: Coze China
zh_Hans: Coze 中国版
- name: 'https://api.coze.com'
label:
en_US: Coze Global
zh_Hans: Coze 全球版
- name: auto-save-history
label:
en_US: Auto Save History
zh_Hans: 自动保存历史
description:
en_US: Whether to automatically save conversation history
zh_Hans: 是否自动保存对话历史
type: boolean
required: false
default: true
- name: timeout
label:
en_US: Request Timeout (seconds)
zh_Hans: 请求超时(秒)
description:
en_US: Timeout in seconds for API requests
zh_Hans: API 请求超时时间(秒)
type: number
required: false
default: 120

View File

@@ -0,0 +1,85 @@
# Cron Trigger Node Configuration
# This file defines the metadata for the Cron Trigger workflow node
# The corresponding Python implementation is in: pkg/workflow/nodes/cron_trigger.py
name: cron_trigger
label:
en_US: Scheduled Trigger
zh_Hans: 定时触发
category: trigger
icon: Timer
color: '#22c55e'
description:
en_US: Trigger workflow on a scheduled time
zh_Hans: 按定时计划触发工作流
inputs: []
outputs:
- name: trigger_time
type: datetime
label:
en_US: Trigger Time
zh_Hans: 触发时间
description:
en_US: The time when the trigger fired
zh_Hans: 触发器触发的时间
- name: context
type: object
label:
en_US: Context
zh_Hans: 上下文
description:
en_US: Trigger context information
zh_Hans: 触发上下文信息
config:
- name: cron_expression
type: string
required: true
default: "0 9 * * *"
label:
en_US: Cron Expression
zh_Hans: Cron 表达式
description:
en_US: Standard cron expression
zh_Hans: 标准 Cron 表达式
- name: timezone
type: select
required: true
default: "Asia/Shanghai"
options:
- UTC
- Asia/Shanghai
- Asia/Tokyo
- America/New_York
- America/Los_Angeles
- Europe/London
- Europe/Berlin
label:
en_US: Timezone
zh_Hans: 时区
description:
en_US: Timezone for the cron schedule
zh_Hans: Cron 计划的时区
- name: description
type: string
default: ""
label:
en_US: Description
zh_Hans: 描述
description:
en_US: Description of this scheduled trigger
zh_Hans: 此定时触发器的描述
- name: enabled
type: boolean
default: true
label:
en_US: Enabled
zh_Hans: 启用
description:
en_US: Whether this scheduled trigger is active
zh_Hans: 此定时触发器是否激活

View File

@@ -0,0 +1,72 @@
# Data Transform Node Configuration
# This file defines the metadata for the Data Transform workflow node
# The corresponding Python implementation is in: pkg/workflow/nodes/data_transform.py
name: data_transform
label:
en_US: Data Transform
zh_Hans: 数据转换
category: process
icon: ArrowRightLeft
color: '#3b82f6'
description:
en_US: Transform data using templates or JSONPath
zh_Hans: 使用模板或 JSONPath 转换数据
inputs:
- name: data
type: any
required: true
label:
en_US: Data
zh_Hans: 数据
description:
en_US: Input data
zh_Hans: 输入数据
outputs:
- name: result
type: any
label:
en_US: Result
zh_Hans: 结果
description:
en_US: Transform result
zh_Hans: 转换结果
config:
- name: transform_type
type: select
required: true
default: template
options:
- template
- jsonpath
- jmespath
- expression
label:
en_US: Transform Type
zh_Hans: 转换类型
description:
en_US: Type of transformation to perform
zh_Hans: 要执行的转换类型
- name: template
type: textarea
default: ""
label:
en_US: Template
zh_Hans: 模板
description:
en_US: Template with {{variable}} syntax
zh_Hans: 支持 {{variable}} 语法的模板
- name: expression
type: string
default: ""
label:
en_US: Expression
zh_Hans: 表达式
description:
en_US: JSONPath/JMESPath expression
zh_Hans: JSONPath/JMESPath 表达式

View File

@@ -0,0 +1,115 @@
# Database Query Node Configuration
# This file defines the metadata for the Database Query workflow node
# The corresponding Python implementation is in: pkg/workflow/nodes/database_query.py
name: database_query
label:
en_US: Database Query
zh_Hans: 数据库查询
category: integration
icon: Database
color: '#ec4899'
description:
en_US: Execute database queries
zh_Hans: 执行数据库查询
inputs:
- name: parameters
type: object
required: false
label:
en_US: Parameters
zh_Hans: 参数
description:
en_US: Query parameters
zh_Hans: 查询参数
outputs:
- name: results
type: array
label:
en_US: Results
zh_Hans: 结果
description:
en_US: Query results
zh_Hans: 查询结果
- name: row_count
type: number
label:
en_US: Row Count
zh_Hans: 行数
description:
en_US: Number of rows affected/returned
zh_Hans: 影响/返回的行数
- name: success
type: boolean
label:
en_US: Success
zh_Hans: 成功
description:
en_US: Whether query was successful
zh_Hans: 查询是否成功
config:
- name: connection_type
type: select
required: true
default: postgresql
options:
- postgresql
- mysql
- sqlite
label:
en_US: Database Type
zh_Hans: 数据库类型
description:
en_US: Type of database to connect to
zh_Hans: 要连接的数据库类型
- name: connection_string
type: string
required: true
default: ""
label:
en_US: Connection String
zh_Hans: 连接字符串
description:
en_US: Database connection string
zh_Hans: 数据库连接字符串
- name: query
type: textarea
required: true
default: ""
label:
en_US: SQL Query
zh_Hans: SQL 查询
description:
en_US: SQL query to execute
zh_Hans: 要执行的 SQL 查询
- name: query_type
type: select
required: true
default: select
options:
- select
- insert
- update
- delete
label:
en_US: Query Type
zh_Hans: 查询类型
description:
en_US: Type of query operation
zh_Hans: 查询操作的类型
- name: timeout
type: integer
default: 30
label:
en_US: Timeout (seconds)
zh_Hans: 超时时间(秒)
description:
en_US: Query timeout
zh_Hans: 查询超时时间

View File

@@ -0,0 +1,74 @@
# Dify Knowledge Base Query Node Configuration
name: dify_knowledge_query
label:
en_US: Dify Knowledge Base
zh_Hans: Dify 知识库
description:
en_US: Query Dify knowledge base
zh_Hans: 查询 Dify 知识库
category: integration
icon: BookOpen
color: '#3b82f6'
inputs:
- name: query
type: string
label:
en_US: Query
zh_Hans: 查询
description:
en_US: Query content
zh_Hans: 查询内容
required: true
outputs:
- name: results
type: array
label:
en_US: Results
zh_Hans: 结果
description:
en_US: Search results
zh_Hans: 检索结果
- name: success
type: boolean
label:
en_US: Success
zh_Hans: 成功
description:
en_US: Whether the call was successful
zh_Hans: 调用是否成功
config:
- name: base-url
label:
en_US: Base URL
zh_Hans: 基础 URL
description:
en_US: Dify service base URL
zh_Hans: Dify 服务基础 URL
type: string
required: true
default: 'https://api.dify.ai/v1'
- name: api-key
label:
en_US: API Key
zh_Hans: API 密钥
description:
en_US: Dify API key
zh_Hans: Dify API 密钥
type: string
required: true
default: ''
- name: dataset_id
label:
en_US: Dataset ID
zh_Hans: 知识库 ID
description:
en_US: Knowledge base ID
zh_Hans: 知识库 ID
type: string
required: true
default: ''

View File

@@ -0,0 +1,120 @@
# Dify Workflow Node Configuration
name: dify_workflow
label:
en_US: Dify Workflow
zh_Hans: Dify 工作流
description:
en_US: Call Dify service API
zh_Hans: 调用 Dify 服务 API
category: integration
icon: Bot
color: '#3b82f6'
inputs:
- name: query
type: string
label:
en_US: Query
zh_Hans: 查询
description:
en_US: User input/query
zh_Hans: 用户输入/查询
required: true
- name: conversation_id
type: string
label:
en_US: Conversation ID
zh_Hans: 会话 ID
description:
en_US: Conversation ID for multi-turn dialogue
zh_Hans: 多轮对话的会话 ID
required: false
outputs:
- name: answer
type: string
label:
en_US: Answer
zh_Hans: 答案
description:
en_US: Answer from Dify
zh_Hans: Dify 返回的答案
- name: conversation_id
type: string
label:
en_US: Conversation ID
zh_Hans: 会话 ID
description:
en_US: Conversation ID
zh_Hans: 会话 ID
- name: success
type: boolean
label:
en_US: Success
zh_Hans: 成功
description:
en_US: Whether the call was successful
zh_Hans: 调用是否成功
config:
- name: base-url
label:
en_US: Base URL
zh_Hans: 基础 URL
description:
en_US: Dify service base URL
zh_Hans: Dify 服务基础 URL
type: string
required: true
default: 'https://api.dify.ai/v1'
options:
- name: 'https://api.dify.ai/v1'
label:
en_US: Dify Cloud
zh_Hans: Dify 云服务
- name: base-prompt
label:
en_US: Base PROMPT
zh_Hans: 基础提示词
description:
en_US: When Dify receives a message with empty input (only images), it will pass this default prompt into it.
zh_Hans: 当 Dify 接收到输入文字为空(仅图片)的消息时,传入该默认提示词
type: string
required: true
default: "When the file content is readable, please read the content of this file. When the file is an image, describe the content of this image."
- name: app-type
label:
en_US: App Type
zh_Hans: 应用类型
description:
en_US: Application type
zh_Hans: 应用类型
type: select
required: true
default: chat
options:
- name: chat
label:
en_US: Chat (including Chatflow)
zh_Hans: 聊天包括Chatflow
- name: agent
label:
en_US: Agent
zh_Hans: Agent
- name: workflow
label:
en_US: Workflow
zh_Hans: 工作流
- name: api-key
label:
en_US: API Key
zh_Hans: API 密钥
description:
en_US: Dify API key
zh_Hans: Dify API 密钥
type: string
required: true
default: ''

View File

@@ -0,0 +1,53 @@
# End Node Configuration
# This file defines the metadata for the End workflow node
# The corresponding Python implementation is in: pkg/workflow/nodes/end.py
name: end
label:
en_US: End
zh_Hans: 结束
category: control
icon: PauseCircle
color: '#8b5cf6'
description:
en_US: End the workflow execution
zh_Hans: 结束工作流执行
inputs:
- name: input
type: any
required: false
label:
en_US: Input
zh_Hans: 输入
description:
en_US: Final output data
zh_Hans: 最终输出数据
outputs: []
config:
- name: status
type: select
required: true
default: success
options:
- success
- failed
- cancelled
label:
en_US: End Status
zh_Hans: 结束状态
description:
en_US: Status to report when workflow ends
zh_Hans: 工作流结束时报告的状态
- name: message
type: string
default: ""
label:
en_US: Message
zh_Hans: 消息
description:
en_US: Optional message to include with the end status
zh_Hans: 与结束状态一起包含的可选消息

View File

@@ -0,0 +1,89 @@
# Event Trigger Node Configuration
# This file defines the metadata for the Event Trigger workflow node
# The corresponding Python implementation is in: pkg/workflow/nodes/event_trigger.py
name: event_trigger
label:
en_US: Event Trigger
zh_Hans: 事件触发
category: trigger
icon: Bell
color: '#22c55e'
description:
en_US: Trigger workflow when a system event occurs
zh_Hans: 当系统事件发生时触发工作流
inputs: []
outputs:
- name: event
type: object
label:
en_US: Event
zh_Hans: 事件
description:
en_US: The event data
zh_Hans: 事件数据
- name: event_type
type: string
label:
en_US: Event Type
zh_Hans: 事件类型
description:
en_US: Type of the event
zh_Hans: 事件类型
- name: context
type: object
label:
en_US: Context
zh_Hans: 上下文
description:
en_US: Event context information
zh_Hans: 事件上下文信息
config:
- name: event_type
type: select
required: true
default: member_join
options:
- member_join
- member_leave
- message_recall
- group_created
- group_disbanded
- bot_added
- bot_removed
- friend_request
- group_request
label:
en_US: Event Type
zh_Hans: 事件类型
description:
en_US: The type of system event to listen for
zh_Hans: 要监听的系统事件类型
- name: source_filter
type: select
required: true
default: all
options:
- all
- group
- private
label:
en_US: Source Filter
zh_Hans: 来源筛选
description:
en_US: Filter events by source
zh_Hans: 按来源筛选事件
- name: platforms
type: json
default: []
label:
en_US: Platform Filter
zh_Hans: 平台筛选
description:
en_US: Only trigger for events from these platforms
zh_Hans: 仅对来自这些平台的事件触发

View File

@@ -0,0 +1,162 @@
# HTTP Request Node Configuration
# This file defines the metadata for the HTTP Request workflow node
# The corresponding Python implementation is in: pkg/workflow/nodes/http_request.py
name: http_request
label:
en_US: HTTP Request
zh_Hans: HTTP 请求
category: action
icon: Globe
color: '#10b981'
description:
en_US: Make HTTP requests to external APIs
zh_Hans: 向外部 API 发送 HTTP 请求
inputs:
- name: body
type: any
required: false
label:
en_US: Body
zh_Hans: 请求体
description:
en_US: Request body data
zh_Hans: 请求体数据
- name: variables
type: object
required: false
label:
en_US: Variables
zh_Hans: 变量
description:
en_US: Variables for URL/header templates
zh_Hans: URL/请求头模板的变量
outputs:
- name: response
type: any
label:
en_US: Response
zh_Hans: 响应
description:
en_US: Response body
zh_Hans: 响应体
- name: status_code
type: number
label:
en_US: Status Code
zh_Hans: 状态码
description:
en_US: HTTP status code
zh_Hans: HTTP 状态码
- name: headers
type: object
label:
en_US: Headers
zh_Hans: 响应头
description:
en_US: Response headers
zh_Hans: 响应头
- name: success
type: boolean
label:
en_US: Success
zh_Hans: 成功
description:
en_US: Whether request was successful
zh_Hans: 请求是否成功
config:
- name: method
type: select
required: true
default: GET
options:
- GET
- POST
- PUT
- PATCH
- DELETE
label:
en_US: Method
zh_Hans: 方法
description:
en_US: HTTP method
zh_Hans: HTTP 方法
- name: url
type: string
required: true
default: ""
label:
en_US: URL
zh_Hans: URL
description:
en_US: Request URL
zh_Hans: 请求 URL
- name: headers
type: json
default: "{}"
label:
en_US: Headers
zh_Hans: 请求头
description:
en_US: Request headers as JSON
zh_Hans: 请求头JSON 格式)
- name: body_type
type: select
default: json
options:
- none
- json
- form
- raw
label:
en_US: Body Type
zh_Hans: 请求体类型
description:
en_US: Type of request body
zh_Hans: 请求体的类型
- name: body_template
type: textarea
default: ""
label:
en_US: Body Template
zh_Hans: 请求体模板
description:
en_US: Request body template
zh_Hans: 请求体模板
- name: timeout
type: integer
default: 30
label:
en_US: Timeout (seconds)
zh_Hans: 超时时间(秒)
description:
en_US: Request timeout in seconds
zh_Hans: 请求超时时间(秒)
- name: retry_count
type: integer
default: 0
label:
en_US: Retry Count
zh_Hans: 重试次数
description:
en_US: Number of retries on failure
zh_Hans: 失败时的重试次数
- name: ignore_ssl
type: boolean
default: false
label:
en_US: Ignore SSL Errors
zh_Hans: 忽略 SSL 错误
description:
en_US: Ignore SSL certificate verification errors
zh_Hans: 忽略 SSL 证书验证错误

View File

@@ -0,0 +1,80 @@
# Iterator Node Configuration
name: iterator
label:
en_US: Iterator
zh_Hans: 迭代器
category: control
icon: Repeat
color: '#f59e0b'
description:
en_US: Iterate over array elements one by one
zh_Hans: 逐个遍历数组元素
inputs:
- name: array
type: array
label:
en_US: Array
zh_Hans: 数组
description:
en_US: Array to iterate
zh_Hans: 要迭代的数组
outputs:
- name: item
type: any
label:
en_US: Item
zh_Hans: 项目
description:
en_US: Current item
zh_Hans: 当前项目
- name: index
type: integer
label:
en_US: Index
zh_Hans: 索引
description:
en_US: Current index
zh_Hans: 当前索引
- name: is_first
type: boolean
label:
en_US: Is First
zh_Hans: 是否第一个
description:
en_US: Whether this is the first item
zh_Hans: 是否是第一个项目
- name: is_last
type: boolean
label:
en_US: Is Last
zh_Hans: 是否最后一个
description:
en_US: Whether this is the last item
zh_Hans: 是否是最后一个项目
config:
- name: parallel
type: boolean
default: false
label:
en_US: Parallel
zh_Hans: 并行
description:
en_US: Whether to process in parallel
zh_Hans: 是否并行处理
- name: max_concurrency
type: integer
default: 5
min_value: 1
max_value: 100
label:
en_US: Max Concurrency
zh_Hans: 最大并发数
description:
en_US: Maximum concurrent tasks
zh_Hans: 最大并发任务数
show_if:
parallel: true

View File

@@ -0,0 +1,118 @@
# Knowledge Retrieval Node Configuration
# This file defines the metadata for the Knowledge Retrieval workflow node
# The corresponding Python implementation is in: pkg/workflow/nodes/knowledge_retrieval.py
name: knowledge_retrieval
label:
en_US: Knowledge Retrieval
zh_Hans: 知识库检索
category: process
icon: Search
color: '#8b5cf6'
description:
en_US: Retrieve relevant information from knowledge bases
zh_Hans: 从知识库中检索相关信息
inputs:
- name: query
type: string
label:
en_US: Query
zh_Hans: 查询
description:
en_US: Query text to search for
zh_Hans: 要搜索的查询文本
outputs:
- name: results
type: array
label:
en_US: Results
zh_Hans: 结果
description:
en_US: Retrieved documents/chunks
zh_Hans: 检索到的文档/块
- name: context
type: string
label:
en_US: Context
zh_Hans: 上下文
description:
en_US: Concatenated text from all results
zh_Hans: 所有结果的连接文本
- name: scores
type: array
label:
en_US: Scores
zh_Hans: 分数
description:
en_US: Similarity scores for each result
zh_Hans: 每个结果的相似度分数
config:
- name: knowledge_bases
type: knowledge-base-multi-selector
required: true
default: []
label:
en_US: Knowledge Bases
zh_Hans: 知识库
description:
en_US: Select knowledge bases to search
zh_Hans: 选择要搜索的知识库
- name: top_k
type: integer
default: 5
label:
en_US: Top K Results
zh_Hans: 返回数量 (Top K)
description:
en_US: Number of top results to retrieve
zh_Hans: 返回的最相关结果数量
- name: similarity_threshold
type: number
default: 0.5
label:
en_US: Similarity Threshold
zh_Hans: 相似度阈值
description:
en_US: Minimum similarity score for results
zh_Hans: 结果的最小相似度分数
- name: retrieval_mode
type: select
default: vector
options:
- vector
- hybrid
- keyword
label:
en_US: Retrieval Mode
zh_Hans: 检索模式
description:
en_US: Method used for retrieving documents
zh_Hans: 用于检索文档的方法
- name: rerank_enabled
type: boolean
default: false
label:
en_US: Enable Reranking
zh_Hans: 启用重排序
description:
en_US: Use a reranking model to improve result relevance
zh_Hans: 使用重排序模型提高结果相关性
- name: rerank_model
type: rerank-model-selector
default: ""
label:
en_US: Rerank Model
zh_Hans: 重排序模型
description:
en_US: Model to use for reranking results
zh_Hans: 用于结果重排序的模型
show_if:
rerank_enabled: true

View File

@@ -0,0 +1,107 @@
# Langflow Flow Node Configuration
name: langflow_flow
label:
en_US: Langflow Flow
zh_Hans: Langflow 流程
description:
en_US: Call Langflow API
zh_Hans: 调用 Langflow API
category: integration
icon: GitBranch
color: '#3b82f6'
inputs:
- name: input_value
type: string
label:
en_US: Input Value
zh_Hans: 输入内容
description:
en_US: Input content
zh_Hans: 输入内容
required: true
outputs:
- name: result
type: any
label:
en_US: Result
zh_Hans: 结果
description:
en_US: Flow execution result
zh_Hans: 流程执行结果
- name: success
type: boolean
label:
en_US: Success
zh_Hans: 成功
description:
en_US: Whether the call was successful
zh_Hans: 调用是否成功
config:
- name: base-url
label:
en_US: Base URL
zh_Hans: 基础 URL
description:
en_US: Langflow server base URL
zh_Hans: Langflow 服务器基础 URL
type: string
required: true
default: 'http://localhost:7860'
- name: api-key
label:
en_US: API Key
zh_Hans: API 密钥
description:
en_US: Langflow API key
zh_Hans: Langflow API 密钥
type: string
required: true
default: ''
- name: flow-id
label:
en_US: Flow ID
zh_Hans: 流程 ID
description:
en_US: Flow ID to run
zh_Hans: 要运行的流程 ID
type: string
required: true
default: ''
- name: input-type
label:
en_US: Input Type
zh_Hans: 输入类型
description:
en_US: Input type for the flow
zh_Hans: 流程的输入类型
type: string
required: false
default: chat
- name: output-type
label:
en_US: Output Type
zh_Hans: 输出类型
description:
en_US: Output type for the flow
zh_Hans: 流程的输出类型
type: string
required: false
default: chat
- name: tweaks
label:
en_US: Tweaks
zh_Hans: 调整参数
description:
en_US: Optional tweaks to apply to the flow
zh_Hans: 可选的流程调整参数
type: json
required: false
default: '{}'

View File

@@ -0,0 +1,230 @@
# LLM Call Node Configuration
# This file defines the metadata for the LLM Call workflow node
# The corresponding Python implementation is in: pkg/workflow/nodes/llm_call.py
name: llm_call
label:
en_US: LLM Call
zh_Hans: LLM 调用
category: process
icon: Brain
color: '#8b5cf6'
description:
en_US: Call a large language model to generate responses
zh_Hans: 调用大语言模型生成响应
inputs:
- name: input
type: string
required: false
label:
en_US: Input
zh_Hans: 输入
description:
en_US: Input text to send to the model
zh_Hans: 发送到模型的输入文本
- name: context
type: object
required: false
label:
en_US: Context
zh_Hans: 上下文
description:
en_US: Additional context data
zh_Hans: 额外的上下文数据
outputs:
- name: response
type: string
label:
en_US: Response
zh_Hans: 响应
description:
en_US: Model response text
zh_Hans: 模型响应文本
- name: usage
type: object
label:
en_US: Usage
zh_Hans: 使用量
description:
en_US: Token usage information
zh_Hans: Token 使用量信息
- name: parsed
type: object
label:
en_US: Parsed
zh_Hans: 解析结果
description:
en_US: Parsed output (if output format is JSON)
zh_Hans: 解析后的输出(如果输出格式为 JSON
config:
- name: model_config
type: model-fallback-selector
required: true
default:
primary: ''
fallbacks: []
label:
en_US: Model Configuration
zh_Hans: 模型配置
description:
en_US: Configure the primary model and optional fallback models
zh_Hans: 配置主模型和可选的备用模型
- name: prompt
label:
en_US: Prompt
zh_Hans: 提示词
description:
en_US: Unless you understand the message structure, please only use a single system prompt
zh_Hans: 除非您了解消息结构,否则请只使用 system 单提示词
type: prompt-editor
required: true
default:
- role: system
content: "You are a helpful assistant."
- name: max_round
type: integer
required: false
default: 10
label:
en_US: Max Round
zh_Hans: 最大回合数
description:
en_US: The maximum number of previous messages that the agent can remember
zh_Hans: 最大前文消息回合数
- name: knowledge_bases
type: knowledge-base-multi-selector
required: false
default: []
label:
en_US: Knowledge Bases
zh_Hans: 知识库
description:
en_US: Configure the knowledge bases to use for the agent, if not selected, the agent will directly use the LLM to reply
zh_Hans: 配置用于提升回复质量的知识库,若不选择,则直接使用大模型回复
- name: rerank_model
type: rerank-model-selector
required: false
default: ''
label:
en_US: Rerank Model
zh_Hans: 重排序模型
description:
en_US: Optional rerank model to improve retrieval quality by re-scoring retrieved chunks
zh_Hans: 可选的重排序模型,通过重新评分检索结果来提升检索质量
- name: rerank_top_k
type: integer
required: false
default: 5
label:
en_US: Rerank Top K
zh_Hans: 重排序保留数量
description:
en_US: Number of top results to keep after reranking
zh_Hans: 重排序后保留的最相关结果数量
- name: temperature
type: number
default: 0.7
label:
en_US: Temperature
zh_Hans: 温度
description:
en_US: Controls randomness in responses
zh_Hans: 控制响应的随机性
- name: max_tokens
type: integer
default: 0
label:
en_US: Max Tokens
zh_Hans: 最大令牌数
description:
en_US: Maximum number of tokens to generate
zh_Hans: 生成的最大令牌数
- name: output_format
type: select
default: text
options:
- text
- json
- markdown
label:
en_US: Output Format
zh_Hans: 输出格式
description:
en_US: Expected format of the model output
zh_Hans: 模型输出的预期格式
- name: json_schema
type: textarea
default: ""
label:
en_US: JSON Schema
zh_Hans: JSON Schema
description:
en_US: JSON schema for structured output validation
zh_Hans: 用于结构化输出验证的 JSON Schema
- name: exception_handling
type: select
required: true
default: show-hint
options:
- name: show-error
label:
en_US: Show Full Error
zh_Hans: 显示完整报错信息
- name: show-hint
label:
en_US: Show Failure Hint
zh_Hans: 仅文字提示
- name: hide
label:
en_US: Hide All
zh_Hans: 不显示任何异常信息
label:
en_US: Exception Handling Strategy
zh_Hans: 异常处理策略
description:
en_US: Controls how error messages are displayed to the user when an AI request fails
zh_Hans: 控制 AI 请求失败时向用户展示错误信息的方式
- name: failure_hint
type: string
required: false
default: 'Request failed.'
label:
en_US: Failure Hint Text
zh_Hans: 失败提示文本
description:
en_US: The text to display when a request fails. Only effective when Exception Handling Strategy is set to "Show Failure Hint"
zh_Hans: 请求失败时显示的提示文本,仅在异常处理策略设置为"仅文字提示"时生效
- name: remove_think
type: boolean
default: false
label:
en_US: Remove CoT
zh_Hans: 删除思维链
description:
en_US: 'If enabled, the model thinking content in the response will be automatically removed. Note: When using streaming response, removing CoT may cause the first token to wait for a long time.'
zh_Hans: '如果启用,将自动删除大模型回复中的模型思考内容。注意:当您使用流式响应时,删除思维链可能会导致首个 Token 的等待时间过长'
- name: track_function_calls
type: boolean
default: false
label:
en_US: Track Function Calls
zh_Hans: 跟踪函数调用
description:
en_US: If enabled, the Agent will output a hint to the user each time a tool is called
zh_Hans: 启用后Agent 每次调用工具时都会输出一个提示给用户

View File

@@ -0,0 +1,117 @@
# Loop Node Configuration
# This file defines the metadata for the Loop workflow node
# The corresponding Python implementation is in: pkg/workflow/nodes/loop.py
name: loop
label:
en_US: Loop
zh_Hans: 循环
category: control
icon: Repeat
color: '#8b5cf6'
description:
en_US: Iterate over items or repeat until condition
zh_Hans: 遍历项目或重复直到满足条件
inputs:
- name: items
type: array
required: false
label:
en_US: Items
zh_Hans: 项目
description:
en_US: Items to iterate over
zh_Hans: 要遍历的项目
outputs:
- name: item
type: any
label:
en_US: Item
zh_Hans: 当前项
description:
en_US: Current item in iteration
zh_Hans: 迭代中的当前项
- name: index
type: number
label:
en_US: Index
zh_Hans: 索引
description:
en_US: Current iteration index
zh_Hans: 当前迭代索引
- name: completed
type: any
label:
en_US: Completed
zh_Hans: 完成
description:
en_US: Output after loop completes
zh_Hans: 循环完成后的输出
config:
- name: loop_type
type: select
required: true
default: foreach
options:
- foreach
- while
- count
label:
en_US: Loop Type
zh_Hans: 循环类型
description:
en_US: Type of loop to execute
zh_Hans: 要执行的循环类型
- name: max_iterations
type: integer
default: 100
label:
en_US: Max Iterations
zh_Hans: 最大迭代次数
description:
en_US: Maximum number of iterations
zh_Hans: 最大迭代次数
- name: count
type: integer
default: 10
label:
en_US: Count
zh_Hans: 计数
description:
en_US: Number of times to iterate
zh_Hans: 迭代次数
- name: while_condition
type: string
default: ""
label:
en_US: While Condition
zh_Hans: While 条件
description:
en_US: Condition expression to continue looping
zh_Hans: 继续循环的条件表达式
- name: parallel
type: boolean
default: false
label:
en_US: Parallel Execution
zh_Hans: 并行执行
description:
en_US: Execute iterations in parallel
zh_Hans: 并行执行迭代
- name: parallel_limit
type: integer
default: 5
label:
en_US: Parallel Limit
zh_Hans: 并行限制
description:
en_US: Maximum number of parallel executions
zh_Hans: 最大并行执行数

View File

@@ -0,0 +1,88 @@
# Mcp Tool Node Configuration
# This file defines the metadata for the Mcp Tool workflow node
# The corresponding Python implementation is in: pkg/workflow/nodes/mcp_tool.py
name: mcp_tool
label:
en_US: MCP Tool
zh_Hans: MCP 工具
category: integration
icon: Wrench
color: '#ec4899'
description:
en_US: Invoke an MCP (Model Context Protocol) tool
zh_Hans: 调用 MCP 工具
inputs:
- name: arguments
type: object
required: false
label:
en_US: Arguments
zh_Hans: 参数
description:
en_US: Tool arguments
zh_Hans: 工具参数
outputs:
- name: result
type: any
label:
en_US: Result
zh_Hans: 结果
description:
en_US: Tool execution result
zh_Hans: 工具执行结果
- name: success
type: boolean
label:
en_US: Success
zh_Hans: 成功
description:
en_US: Whether tool call was successful
zh_Hans: 工具调用是否成功
- name: error
type: string
label:
en_US: Error
zh_Hans: 错误
description:
en_US: Error message if failed
zh_Hans: 失败时的错误信息
config:
- name: server_name
type: string
required: true
default: ''
label:
en_US: MCP Server
zh_Hans: MCP 服务器
description:
en_US: Name of the MCP server
zh_Hans: MCP 服务器名称
- name: tool_name
type: string
required: true
default: ''
label:
en_US: Tool Name
zh_Hans: 工具名称
description:
en_US: Name of the MCP tool to invoke
zh_Hans: 要调用的 MCP 工具名称
- name: arguments_template
type: textarea
default: ''
label:
en_US: Arguments Template
zh_Hans: 参数模板
description:
en_US: Tool arguments as JSON
zh_Hans: 工具参数JSON 格式)
- name: timeout
type: integer
default: 30
label:
en_US: Timeout (seconds)
zh_Hans: 超时时间(秒)
description:
en_US: Maximum execution time
zh_Hans: 最大执行时间

View File

@@ -0,0 +1,99 @@
# Memory Store Node Configuration
# This file defines the metadata for the Memory Store workflow node
# The corresponding Python implementation is in: pkg/workflow/nodes/memory_store.py
name: memory_store
label:
en_US: Memory Store
zh_Hans: 记忆存储
category: integration
icon: HardDrive
color: '#ec4899'
description:
en_US: Store and retrieve data from workflow memory
zh_Hans: 从工作流记忆中存储和检索数据
inputs:
- name: value
type: any
required: false
label:
en_US: Value
zh_Hans:
description:
en_US: Value to store
zh_Hans: 要存储的值
outputs:
- name: result
type: any
label:
en_US: Result
zh_Hans: 结果
description:
en_US: Retrieved or stored value
zh_Hans: 检索到的或存储的值
- name: success
type: boolean
label:
en_US: Success
zh_Hans: 成功
description:
en_US: Whether operation was successful
zh_Hans: 操作是否成功
config:
- name: operation
type: select
required: true
default: get
options:
- get
- set
- delete
- append
- list
label:
en_US: Operation
zh_Hans: 操作
description:
en_US: Memory operation to perform
zh_Hans: 要执行的记忆操作
- name: key
type: string
required: true
default: ""
label:
en_US: Key
zh_Hans:
description:
en_US: Memory key
zh_Hans: 记忆键
- name: scope
type: select
required: true
default: execution
options:
- execution
- workflow
- session
- user
- global
label:
en_US: Scope
zh_Hans: 作用域
description:
en_US: Scope of the memory storage
zh_Hans: 记忆存储的作用域
- name: ttl
type: integer
default: 0
label:
en_US: TTL (seconds)
zh_Hans: TTL
description:
en_US: Time to live (0 = no expiry)
zh_Hans: 过期时间0 = 不过期)

View File

@@ -0,0 +1,77 @@
# Merge Node Configuration
name: merge
label:
en_US: Merge
zh_Hans: 合并
category: control
icon: GitMerge
color: '#f59e0b'
description:
en_US: Merge multiple branches back together
zh_Hans: 将多个分支合并在一起
inputs:
- name: input_1
type: any
label:
en_US: Input 1
zh_Hans: 输入 1
description:
en_US: First input
zh_Hans: 第一个输入
- name: input_2
type: any
label:
en_US: Input 2
zh_Hans: 输入 2
description:
en_US: Second input
zh_Hans: 第二个输入
- name: input_3
type: any
label:
en_US: Input 3
zh_Hans: 输入 3
description:
en_US: Third input
zh_Hans: 第三个输入
required: false
- name: input_4
type: any
label:
en_US: Input 4
zh_Hans: 输入 4
description:
en_US: Fourth input
zh_Hans: 第四个输入
required: false
outputs:
- name: merged
type: object
label:
en_US: Merged
zh_Hans: 合并结果
description:
en_US: Merged result
zh_Hans: 合并后的结果
- name: array
type: array
label:
en_US: Array
zh_Hans: 数组
description:
en_US: All inputs as array
zh_Hans: 所有输入作为数组
config:
- name: merge_strategy
type: select
options: ["object", "array", "first_non_null", "concat"]
default: "object"
label:
en_US: Merge Strategy
zh_Hans: 合并策略
description:
en_US: Strategy for merging inputs
zh_Hans: 合并输入的策略

View File

@@ -0,0 +1,105 @@
# Message Trigger Node Configuration
# This file defines the metadata for the Message Trigger workflow node
# The corresponding Python implementation is in: pkg/workflow/nodes/message_trigger.py
name: message_trigger
label:
en_US: Message Trigger
zh_Hans: 消息触发
category: trigger
icon: MessageSquare
color: '#22c55e'
description:
en_US: Trigger workflow when a message is received
zh_Hans: 当收到消息时触发工作流
inputs: []
outputs:
- name: message
type: object
label:
en_US: Message
zh_Hans: 消息
description:
en_US: The received message object
zh_Hans: 接收到的消息对象
- name: sender
type: object
label:
en_US: Sender
zh_Hans: 发送者
description:
en_US: Message sender information
zh_Hans: 消息发送者信息
- name: context
type: object
label:
en_US: Context
zh_Hans: 上下文
description:
en_US: Message context information
zh_Hans: 消息上下文信息
config:
- name: match_type
type: select
required: true
default: all
options:
- all
- prefix
- regex
- contains
- exact
label:
en_US: Match Type
zh_Hans: 匹配类型
description:
en_US: How to match the incoming message
zh_Hans: 如何匹配收到的消息
- name: match_pattern
type: string
default: ""
label:
en_US: Match Pattern
zh_Hans: 匹配模式
description:
en_US: The pattern to match against the message
zh_Hans: 用于匹配消息的模式
- name: message_source
type: select
required: true
default: all
options:
- all
- group
- private
label:
en_US: Message Source
zh_Hans: 消息来源
description:
en_US: Filter messages by source type
zh_Hans: 按来源类型筛选消息
- name: platforms
type: json
default: []
label:
en_US: Platform Filter
zh_Hans: 平台筛选
description:
en_US: Only trigger for messages from these platforms
zh_Hans: 仅对来自这些平台的消息触发
- name: ignore_bot_messages
type: boolean
default: true
label:
en_US: Ignore Bot Messages
zh_Hans: 忽略机器人消息
description:
en_US: Do not trigger for messages sent by bots
zh_Hans: 不对机器人发送的消息触发

Some files were not shown because too many files have changed in this diff Show More