后端没修完版

This commit is contained in:
Typer_Body
2026-05-05 15:08:04 +08:00
parent a8fba46040
commit e7c9bc69d3
156 changed files with 34633 additions and 2149 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

View File

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

View File

@@ -0,0 +1,253 @@
"""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:
pass
@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,351 @@
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)
# 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))
@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,7 +99,11 @@ class BotService:
# TODO: 检查配置信息格式
bot_data['uuid'] = str(uuid.uuid4())
# checkout the default pipeline
# 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).where(
persistence_pipeline.LegacyPipeline.is_default == True
@@ -109,6 +113,9 @@ class BotService:
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))
@@ -123,7 +130,11 @@ class BotService:
if 'uuid' in bot_data:
del bot_data['uuid']
# set use_pipeline_name
# Handle binding_type and binding_uuid for the new unified binding model
# If binding_type is explicitly set to 'workflow', skip pipeline validation
binding_type = bot_data.get('binding_type')
# set use_pipeline_name (for backward compatibility with 'pipeline' binding_type)
if 'use_pipeline_uuid' in bot_data:
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
@@ -133,8 +144,18 @@ class BotService:
pipeline = result.first()
if pipeline is not None:
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(bot_data).where(persistence_bot.Bot.uuid == bot_uuid)

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 ..discover import engine as discover_engine
from ..storage import mgr as storagemgr
@@ -149,6 +150,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
@@ -218,6 +221,25 @@ 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],
)
self.task_mgr.create_task(
never_ending(),
name='never-ending-task',

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 ...discover import engine as discover_engine
from ...storage import mgr as storagemgr
from ...utils import logcache
@@ -85,6 +86,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,52 @@ 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 configurations from YAML files
ap.workflow_node_configs = {}
node_config_files = [
# Trigger nodes
'metadata/nodes/message_trigger.yaml',
'metadata/nodes/cron_trigger.yaml',
'metadata/nodes/webhook_trigger.yaml',
'metadata/nodes/event_trigger.yaml',
# AI/Process nodes
'metadata/nodes/llm_call.yaml',
'metadata/nodes/question_classifier.yaml',
'metadata/nodes/parameter_extractor.yaml',
'metadata/nodes/knowledge_retrieval.yaml',
'metadata/nodes/code_executor.yaml',
'metadata/nodes/data_transform.yaml',
# Control nodes
'metadata/nodes/condition.yaml',
'metadata/nodes/switch.yaml',
'metadata/nodes/loop.yaml',
'metadata/nodes/parallel.yaml',
'metadata/nodes/wait.yaml',
'metadata/nodes/end.yaml',
# Action nodes
'metadata/nodes/send_message.yaml',
'metadata/nodes/http_request.yaml',
# Integration nodes - Data & Tools
'metadata/nodes/database_query.yaml',
'metadata/nodes/redis_operation.yaml',
'metadata/nodes/mcp_tool.yaml',
'metadata/nodes/memory_store.yaml',
# Integration nodes - External services
'metadata/nodes/dify_workflow.yaml',
'metadata/nodes/dify_knowledge_query.yaml',
'metadata/nodes/n8n_workflow.yaml',
'metadata/nodes/langflow_flow.yaml',
'metadata/nodes/coze_bot.yaml',
]
for config_file in node_config_files:
try:
node_config = await load_resource_yaml_template_data(config_file)
node_name = node_config.get('name')
node_category = node_config.get('category', 'misc')
if node_name:
# Use category.name format to match node type format (e.g., integration.coze_bot)
full_type = f'{node_category}.{node_name}'
ap.workflow_node_configs[full_type] = node_config
except Exception as e:
print(f'Failed to load node config {config_file}: {e}')

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,127 @@
"""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,141 @@
"""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:
result = 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,46 @@
"""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

@@ -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 [])
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 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
binding_type, binding_uuid = self.get_binding_info()
# If bound to workflow, return None for pipeline_uuid
# The caller should check binding_type and handle accordingly
if binding_type == 'workflow':
# For workflow binding, we still need to return something
# The actual workflow handling should be done by the caller
return None, False
return binding_uuid, False
async def _record_discarded_message(
self,

View File

@@ -369,6 +369,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)
@@ -410,6 +411,61 @@ 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
execution_id = await self.ap.workflow_service.execute_workflow(
pipeline_uuid,
trigger_type='message',
trigger_data=trigger_data,
session_id=f'{session_type}_{connection.connection_id}',
user_id=message_context['sender_id'],
bot_id=self.ap.platform_mgr.websocket_proxy_bot.bot_entity.uuid,
)
await connection.send_queue.put(
{
'type': 'broadcast',
'message': f'Workflow execution started: {execution_id}',
}
)
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

@@ -0,0 +1,53 @@
"""Workflow package for LangBot
This package provides a visual workflow system for LangBot, including:
- Workflow definition models
- Execution engine
- Node types (trigger, process, control, action, integration)
- Trigger system for automation
"""
from .entities import (
WorkflowDefinition,
NodeDefinition,
EdgeDefinition,
Position,
PortDefinition,
TriggerDefinition,
WorkflowSettings,
ExecutionContext,
NodeState,
ExecutionStatus,
NodeStatus,
)
from .node import WorkflowNode, NodePort, NodeConfig, workflow_node
from .registry import NodeTypeRegistry
from .executor import WorkflowExecutor
# Import nodes module to trigger node registration
from . import nodes as nodes
__all__ = [
# Entities
'WorkflowDefinition',
'NodeDefinition',
'EdgeDefinition',
'Position',
'PortDefinition',
'TriggerDefinition',
'WorkflowSettings',
'ExecutionContext',
'NodeState',
'ExecutionStatus',
'NodeStatus',
# Node
'WorkflowNode',
'NodePort',
'NodeConfig',
'workflow_node',
# Registry
'NodeTypeRegistry',
# Executor
'WorkflowExecutor',
]

View File

@@ -0,0 +1,278 @@
"""Workflow entities and data models"""
from __future__ import annotations
import enum
from datetime import datetime
from typing import Any, Optional
import pydantic
class Position(pydantic.BaseModel):
"""Node position on canvas"""
x: float = 0
y: float = 0
class PortDefinition(pydantic.BaseModel):
"""Node port definition"""
name: str
type: str = "any" # any, string, number, boolean, object, array
description: str = ""
required: bool = True
class NodeDefinition(pydantic.BaseModel):
"""Workflow node definition"""
id: str
type: str
name: str = ""
position: Position = Position()
config: dict[str, Any] = {}
inputs: list[PortDefinition] = []
outputs: list[PortDefinition] = []
# UI metadata
description: str = ""
comment: str = "" # User comment/annotation
class EdgeDefinition(pydantic.BaseModel):
"""Workflow edge definition (connection between nodes)"""
id: str
source_node: str
source_port: str = "output"
target_node: str
target_port: str = "input"
condition: Optional[str] = None # Optional condition expression
class TriggerDefinition(pydantic.BaseModel):
"""Workflow trigger definition"""
id: str
type: str # message, cron, event, webhook
config: dict[str, Any] = {}
enabled: bool = True
class WorkflowSettings(pydantic.BaseModel):
"""Workflow settings"""
# Execution settings
max_execution_time: int = 300 # seconds
max_retries: int = 3
retry_delay: int = 5 # seconds
# Error handling
error_handling: str = "stop" # stop, continue, retry
# Logging
log_level: str = "info"
save_execution_history: bool = True
# Concurrency
max_concurrent_executions: int = 10
class SafetyConfig(pydantic.BaseModel):
"""Safety configuration (inherited from Pipeline)"""
content_filter: dict[str, Any] = {
"enable": False,
"sensitive_words": [],
"replace_with": "***"
}
rate_limit: dict[str, Any] = {
"enable": False,
"requests_per_minute": 60,
"burst_limit": 10
}
class OutputConfig(pydantic.BaseModel):
"""Output configuration (inherited from Pipeline)"""
long_text_processing: dict[str, Any] = {
"strategy": "split", # split, truncate, file
"max_length": 4000,
"split_separator": "\n\n"
}
force_delay: dict[str, Any] = {
"enable": False,
"min_delay_ms": 0,
"max_delay_ms": 0
}
misc: dict[str, Any] = {}
class WorkflowGlobalConfig(pydantic.BaseModel):
"""Workflow global configuration (inherited from Pipeline capabilities)"""
safety: SafetyConfig = SafetyConfig()
output: OutputConfig = OutputConfig()
class ExtensionsPreferences(pydantic.BaseModel):
"""Extensions preferences (same as Pipeline)"""
enable_all_plugins: bool = True
enable_all_mcp_servers: bool = True
plugins: list[str] = []
mcp_servers: list[str] = []
class ConversationVariable(pydantic.BaseModel):
"""Conversation-level variable definition"""
name: str
type: str = "string" # string, number, boolean, object, array
description: str = ""
default_value: Any = None
max_length: Optional[int] = None # For strings
class WorkflowDefinition(pydantic.BaseModel):
"""Complete workflow definition"""
uuid: str
name: str
description: str = ""
emoji: str = "🔄"
version: int = 1
# Workflow graph
nodes: list[NodeDefinition] = []
edges: list[EdgeDefinition] = []
# Variables
variables: dict[str, Any] = {} # Global variables
conversation_variables: list[ConversationVariable] = [] # Session-level variables
# Settings
settings: WorkflowSettings = WorkflowSettings()
# Triggers (for automation)
triggers: list[TriggerDefinition] = []
# Global configuration (inherited from Pipeline)
global_config: WorkflowGlobalConfig = WorkflowGlobalConfig()
# Extensions
extensions_preferences: ExtensionsPreferences = ExtensionsPreferences()
# Metadata
is_enabled: bool = True
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
# Source tracking (for imported workflows)
source: Optional[str] = None # dify, n8n, langflow, etc.
source_id: Optional[str] = None
class ExecutionStatus(enum.Enum):
"""Workflow execution status"""
PENDING = "pending"
RUNNING = "running"
WAITING = "waiting"
COMPLETED = "completed"
FAILED = "failed"
CANCELLED = "cancelled"
class NodeStatus(enum.Enum):
"""Node execution status"""
PENDING = "pending"
RUNNING = "running"
COMPLETED = "completed"
FAILED = "failed"
SKIPPED = "skipped"
class NodeState(pydantic.BaseModel):
"""Runtime state of a node during execution"""
node_id: str
status: NodeStatus = NodeStatus.PENDING
inputs: dict[str, Any] = {}
outputs: dict[str, Any] = {}
start_time: Optional[datetime] = None
end_time: Optional[datetime] = None
error: Optional[str] = None
retry_count: int = 0
class MessageContext(pydantic.BaseModel):
"""Message context for message-triggered workflows"""
message_id: str
message_content: str
sender_id: str
sender_name: str = ""
platform: str = ""
conversation_id: str = ""
is_group: bool = False
group_id: Optional[str] = None
mentions: list[str] = []
reply_to: Optional[str] = None
raw_message: dict[str, Any] = {}
class ExecutionStep(pydantic.BaseModel):
"""Execution history step"""
timestamp: datetime
node_id: str
node_type: str
status: str
inputs: dict[str, Any] = {}
outputs: dict[str, Any] = {}
duration_ms: int = 0
error: Optional[str] = None
class ExecutionContext(pydantic.BaseModel):
"""Workflow execution context"""
execution_id: str
workflow_id: str
workflow_version: int = 1
status: ExecutionStatus = ExecutionStatus.PENDING
# Runtime data
variables: dict[str, Any] = {}
conversation_variables: dict[str, Any] = {} # Session-level persistent variables
node_states: dict[str, NodeState] = {}
memory: dict[str, Any] = {} # Workflow memory for storing/retrieving data
# Timing
start_time: Optional[datetime] = None
end_time: Optional[datetime] = None
# Error
error: Optional[str] = None
# Message context (if triggered by message)
message_context: Optional[MessageContext] = None
# Trigger info
trigger_type: Optional[str] = None
trigger_data: dict[str, Any] = {}
# Execution history
history: list[ExecutionStep] = []
# Session info
session_id: Optional[str] = None
user_id: Optional[str] = None
bot_id: Optional[str] = None
def get_node_output(self, node_id: str, output_name: str = "output") -> Any:
"""Get output from a specific node"""
if node_id in self.node_states:
return self.node_states[node_id].outputs.get(output_name)
return None
def set_variable(self, name: str, value: Any):
"""Set a workflow variable"""
self.variables[name] = value
def get_variable(self, name: str, default: Any = None) -> Any:
"""Get a workflow variable"""
return self.variables.get(name, default)
def set_conversation_variable(self, name: str, value: Any):
"""Set a conversation-level variable (persisted across executions)"""
self.conversation_variables[name] = value
def get_conversation_variable(self, name: str, default: Any = None) -> Any:
"""Get a conversation-level variable"""
return self.conversation_variables.get(name, default)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,280 @@
"""Workflow node base class and decorators"""
from __future__ import annotations
import abc
from typing import Any, Callable, Optional, TYPE_CHECKING
import pydantic
if TYPE_CHECKING:
from .entities import ExecutionContext
from ..core import app
class NodePort(pydantic.BaseModel):
"""Node port definition"""
name: str
type: str = "any" # any, string, number, boolean, object, array
description: str = ""
required: bool = True
class NodeConfig(pydantic.BaseModel):
"""Node configuration field definition"""
name: str
type: str # string, integer, number, boolean, select, json, secret, etc.
required: bool = False
default: Any = None
description: str = ""
options: Optional[list[str]] = None # For select type
# Validation
min_value: Optional[float] = None
max_value: Optional[float] = None
min_length: Optional[int] = None
max_length: Optional[int] = None
pattern: Optional[str] = None # Regex pattern
# UI hints
placeholder: str = ""
show_if: Optional[dict] = None # Conditional display
# Pipeline config source (for reusing Pipeline config metadata)
pipeline_config_source: Optional[str] = None # e.g., "pipeline:trigger"
# i18n support for label
label: Optional[dict[str, str]] = None # e.g., {"en_US": "Name", "zh_Hans": "名称"}
label_zh: Optional[str] = None # Chinese label
label_en: Optional[str] = None # English label
class WorkflowNode(abc.ABC):
"""Base class for all workflow nodes"""
# Node metadata
type_name: str = ""
name: str = ""
description: str = ""
category: str = "misc" # trigger, process, control, action, integration
icon: str = ""
# Port definitions
inputs: list[NodePort] = []
outputs: list[NodePort] = []
# Configuration schema
config_schema: list[NodeConfig] = []
# Pipeline config reuse
config_schema_source: Optional[str] = None # e.g., "pipeline:ai"
config_stages: list[str] = [] # Specific stages to reuse
def __init__(self, node_id: str, config: dict[str, Any], ap: Optional['app.Application'] = None):
"""Initialize node with ID and configuration"""
self.node_id = node_id
self.config = config
self.ap = ap # Reference to the application instance for accessing services
@abc.abstractmethod
async def execute(
self,
inputs: dict[str, Any],
context: ExecutionContext
) -> dict[str, Any]:
"""
Execute the node logic.
Args:
inputs: Input data from connected nodes
context: Execution context with workflow state
Returns:
Dictionary of output values
"""
pass
async def validate_inputs(self, inputs: dict[str, Any]) -> list[str]:
"""
Validate input data against port definitions.
Returns:
List of validation error messages (empty if valid)
"""
errors = []
for port in self.inputs:
if port.required and port.name not in inputs:
errors.append(f"Missing required input: {port.name}")
return errors
async def validate_config(self) -> list[str]:
"""
Validate node configuration.
Returns:
List of validation error messages (empty if valid)
"""
errors = []
for cfg in self.config_schema:
if cfg.required and cfg.name not in self.config:
errors.append(f"Missing required config: {cfg.name}")
elif cfg.name in self.config:
value = self.config[cfg.name]
# Type validation
if cfg.type == "integer" and not isinstance(value, int):
errors.append(f"Config {cfg.name} must be an integer")
elif cfg.type == "number" and not isinstance(value, (int, float)):
errors.append(f"Config {cfg.name} must be a number")
elif cfg.type == "boolean" and not isinstance(value, bool):
errors.append(f"Config {cfg.name} must be a boolean")
# Range validation
if cfg.min_value is not None and isinstance(value, (int, float)):
if value < cfg.min_value:
errors.append(f"Config {cfg.name} must be >= {cfg.min_value}")
if cfg.max_value is not None and isinstance(value, (int, float)):
if value > cfg.max_value:
errors.append(f"Config {cfg.name} must be <= {cfg.max_value}")
return errors
# Type mapping from backend to frontend DynamicFormItemType
_TYPE_MAP = {
'string': 'string',
'integer': 'integer',
'number': 'float',
'boolean': 'boolean',
'select': 'select',
'json': 'text',
'textarea': 'text',
'secret': 'secret',
'llm-model-selector': 'llm-model-selector',
'embedding-model-selector': 'embedding-model-selector',
'rerank-model-selector': 'rerank-model-selector',
'knowledge-base-selector': 'knowledge-base-selector',
'knowledge-base-multi-selector': 'knowledge-base-multi-selector',
'bot-selector': 'bot-selector',
'tools-selector': 'tools-selector',
'model-fallback-selector': 'model-fallback-selector',
'prompt-editor': 'prompt-editor',
}
def get_config(self, key: str, default: Any = None) -> Any:
"""Get configuration value with default"""
return self.config.get(key, default)
@classmethod
def _config_to_schema_item(cls, cfg: NodeConfig) -> dict[str, Any]:
"""Convert a NodeConfig to frontend-compatible schema item"""
# Map type to frontend type
frontend_type = cls._TYPE_MAP.get(cfg.type, 'string')
# Build i18n label from name
label = {
'zh_Hans': cfg.name,
'en_US': cfg.name,
}
# Build i18n description
desc = cfg.description or ''
description = {
'zh_Hans': desc,
'en_US': desc,
}
result = {
'id': cfg.name,
'name': cfg.name,
'type': frontend_type,
'label': label,
'description': description,
'required': cfg.required,
'default': cfg.default,
}
# Add placeholder if present
if cfg.placeholder:
result['placeholder'] = cfg.placeholder
# Add options if present
if cfg.options:
result['options'] = [
{
'name': opt,
'label': {
'zh_Hans': opt,
'en_US': opt,
}
}
for opt in cfg.options
]
# Add show_if if present
if cfg.show_if:
result['show_if'] = cfg.show_if
return result
@classmethod
def to_schema(cls) -> dict[str, Any]:
"""
Convert node class to JSON schema for frontend.
Returns:
Node schema dictionary
"""
# Build label dict for i18n support
# Use underscore format to match frontend I18nObject interface
name_zh = getattr(cls, 'name_zh', None) or cls.name
name_en = getattr(cls, 'name_en', None) or cls.name
desc_zh = getattr(cls, 'description_zh', None) or cls.description
desc_en = getattr(cls, 'description_en', None) or cls.description
label = {
'zh_Hans': name_zh,
'en_US': name_en,
}
description = {
'zh_Hans': desc_zh,
'en_US': desc_en,
}
return {
'type': f'{cls.category}.{cls.type_name}',
'name': cls.name,
'label': label,
'description': description,
'category': cls.category,
'icon': cls.icon,
'inputs': [port.model_dump() for port in cls.inputs],
'outputs': [port.model_dump() for port in cls.outputs],
'config_schema': [cls._config_to_schema_item(cfg) for cfg in cls.config_schema],
'config_schema_source': cls.config_schema_source,
'config_stages': cls.config_stages,
}
# Registry for node type decorator
_pending_registrations: list[tuple[str, type[WorkflowNode]]] = []
def workflow_node(type_name: str) -> Callable[[type[WorkflowNode]], type[WorkflowNode]]:
"""
Decorator to register a workflow node type.
Usage:
@workflow_node('llm_call')
class LLMCallNode(WorkflowNode):
...
"""
def decorator(cls: type[WorkflowNode]) -> type[WorkflowNode]:
cls.type_name = type_name
_pending_registrations.append((type_name, cls))
return cls
return decorator
def get_pending_registrations() -> list[tuple[str, type[WorkflowNode]]]:
"""Get pending node registrations"""
return _pending_registrations.copy()
def clear_pending_registrations():
"""Clear pending registrations after they're processed"""
_pending_registrations.clear()

View File

@@ -0,0 +1,91 @@
"""Core workflow nodes package"""
# Import all node modules to trigger registration
# Trigger nodes
from . import message_trigger
from . import cron_trigger
from . import webhook_trigger
from . import event_trigger
# Process nodes
from . import llm_call
from . import code_executor
from . import http_request
from . import data_transform
from . import question_classifier
from . import parameter_extractor
from . import knowledge_retrieval
# Control nodes
from . import condition
from . import switch
from . import loop
from . import iterator
from . import parallel
from . import wait
from . import merge
from . import variable_aggregator
# Action nodes
from . import send_message
from . import reply_message
from . import call_pipeline
from . import store_data
from . import set_variable
from . import opening_statement
from . import end
# Integration nodes
from . import database_query
from . import redis_operation
from . import mcp_tool
from . import memory_store
from . import dify_workflow
from . import dify_knowledge_query
from . import n8n_workflow
from . import langflow_flow
from . import coze_bot
# from . import plugin_call
__all__ = [
# Trigger nodes
'message_trigger',
'cron_trigger',
'webhook_trigger',
'event_trigger',
# Process nodes
'llm_call',
'code_executor',
'http_request',
'data_transform',
'question_classifier',
'parameter_extractor',
'knowledge_retrieval',
# Control nodes
'condition',
'switch',
'loop',
'iterator',
'parallel',
'wait',
'merge',
'variable_aggregator',
# Action nodes
'send_message',
'reply_message',
'call_pipeline',
'store_data',
'set_variable',
'opening_statement',
'end',
# Integration nodes
'database_query',
'redis_operation',
'mcp_tool',
'memory_store',
'dify_workflow',
'dify_knowledge_query',
'n8n_workflow',
'langflow_flow',
'coze_bot',
]

View File

@@ -0,0 +1,36 @@
"""Call Pipeline Node - invoke an existing pipeline
Node metadata is loaded from: ../../templates/metadata/nodes/call_pipeline.yaml
"""
from __future__ import annotations
from typing import Any, ClassVar
from ..entities import ExecutionContext
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
@workflow_node('call_pipeline')
class CallPipelineNode(WorkflowNode):
"""Call pipeline node - invoke an existing pipeline"""
type_name = "call_pipeline"
category = "action"
icon = "⚙️"
name = "call_pipeline"
description = "call_pipeline"
name_zh = "调用 Pipeline"
name_en = "Call Pipeline"
description_zh = "调用现有的 Pipeline 进行处理"
description_en = "Invoke an existing Pipeline for processing"
inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = []
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
query = inputs.get("query", "")
pipeline_uuid = self.get_config("pipeline_uuid", "")
return {"response": f"[Pipeline {pipeline_uuid} response for: {query[:50]}...]", "result": {}}

View File

@@ -0,0 +1,73 @@
"""Code Executor Node - run Python or JavaScript code
Node metadata is loaded from: ../../templates/metadata/nodes/code_executor.yaml
"""
from __future__ import annotations
import json
import re
from typing import Any, ClassVar
from ..entities import ExecutionContext
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
@workflow_node('code_executor')
class CodeExecutorNode(WorkflowNode):
"""Code executor node - run Python or JavaScript code"""
type_name = "code_executor"
category = "process"
icon = "💻"
name = "code_executor"
description = "code_executor"
name_zh = "代码执行"
name_en = "Code Executor"
description_zh = "执行自定义代码处理数据"
description_en = "Execute custom code to process data"
inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = []
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
code = self.get_config("code", "")
language = self.get_config("language", "python")
if language == "python":
return await self._execute_python(code, inputs, context)
else:
return await self._execute_javascript(code, inputs, context)
async def _execute_python(self, code: str, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
import io
import sys
stdout_capture = io.StringIO()
old_stdout = sys.stdout
try:
sys.stdout = stdout_capture
restricted_globals = {
'__builtins__': {
'len': len, 'str': str, 'int': int, 'float': float, 'bool': bool,
'list': list, 'dict': dict, 'set': set, 'tuple': tuple,
'range': range, 'enumerate': enumerate, 'zip': zip,
'map': map, 'filter': filter, 'sorted': sorted, 'reversed': reversed,
'sum': sum, 'min': min, 'max': max, 'abs': abs, 'round': round,
'print': print, 'isinstance': isinstance, 'type': type,
'hasattr': hasattr, 'getattr': getattr, 'json': json, 're': re,
}
}
local_vars = {'inputs': inputs, 'output': None}
exec(code, restricted_globals, local_vars)
return {"output": local_vars.get('output'), "console": stdout_capture.getvalue()}
finally:
sys.stdout = old_stdout
async def _execute_javascript(self, code: str, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
return {"output": f"[JS execution not implemented: {code[:50]}...]", "console": ""}

View File

@@ -0,0 +1,88 @@
"""Condition Node - branch based on condition
Node metadata is loaded from: ../../templates/metadata/nodes/condition.yaml
"""
from __future__ import annotations
from typing import Any, ClassVar
from ..entities import ExecutionContext
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
from ..safe_eval import safe_eval_with_vars
@workflow_node('condition')
class ConditionNode(WorkflowNode):
"""Condition node - branch based on condition"""
type_name = "condition"
category = "control"
icon = "🔀"
name = "condition"
description = "condition"
name_zh = "条件分支"
name_en = "Condition"
description_zh = "根据条件分支工作流"
description_en = "Branch workflow based on a condition"
inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = []
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
condition_type = self.get_config("condition_type", "expression")
input_data = inputs.get("input")
result = False
if condition_type == "expression":
expression = self.get_config("expression", "false")
result = await self._evaluate_expression(expression, input_data, context)
elif condition_type == "comparison":
result = await self._evaluate_comparison(input_data, context)
elif condition_type == "contains":
left = self.get_config("left_value", "")
right = self.get_config("right_value", "")
result = right in left
elif condition_type == "empty":
result = not bool(input_data)
elif condition_type == "regex":
import re
left = self.get_config("left_value", "")
pattern = self.get_config("right_value", "")
result = bool(re.match(pattern, str(left)))
if result:
return {"true": input_data, "false": None}
else:
return {"true": None, "false": input_data}
async def _evaluate_expression(self, expression: str, data: Any, context: ExecutionContext) -> bool:
try:
local_vars = {"input": data, "data": data, "variables": context.variables}
return bool(safe_eval_with_vars(expression, local_vars))
except Exception:
return False
async def _evaluate_comparison(self, data: Any, context: ExecutionContext) -> bool:
left = self.get_config("left_value", "")
right = self.get_config("right_value", "")
operator = self.get_config("operator", "==")
try:
left_num = float(left)
right_num = float(right)
if operator == "==": return left_num == right_num
elif operator == "!=": return left_num != right_num
elif operator == ">": return left_num > right_num
elif operator == "<": return left_num < right_num
elif operator == ">=": return left_num >= right_num
elif operator == "<=": return left_num <= right_num
except ValueError:
if operator == "==": return left == right
elif operator == "!=": return left != right
elif operator in (">", "<", ">=", "<="): return False
return False

View File

@@ -0,0 +1,49 @@
"""Coze Bot Node - call Coze API bot
Node metadata is loaded from: ../../templates/metadata/nodes/coze_bot.yaml
"""
from __future__ import annotations
from typing import Any, ClassVar
from ..entities import ExecutionContext
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
@workflow_node('coze_bot')
class CozeBotNode(WorkflowNode):
"""Coze bot node - call Coze API bot"""
type_name = "coze_bot"
category = "integration"
icon = "MessageSquare"
name = "coze_bot"
description = "coze_bot"
name_zh = "Coze Bot"
name_en = "Coze Bot"
description_zh = "调用扣子 Bot"
description_en = "Call a Coze Bot"
inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = []
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
api_key = self.get_config("api_key", "")
bot_id = self.get_config("bot_id", "")
api_base = self.get_config("api_base", "https://api.coze.cn")
query = inputs.get("query", "")
conversation_id = inputs.get("conversation_id")
return {
"answer": "",
"conversation_id": conversation_id,
"success": False,
"_debug": {
"api_key": api_key[:8] + "..." if api_key else "",
"bot_id": bot_id,
"api_base": api_base,
"query": query,
},
}

View File

@@ -0,0 +1,39 @@
"""Cron Trigger Node - triggers workflow on schedule
Node metadata is loaded from: ../../templates/metadata/nodes/cron_trigger.yaml
"""
from __future__ import annotations
from typing import Any, ClassVar
from ..entities import ExecutionContext
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
@workflow_node('cron_trigger')
class CronTriggerNode(WorkflowNode):
"""Cron trigger node - triggers workflow on schedule"""
type_name = "cron_trigger"
category = "trigger"
icon = ""
name = "cron_trigger"
description = "cron_trigger"
name_zh = "定时触发"
name_en = "Scheduled Trigger"
description_zh = "按定时计划触发工作流"
description_en = "Trigger workflow on a scheduled time"
inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = []
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
from datetime import datetime
return {
"timestamp": datetime.now().isoformat(),
"schedule": self.get_config("cron", ""),
"context": context.trigger_data,
}

View File

@@ -0,0 +1,81 @@
"""Data Transform Node - transform data using templates or JSONPath
Node metadata is loaded from: ../../templates/metadata/nodes/data_transform.yaml
"""
from __future__ import annotations
from typing import Any, ClassVar
from ..entities import ExecutionContext
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
from ..safe_eval import safe_eval_with_vars
@workflow_node('data_transform')
class DataTransformNode(WorkflowNode):
"""Data transform node - transform data using templates or JSONPath"""
type_name = "data_transform"
category = "process"
icon = "🔄"
name = "data_transform"
description = "data_transform"
name_zh = "数据转换"
name_en = "Data Transform"
description_zh = "使用模板或 JSONPath 转换数据"
description_en = "Transform data using templates or JSONPath"
inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = []
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
data = inputs.get("data")
transform_type = self.get_config("transform_type", "template")
if transform_type == "template":
template = self.get_config("template", "")
result = self._apply_template(template, data, context)
elif transform_type == "jsonpath":
expression = self.get_config("expression", "$")
result = self._apply_jsonpath(expression, data)
elif transform_type == "expression":
expression = self.get_config("expression", "")
result = self._evaluate_expression(expression, data, context)
else:
result = data
return {"result": result}
def _apply_template(self, template: str, data: Any, context: ExecutionContext) -> str:
result = template
if isinstance(data, dict):
for key, value in data.items():
result = result.replace(f"{{{{data.{key}}}}}", str(value))
for key, value in context.variables.items():
result = result.replace(f"{{{{variables.{key}}}}}", str(value))
return result
def _apply_jsonpath(self, expression: str, data: Any) -> Any:
if expression == "$":
return data
if expression.startswith("$."):
parts = expression[2:].split(".")
result = data
for part in parts:
if isinstance(result, dict):
result = result.get(part)
elif isinstance(result, list) and part.isdigit():
result = result[int(part)]
else:
return None
return result
return data
def _evaluate_expression(self, expression: str, data: Any, context: ExecutionContext) -> Any:
local_vars = {"data": data, "variables": context.variables}
try:
return safe_eval_with_vars(expression, local_vars)
except Exception:
return None

View File

@@ -0,0 +1,52 @@
"""Database Query Node - execute database queries
Node metadata is loaded from: ../../templates/metadata/nodes/database_query.yaml
"""
from __future__ import annotations
from typing import Any, ClassVar
from ..entities import ExecutionContext
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
@workflow_node('database_query')
class DatabaseQueryNode(WorkflowNode):
"""Database query node - execute database queries"""
type_name = "database_query"
category = "integration"
icon = "Database"
name = "database_query"
description = "database_query"
name_zh = "数据库查询"
name_en = "Database Query"
description_zh = "执行数据库查询"
description_en = "Execute database queries"
inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = []
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
connection_type = self.get_config("connection_type", "postgresql")
connection_string = self.get_config("connection_string", "")
query = self.get_config("query", "")
query_type = self.get_config("query_type", "select")
timeout = self.get_config("timeout", 30)
parameters = inputs.get("parameters", {})
return {
"results": [],
"row_count": 0,
"success": False,
"_debug": {
"connection_type": connection_type,
"query": query,
"query_type": query_type,
"timeout": timeout,
"parameters": parameters,
},
}

View File

@@ -0,0 +1,47 @@
"""Dify Knowledge Query Node - query Dify knowledge base
Node metadata is loaded from: ../../templates/metadata/nodes/dify_knowledge_query.yaml
"""
from __future__ import annotations
from typing import Any, ClassVar
from ..entities import ExecutionContext
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
@workflow_node('dify_knowledge_query')
class DifyKnowledgeQueryNode(WorkflowNode):
"""Dify knowledge base query node - query Dify knowledge base"""
type_name = "dify_knowledge_query"
category = "integration"
icon = "BookOpen"
name = "dify_knowledge_query"
description = "dify_knowledge_query"
name_zh = "Dify 知识库查询"
name_en = "Dify Knowledge Query"
description_zh = "查询 Dify 知识库"
description_en = "Query Dify knowledge base"
inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = []
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
base_url = self.get_config("base_url", "https://api.dify.ai/v1")
api_key = self.get_config("api_key", "")
dataset_id = self.get_config("dataset_id", "")
query = inputs.get("query", "")
return {
"results": [],
"success": False,
"_debug": {
"base_url": base_url,
"api_key": api_key[:8] + "..." if api_key else "",
"dataset_id": dataset_id,
"query": query,
},
}

View File

@@ -0,0 +1,49 @@
"""Dify Workflow Node - call Dify service API
Node metadata is loaded from: ../../templates/metadata/nodes/dify_workflow.yaml
"""
from __future__ import annotations
from typing import Any, ClassVar
from ..entities import ExecutionContext
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
@workflow_node('dify_workflow')
class DifyWorkflowNode(WorkflowNode):
"""Dify workflow node - call Dify service API"""
type_name = "dify_workflow"
category = "integration"
icon = "Bot"
name = "dify_workflow"
description = "dify_workflow"
name_zh = "Dify 工作流"
name_en = "Dify Workflow"
description_zh = "调用 Dify 平台工作流"
description_en = "Call a Dify platform workflow"
inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = []
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
base_url = self.get_config("base_url", "https://api.dify.ai/v1")
api_key = self.get_config("api_key", "")
app_type = self.get_config("app_type", "chat")
query = inputs.get("query", "")
conversation_id = inputs.get("conversation_id")
return {
"answer": "",
"conversation_id": conversation_id,
"success": False,
"_debug": {
"base_url": base_url,
"api_key": api_key[:8] + "..." if api_key else "",
"app_type": app_type,
"query": query,
},
}

View File

@@ -0,0 +1,45 @@
"""End Node - marks the end of workflow execution
Node metadata is loaded from: ../../templates/metadata/nodes/end.yaml
"""
from __future__ import annotations
from typing import Any, ClassVar
from ..entities import ExecutionContext
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
@workflow_node('end')
class EndNode(WorkflowNode):
"""End node - marks the end of workflow execution"""
type_name = "end"
category = "action"
icon = "🏁"
name = "end"
description = "end"
name_zh = "结束"
name_en = "End"
description_zh = "结束工作流执行"
description_en = "End the workflow execution"
inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = []
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
result = inputs.get("result")
output_format = self.get_config("output_format", "passthrough")
if output_format == "text":
return {"output": str(result)}
elif output_format == "json":
import json
try:
return {"output": json.dumps(result, ensure_ascii=False)}
except Exception:
return {"output": str(result)}
else:
return {"output": result}

View File

@@ -0,0 +1,41 @@
"""Event Trigger Node - triggers workflow on system events
Node metadata is loaded from: ../../templates/metadata/nodes/event_trigger.yaml
"""
from __future__ import annotations
from typing import Any, ClassVar
from ..entities import ExecutionContext
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
@workflow_node('event_trigger')
class EventTriggerNode(WorkflowNode):
"""Event trigger node - triggers workflow on system events"""
type_name = "event_trigger"
category = "trigger"
icon = "📡"
name = "event_trigger"
description = "event_trigger"
name_zh = "事件触发"
name_en = "Event Trigger"
description_zh = "当系统事件发生时触发工作流"
description_en = "Trigger workflow when a system event occurs"
inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = []
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
from datetime import datetime
trigger_data = context.trigger_data
return {
"event_type": trigger_data.get("event_type", ""),
"event_data": trigger_data.get("event_data", {}),
"timestamp": trigger_data.get("timestamp", datetime.now().isoformat()),
}

View File

@@ -0,0 +1,70 @@
"""HTTP Request Node - make HTTP API calls
Node metadata is loaded from: ../../templates/metadata/nodes/http_request.yaml
"""
from __future__ import annotations
from typing import Any, ClassVar
from ..entities import ExecutionContext
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
@workflow_node('http_request')
class HTTPRequestNode(WorkflowNode):
"""HTTP request node - make HTTP API calls"""
type_name = "http_request"
category = "process"
icon = "🌐"
name = "http_request"
description = "http_request"
name_zh = "HTTP 请求"
name_en = "HTTP Request"
description_zh = "向外部 API 发送 HTTP 请求"
description_en = "Make HTTP requests to external APIs"
inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = []
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
import aiohttp
url = self.get_config("url", "")
method = self.get_config("method", "GET")
timeout = self.get_config("timeout", 30)
content_type = self.get_config("content_type", "application/json")
headers = inputs.get("headers", {})
headers["Content-Type"] = content_type
auth_type = self.get_config("auth_type", "none")
auth_config = self.get_config("auth_config", {})
if auth_type == "bearer":
headers["Authorization"] = f"Bearer {auth_config.get('token', '')}"
elif auth_type == "api_key":
header_name = auth_config.get("header", "X-API-Key")
headers[header_name] = auth_config.get("key", "")
body = inputs.get("body")
try:
async with aiohttp.ClientSession() as session:
async with session.request(
method=method, url=url,
json=body if content_type == "application/json" else None,
data=body if content_type != "application/json" else None,
headers=headers,
timeout=aiohttp.ClientTimeout(total=timeout)
) as response:
try:
response_data = await response.json()
except Exception:
response_data = await response.text()
return {"response": response_data, "status_code": response.status, "headers": dict(response.headers)}
except Exception as e:
return {"response": None, "status_code": 0, "headers": {}, "error": str(e)}

View File

@@ -0,0 +1,60 @@
"""Iterator Node - Dify-style iterator for processing array items"""
from __future__ import annotations
from typing import Any, ClassVar
from ..entities import ExecutionContext
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
@workflow_node('iterator')
class IteratorNode(WorkflowNode):
"""Iterator node - iterate over array items one by one"""
type_name = "iterator"
category = "control"
icon = "🔄"
name = "iterator"
name_zh = "迭代器"
name_en = "Iterator"
description = "iterator"
description_zh = "逐个遍历数组元素"
description_en = "Iterate over array elements one by one"
inputs: ClassVar[list[NodePort]] = [
NodePort(name="items", type="array", description="Array to iterate over", required=True),
]
outputs: ClassVar[list[NodePort]] = [
NodePort(name="item", type="any", description="Current item"),
NodePort(name="index", type="number", description="Current index"),
NodePort(name="is_first", type="boolean", description="Whether this is the first item"),
NodePort(name="is_last", type="boolean", description="Whether this is the last item"),
NodePort(name="results", type="array", description="All iteration results"),
NodePort(name="completed", type="boolean", description="Whether iteration completed"),
]
config_schema: ClassVar[list[NodeConfig]] = [
NodeConfig(
name="max_iterations", type="integer", required=False, default=1000,
description="Maximum iterations (safety limit)",
label={"en_US": "Max Iterations", "zh_Hans": "最大迭代次数"},
),
]
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
items = inputs.get("items", [])
if not isinstance(items, list):
items = [items] if items else []
max_iterations = self.get_config("max_iterations", 1000)
items = items[:max_iterations]
return {
"item": items[0] if items else None,
"index": 0,
"is_first": True,
"is_last": len(items) <= 1,
"results": [],
"completed": len(items) == 0,
"_items": items,
}

View File

@@ -0,0 +1,34 @@
"""Knowledge Retrieval Node - search in knowledge base
Node metadata is loaded from: ../../templates/metadata/nodes/knowledge_retrieval.yaml
"""
from __future__ import annotations
from typing import Any, ClassVar
from ..entities import ExecutionContext
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
@workflow_node('knowledge_retrieval')
class KnowledgeRetrievalNode(WorkflowNode):
"""Knowledge retrieval node - search in knowledge base"""
type_name = "knowledge_retrieval"
category = "process"
icon = "📚"
name = "knowledge_retrieval"
description = "knowledge_retrieval"
name_zh = "知识库检索"
name_en = "Knowledge Retrieval"
description_zh = "从知识库中检索相关信息"
description_en = "Retrieve relevant information from knowledge bases"
inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = []
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
query = inputs.get("query", "")
return {"documents": [], "citations": [], "context": f"[Knowledge base search for: {query}]"}

View File

@@ -0,0 +1,47 @@
"""Langflow Flow Node - call Langflow API
Node metadata is loaded from: ../../templates/metadata/nodes/langflow_flow.yaml
"""
from __future__ import annotations
from typing import Any, ClassVar
from ..entities import ExecutionContext
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
@workflow_node('langflow_flow')
class LangflowFlowNode(WorkflowNode):
"""Langflow flow node - call Langflow API"""
type_name = "langflow_flow"
category = "integration"
icon = "GitBranch"
name = "langflow_flow"
description = "langflow_flow"
name_zh = "Langflow 流程"
name_en = "Langflow Flow"
description_zh = "调用 Langflow 流程"
description_en = "Call a Langflow flow"
inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = []
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
base_url = self.get_config("base_url", "http://localhost:7860")
api_key = self.get_config("api_key", "")
flow_id = self.get_config("flow_id", "")
input_value = inputs.get("input_value", "")
return {
"result": None,
"success": False,
"_debug": {
"base_url": base_url,
"api_key": api_key[:8] + "..." if api_key else "",
"flow_id": flow_id,
"input_value": input_value,
},
}

View File

@@ -0,0 +1,163 @@
"""LLM Call Node - invoke large language model."""
from __future__ import annotations
import logging
import re
from typing import Any, ClassVar
import langbot_plugin.api.entities.builtin.provider.message as provider_message
from ..entities import ExecutionContext
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
logger = logging.getLogger(__name__)
@workflow_node('llm_call')
class LLMCallNode(WorkflowNode):
"""LLM call node - invoke large language model"""
type_name = "llm_call"
category = "process"
icon = "🤖"
name = "llm_call"
name_zh = "LLM 调用"
name_en = "LLM Call"
description = "llm_call"
description_zh = "调用大语言模型生成响应"
description_en = "Call a large language model to generate responses"
inputs: ClassVar[list[NodePort]] = [
NodePort(name="input", type="string", description="Input text to send to the model", required=False),
NodePort(name="context", type="object", description="Additional context data", required=False),
]
outputs: ClassVar[list[NodePort]] = [
NodePort(name="response", type="string", description="Model response text"),
NodePort(name="usage", type="object", description="Token usage information"),
]
config_schema: ClassVar[list[NodeConfig]] = [
NodeConfig(
name="model", type="llm-model-selector", required=True,
description="Select the LLM model to use",
label={"en_US": "Model", "zh_Hans": "模型"},
),
NodeConfig(
name="system_prompt", type="textarea", required=False, default="",
description="System prompt to set model behavior",
label={"en_US": "System Prompt", "zh_Hans": "系统提示词"},
),
NodeConfig(
name="user_prompt_template", type="textarea", required=True, default="{{input}}",
description="User prompt template with variable placeholders",
label={"en_US": "User Prompt Template", "zh_Hans": "用户提示词模板"},
),
NodeConfig(
name="temperature", type="number", required=False, default=0.7,
description="Controls randomness (0.0-2.0)",
label={"en_US": "Temperature", "zh_Hans": "温度"},
min_value=0.0, max_value=2.0,
),
NodeConfig(
name="max_tokens", type="integer", required=False, default=0,
description="Max tokens to generate (0 = model default)",
label={"en_US": "Max Tokens", "zh_Hans": "最大令牌数"},
),
]
def _resolve_template(self, template: str, inputs: dict[str, Any], context: ExecutionContext) -> str:
"""Resolve {{variable}} placeholders in a template string."""
def replacer(match: re.Match) -> str:
expr = match.group(1).strip()
# Try inputs first
if expr in inputs:
return str(inputs[expr])
# Try context variables
if expr.startswith("variables."):
var_name = expr[len("variables."):]
return str(context.variables.get(var_name, ""))
# Try message context
if expr.startswith("message.") and context.message_context:
attr = expr[len("message."):]
return str(getattr(context.message_context, attr, ""))
return match.group(0) # leave unresolved
return re.sub(r"\{\{([^}]+)\}\}", replacer, template)
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
model_uuid = self.get_config("model", "")
if not model_uuid:
raise ValueError("No model configured for LLM call node")
if not self.ap:
raise RuntimeError("Application instance not available — cannot call LLM")
# Resolve prompts
system_prompt = self._resolve_template(
self.get_config("system_prompt", ""), inputs, context
)
user_prompt = self._resolve_template(
self.get_config("user_prompt_template", "{{input}}"), inputs, context
)
# Build messages
messages: list[provider_message.Message] = []
if system_prompt:
messages.append(provider_message.Message(role="system", content=system_prompt))
messages.append(provider_message.Message(role="user", content=user_prompt))
# Get model
runtime_model = await self.ap.model_mgr.get_model_by_uuid(model_uuid)
# Build extra args from config
extra_args: dict[str, Any] = {}
temperature = self.get_config("temperature")
if temperature is not None:
extra_args["temperature"] = float(temperature)
max_tokens = self.get_config("max_tokens", 0)
if max_tokens and int(max_tokens) > 0:
extra_args["max_tokens"] = int(max_tokens)
# Invoke LLM
logger.info(f"LLM call node {self.node_id}: invoking model {model_uuid}")
result_message = await runtime_model.provider.invoke_llm(
query=None,
model=runtime_model,
messages=messages,
funcs=None,
extra_args=extra_args,
)
# Extract response text
response_text = ""
if isinstance(result_message.content, str):
response_text = result_message.content
elif isinstance(result_message.content, list):
# ContentElement list — concatenate text elements
for elem in result_message.content:
if hasattr(elem, 'text') and elem.text:
response_text += elem.text
elif isinstance(elem, str):
response_text += elem
# Extract usage info if available
usage = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
if hasattr(result_message, 'usage') and result_message.usage:
u = result_message.usage
usage = {
"prompt_tokens": getattr(u, 'prompt_tokens', 0) or 0,
"completion_tokens": getattr(u, 'completion_tokens', 0) or 0,
"total_tokens": getattr(u, 'total_tokens', 0) or 0,
}
elif hasattr(result_message, 'token_usage') and result_message.token_usage:
u = result_message.token_usage
usage = {
"prompt_tokens": getattr(u, 'prompt_tokens', 0) or 0,
"completion_tokens": getattr(u, 'completion_tokens', 0) or 0,
"total_tokens": getattr(u, 'total_tokens', 0) or 0,
}
return {
"response": response_text,
"usage": usage,
}

View File

@@ -0,0 +1,62 @@
"""Loop Node - iterate over items"""
from __future__ import annotations
from typing import Any, ClassVar
from ..entities import ExecutionContext
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
@workflow_node('loop')
class LoopNode(WorkflowNode):
"""Loop node - iterate over items"""
type_name = "loop"
category = "control"
icon = "🔁"
name = "loop"
name_zh = "循环"
name_en = "Loop"
description = "loop"
description_zh = "遍历项目或重复直到满足条件"
description_en = "Iterate over items or repeat until condition"
inputs: ClassVar[list[NodePort]] = [
NodePort(name="items", type="array", description="Items to iterate over", required=False),
]
outputs: ClassVar[list[NodePort]] = [
NodePort(name="item", type="any", description="Current item in iteration"),
NodePort(name="index", type="number", description="Current iteration index"),
NodePort(name="results", type="array", description="All iteration results"),
NodePort(name="completed", type="boolean", description="Whether loop completed"),
]
config_schema: ClassVar[list[NodeConfig]] = [
NodeConfig(
name="loop_type", type="select", required=True, default="foreach",
description="Type of loop",
label={"en_US": "Loop Type", "zh_Hans": "循环类型"},
options=["foreach", "while", "count"],
),
NodeConfig(
name="max_iterations", type="integer", required=False, default=100,
description="Maximum iterations (safety limit)",
label={"en_US": "Max Iterations", "zh_Hans": "最大迭代次数"},
),
]
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
items = inputs.get("items", [])
if not isinstance(items, list):
items = [items] if items else []
max_iterations = self.get_config("max_iterations", 100)
items = items[:max_iterations]
return {
"item": items[0] if items else None,
"index": 0,
"results": [],
"completed": len(items) == 0,
"_items": items,
}

View File

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

View File

@@ -0,0 +1,103 @@
"""Memory Store Node - store and retrieve from workflow memory
Node metadata is loaded from: ../../templates/metadata/nodes/memory_store.yaml
"""
from __future__ import annotations
from typing import Any, ClassVar
from ..entities import ExecutionContext
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
class MemoryHelper:
"""Helper class wrapping context.memory dict with get/set/delete/list_all/append operations"""
def __init__(self, memory_dict: dict[str, Any]):
self._data = memory_dict
def get(self, key: str, scope: str = "execution", default: Any = None) -> Any:
"""Get a value from memory by key"""
scoped_key = f"{scope}:{key}" if scope else key
return self._data.get(scoped_key, default)
def set(self, key: str, value: Any, scope: str = "execution", ttl: int = 0) -> None:
"""Set a value in memory"""
scoped_key = f"{scope}:{key}" if scope else key
self._data[scoped_key] = value
def delete(self, key: str, scope: str = "execution") -> None:
"""Delete a value from memory"""
scoped_key = f"{scope}:{key}" if scope else key
self._data.pop(scoped_key, None)
def list_all(self, scope: str = "execution") -> dict[str, Any]:
"""List all values in the given scope"""
prefix = f"{scope}:"
return {
k[len(prefix):]: v
for k, v in self._data.items()
if k.startswith(prefix)
}
def append(self, key: str, value: Any, scope: str = "execution", ttl: int = 0) -> list:
"""Append a value to a list in memory"""
current = self.get(key, scope=scope, default=[])
if isinstance(current, list):
current.append(value)
else:
current = [current, value]
self.set(key, current, scope=scope, ttl=ttl)
return current
@workflow_node('memory_store')
class MemoryStoreNode(WorkflowNode):
"""Memory store node - store and retrieve from workflow memory"""
type_name = "memory_store"
category = "integration"
icon = "HardDrive"
name = "memory_store"
description = "memory_store"
name_zh = "记忆存储"
name_en = "Memory Store"
description_zh = "从工作流记忆中存储和检索数据"
description_en = "Store and retrieve data from workflow memory"
inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = []
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
operation = self.get_config("operation", "get")
key = self.get_config("key", "")
scope = self.get_config("scope", "execution")
ttl = self.get_config("ttl", 0)
value = inputs.get("value")
# Wrap context.memory dict with MemoryHelper for structured operations
memory = MemoryHelper(context.memory)
try:
if operation == "get":
result = memory.get(key, scope=scope)
return {"result": result, "success": True}
elif operation == "set":
memory.set(key, value, scope=scope, ttl=ttl)
return {"result": value, "success": True}
elif operation == "delete":
memory.delete(key, scope=scope)
return {"result": None, "success": True}
elif operation == "append":
result = memory.append(key, value, scope=scope, ttl=ttl)
return {"result": result, "success": True}
elif operation == "list":
result = memory.list_all(scope=scope)
return {"result": result, "success": True}
else:
return {"result": None, "success": False, "error": f"Unknown operation: {operation}"}
except Exception as e:
return {"result": None, "success": False, "error": str(e)}

View File

@@ -0,0 +1,65 @@
"""Merge Node - combine multiple inputs
Node metadata is loaded from: ../../templates/metadata/nodes/merge.yaml
"""
from __future__ import annotations
from typing import Any, ClassVar
from ..entities import ExecutionContext
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
@workflow_node('merge')
class MergeNode(WorkflowNode):
"""Merge node - combine multiple inputs"""
type_name = "merge"
category = "control"
icon = "🔗"
name = "merge"
description = "merge"
name_zh = "合并"
name_en = "Merge"
description_zh = "将多个分支合并在一起"
description_en = "Merge multiple branches back together"
inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = []
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
strategy = self.get_config("merge_strategy", "object")
values = [inputs.get("input_1"), inputs.get("input_2"), inputs.get("input_3"), inputs.get("input_4")]
non_null_values = [v for v in values if v is not None]
if strategy == "object":
merged = {}
for i, v in enumerate(non_null_values):
if isinstance(v, dict):
merged.update(v)
else:
merged[f"value_{i}"] = v
return {"merged": merged, "array": non_null_values}
elif strategy == "array":
return {"merged": non_null_values, "array": non_null_values}
elif strategy == "first_non_null":
first = non_null_values[0] if non_null_values else None
return {"merged": first, "array": non_null_values}
elif strategy == "concat":
if all(isinstance(v, str) for v in non_null_values):
return {"merged": "".join(non_null_values), "array": non_null_values}
elif all(isinstance(v, list) for v in non_null_values):
merged_list = []
for v in non_null_values:
merged_list.extend(v)
return {"merged": merged_list, "array": merged_list}
else:
return {"merged": non_null_values, "array": non_null_values}
return {"merged": non_null_values, "array": non_null_values}

View File

@@ -0,0 +1,56 @@
"""Message Trigger Node - triggers workflow on message arrival
This module contains the implementation for the Message Trigger workflow node.
Node metadata (label, description, inputs, outputs, config) is loaded from:
../../templates/metadata/nodes/message_trigger.yaml
"""
from __future__ import annotations
from typing import Any, ClassVar
from ..entities import ExecutionContext
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
@workflow_node('message_trigger')
class MessageTriggerNode(WorkflowNode):
"""Message trigger node - triggers workflow on message arrival"""
type_name = "message_trigger"
category = "trigger"
icon = "💬"
name = "message_trigger"
description = "message_trigger"
name_zh = "消息触发"
name_en = "Message Trigger"
description_zh = "当收到消息时触发工作流"
description_en = "Trigger workflow when a message is received"
inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = []
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
msg_ctx = context.message_context
if msg_ctx:
return {
"message": msg_ctx.message_content,
"sender_id": msg_ctx.sender_id,
"sender_name": msg_ctx.sender_name,
"platform": msg_ctx.platform,
"conversation_id": msg_ctx.conversation_id,
"is_group": msg_ctx.is_group,
"context": msg_ctx.model_dump(),
}
return {
"message": context.get_variable("message", ""),
"sender_id": context.get_variable("sender_id", ""),
"sender_name": context.get_variable("sender_name", ""),
"platform": context.get_variable("platform", ""),
"conversation_id": context.get_variable("conversation_id", ""),
"is_group": context.get_variable("is_group", False),
"context": context.trigger_data,
}

View File

@@ -0,0 +1,47 @@
"""N8n Workflow Node - call n8n workflow API
Node metadata is loaded from: ../../templates/metadata/nodes/n8n_workflow.yaml
"""
from __future__ import annotations
from typing import Any, ClassVar
from ..entities import ExecutionContext
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
@workflow_node('n8n_workflow')
class N8nWorkflowNode(WorkflowNode):
"""n8n workflow node - call n8n workflow API"""
type_name = "n8n_workflow"
category = "integration"
icon = "Workflow"
name = "n8n_workflow"
description = "n8n_workflow"
name_zh = "n8n 工作流"
name_en = "N8n Workflow"
description_zh = "通过 webhook 调用 n8n 工作流"
description_en = "Call an n8n workflow via webhook"
inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = []
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
webhook_url = self.get_config("webhook_url", "")
auth_type = self.get_config("auth_type", "none")
timeout = self.get_config("timeout", 120)
payload = inputs.get("payload", {})
return {
"result": None,
"success": False,
"_debug": {
"webhook_url": webhook_url,
"auth_type": auth_type,
"timeout": timeout,
"payload": payload,
},
}

View File

@@ -0,0 +1,37 @@
"""Opening Statement Node - provide conversation opener and suggested questions
Node metadata is loaded from: ../../templates/metadata/nodes/opening_statement.yaml
"""
from __future__ import annotations
from typing import Any, ClassVar
from ..entities import ExecutionContext
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
@workflow_node('opening_statement')
class OpeningStatementNode(WorkflowNode):
"""Opening statement node - provide conversation opener and suggested questions"""
type_name = "opening_statement"
category = "action"
icon = "👋"
name = "opening_statement"
description = "opening_statement"
name_zh = "对话开场白"
name_en = "Opening Statement"
description_zh = "提供对话开场白和建议问题"
description_en = "Provide conversation opener and suggested questions"
inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = []
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
statement = self.get_config("statement", "")
suggestions = self.get_config("suggested_questions", [])
show = self.get_config("show_suggestions", True)
return {"statement": statement, "suggested_questions": suggestions if show else []}

View File

@@ -0,0 +1,49 @@
"""Parallel Node - execute multiple branches simultaneously"""
from __future__ import annotations
from typing import Any, ClassVar
from ..entities import ExecutionContext
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
@workflow_node('parallel')
class ParallelNode(WorkflowNode):
"""Parallel node - execute multiple branches simultaneously"""
type_name = "parallel"
category = "control"
icon = ""
name = "parallel"
name_zh = "并行执行"
name_en = "Parallel"
description = "parallel"
description_zh = "并行执行多个分支"
description_en = "Execute multiple branches in parallel"
inputs: ClassVar[list[NodePort]] = [
NodePort(name="input", type="any", description="Input data for all branches", required=False),
]
outputs: ClassVar[list[NodePort]] = [
NodePort(name="results", type="object", description="Combined results from all branches"),
NodePort(name="errors", type="array", description="Errors from branches (if any)"),
]
config_schema: ClassVar[list[NodeConfig]] = [
NodeConfig(
name="wait_all", type="boolean", required=False, default=True,
description="Wait for all branches to complete",
label={"en_US": "Wait for All", "zh_Hans": "等待全部完成"},
),
NodeConfig(
name="fail_fast", type="boolean", required=False, default=False,
description="Stop all branches if any fails",
label={"en_US": "Fail Fast", "zh_Hans": "快速失败"},
),
]
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
return {
"results": {},
"errors": [],
}

View File

@@ -0,0 +1,40 @@
"""Parameter Extractor Node - extract structured parameters from text
Node metadata is loaded from: ../../templates/metadata/nodes/parameter_extractor.yaml
"""
from __future__ import annotations
from typing import Any, ClassVar
from ..entities import ExecutionContext
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
@workflow_node('parameter_extractor')
class ParameterExtractorNode(WorkflowNode):
"""Parameter extractor node - extract structured parameters from text"""
type_name = "parameter_extractor"
category = "process"
icon: str = "📤"
name = "parameter_extractor"
description = "parameter_extractor"
name_zh = "参数提取器"
name_en = "Parameter Extractor"
description_zh = "使用 AI 从文本中提取结构化参数"
description_en = "Extract structured parameters from text using AI"
inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = []
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
text = inputs.get("text", "")
param_defs = self.get_config("parameters", [])
extracted = {}
for param in param_defs:
extracted[param.get("name", "")] = None
return {"parameters": extracted, "extraction_success": False}

View File

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

View File

@@ -0,0 +1,43 @@
"""Question Classifier Node - classify user questions into categories
Node metadata is loaded from: ../../templates/metadata/nodes/question_classifier.yaml
"""
from __future__ import annotations
from typing import Any, ClassVar
from ..entities import ExecutionContext
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
@workflow_node('question_classifier')
class QuestionClassifierNode(WorkflowNode):
"""Question classifier node - classify user questions into categories"""
type_name = "question_classifier"
category = "process"
icon = "🏷️"
name = "question_classifier"
description = "question_classifier"
name_zh = "问题分类器"
name_en = "Question Classifier"
description_zh = "使用 AI 将问题分类到预定义类别"
description_en = "Classify questions into predefined categories using AI"
inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = []
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
question = inputs.get("question", "")
categories = self.get_config("categories", [])
if categories:
return {
"category": categories[0].get("name", "unknown"),
"confidence": 0.8,
"all_scores": {cat.get("name"): 0.1 for cat in categories},
}
return {"category": "unknown", "confidence": 0.0, "all_scores": {}}

View File

@@ -0,0 +1,53 @@
"""Redis Operation Node - perform Redis cache operations
Node metadata is loaded from: ../../templates/metadata/nodes/redis_operation.yaml
"""
from __future__ import annotations
from typing import Any, ClassVar
from ..entities import ExecutionContext
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
@workflow_node('redis_operation')
class RedisOperationNode(WorkflowNode):
"""Redis operation node - perform Redis cache operations"""
type_name = "redis_operation"
category = "integration"
icon = "Server"
name = "redis_operation"
description = "redis_operation"
name_zh = "Redis 操作"
name_en = "Redis Operation"
description_zh = "执行 Redis 缓存操作"
description_en = "Perform Redis cache operations"
inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = []
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
connection_url = self.get_config("connection_url", "redis://localhost:6379")
operation = self.get_config("operation", "get")
key_template = self.get_config("key_template", "")
hash_field = self.get_config("hash_field", "")
ttl = self.get_config("ttl", 0)
key = inputs.get("key", key_template)
value = inputs.get("value")
return {
"result": None,
"success": False,
"_debug": {
"connection_url": connection_url,
"operation": operation,
"key": key,
"hash_field": hash_field,
"ttl": ttl,
"value": value,
},
}

View File

@@ -0,0 +1,95 @@
"""Reply Message Node - reply to the triggering message
Node metadata is loaded from: ../../templates/metadata/nodes/reply_message.yaml
"""
from __future__ import annotations
import logging
from typing import Any, ClassVar
from ..entities import ExecutionContext
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
logger = logging.getLogger(__name__)
@workflow_node('reply_message')
class ReplyMessageNode(WorkflowNode):
"""Reply message node - reply to the triggering message"""
type_name = "reply_message"
category = "action"
icon = "↩️"
name = "reply_message"
description = "reply_message"
name_zh = "回复消息"
name_en = "Reply Message"
description_zh = "回复触发工作流的消息"
description_en = "Reply to the message that triggered the workflow"
inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = []
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
message = inputs.get("message")
if message in (None, ""):
message = inputs.get("input")
if message in (None, ""):
message = inputs.get("response")
if message in (None, "") and context.message_context:
message = context.message_context.message_content
if message is None:
message = ""
template = self.get_config("message_template")
if template:
message = template
for key, value in inputs.items():
message = message.replace(f"{{{{{key}}}}}", str(value))
for key, value in context.variables.items():
message = message.replace(f"{{{{variables.{key}}}}}", str(value))
logger.info(
"ReplyMessageNode resolved message",
extra={
'node_id': self.node_id,
'execution_id': context.execution_id,
'input_keys': list(inputs.keys()),
'message_preview': str(message)[:200],
'has_template': bool(template),
'session_id': context.session_id,
},
)
if not str(message).strip():
logger.warning(
"ReplyMessageNode has empty message after resolution",
extra={
'node_id': self.node_id,
'execution_id': context.execution_id,
'input_keys': list(inputs.keys()),
},
)
# 实际发送消息
if self.ap:
from langbot_plugin.api.entities.builtin.platform.message import MessageChain, Plain
message_chain = MessageChain([Plain(text=str(message))])
await self.ap.platform_mgr.websocket_proxy_bot.adapter.send_message(
target_type='person',
target_id=f'websocket_{context.session_id}',
message=message_chain,
)
else:
logger.warning(
"ReplyMessageNode missing application instance",
extra={
'node_id': self.node_id,
'execution_id': context.execution_id,
},
)
return {"status": "sent", "message_id": f"reply_{context.execution_id}"}

View File

@@ -0,0 +1,36 @@
"""Send Message Node - send message to a target
Node metadata is loaded from: ../../templates/metadata/nodes/send_message.yaml
"""
from __future__ import annotations
from typing import Any, ClassVar
from ..entities import ExecutionContext
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
@workflow_node('send_message')
class SendMessageNode(WorkflowNode):
"""Send message node - send message to a target"""
type_name = "send_message"
category = "action"
icon = "📤"
name = "send_message"
description = "send_message"
name_zh = "发送消息"
name_en = "Send Message"
description_zh = "向聊天或用户发送消息"
description_en = "Send a message to a chat or user"
inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = []
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
message = inputs.get("message", "")
target = inputs.get("target") or self.get_config("target_id", "")
return {"status": "sent", "message_id": f"msg_{context.execution_id}"}

View File

@@ -0,0 +1,64 @@
"""Set Variable Node - set workflow or conversation variable
Node metadata is loaded from: ../../templates/metadata/nodes/set_variable.yaml
"""
from __future__ import annotations
from typing import Any, ClassVar
from ..entities import ExecutionContext
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
@workflow_node('set_variable')
class SetVariableNode(WorkflowNode):
"""Set variable node - set workflow or conversation variable"""
type_name = "set_variable"
category = "action"
icon = "📝"
name = "set_variable"
description = "set_variable"
name_zh = "设置变量"
name_en = "Set Variable"
description_zh = "设置上下文变量值"
description_en = "Set a context variable value"
inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = []
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
value = inputs.get("value")
name = self.get_config("variable_name", "")
scope = self.get_config("variable_scope", "workflow")
operation = self.get_config("operation", "set")
if scope == "conversation":
current = context.get_conversation_variable(name)
else:
current = context.get_variable(name)
if operation == "set":
final_value = value
elif operation == "append":
if isinstance(current, list):
final_value = current + [value]
elif isinstance(current, str):
final_value = current + str(value)
else:
final_value = [current, value] if current else [value]
elif operation == "increment":
final_value = (current or 0) + (value if isinstance(value, (int, float)) else 1)
elif operation == "decrement":
final_value = (current or 0) - (value if isinstance(value, (int, float)) else 1)
else:
final_value = value
if scope == "conversation":
context.set_conversation_variable(name, final_value)
else:
context.set_variable(name, final_value)
return {"value": final_value}

View File

@@ -0,0 +1,45 @@
"""Store Data Node - save data to storage
Node metadata is loaded from: ../../templates/metadata/nodes/store_data.yaml
"""
from __future__ import annotations
from typing import Any, ClassVar
from ..entities import ExecutionContext
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
@workflow_node('store_data')
class StoreDataNode(WorkflowNode):
"""Store data node - save data to storage"""
type_name = "store_data"
category = "action"
icon = "💾"
name = "store_data"
description = "store_data"
name_zh = "存储数据"
name_en = "Store Data"
description_zh = "将数据存储到持久化存储"
description_en = "Store data to persistent storage"
inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = []
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
key = inputs.get("key", "")
value = inputs.get("value")
storage_type = self.get_config("storage_type", "session")
prefix = self.get_config("key_prefix", "")
full_key = f"{prefix}{key}" if prefix else key
if storage_type == "session":
context.set_conversation_variable(full_key, value)
else:
context.set_variable(full_key, value)
return {"status": "stored"}

View File

@@ -0,0 +1,64 @@
"""Switch Node - multi-way branch based on value
Node metadata is loaded from: ../../templates/metadata/nodes/switch.yaml
"""
from __future__ import annotations
from typing import Any, ClassVar
from ..entities import ExecutionContext
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
@workflow_node('switch')
class SwitchNode(WorkflowNode):
"""Switch node - multi-way branch based on value"""
type_name = "switch"
category = "control"
icon = "🔃"
name = "switch"
description = "switch"
name_zh = "多路分支"
name_en = "Switch"
description_zh = "根据多个条件分支工作流"
description_en = "Branch workflow based on multiple cases"
inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = []
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
expression = self.get_config("expression", "")
cases = self.get_config("cases", [])
input_data = inputs.get("input")
value = await self._evaluate_expression(expression, input_data, context)
for case in cases:
if str(case.get("value")) == str(value):
return {"matched_case": input_data, "default": None, "_matched_output": case.get("output")}
return {"matched_case": None, "default": input_data}
async def _evaluate_expression(self, expression: str, data: Any, context: ExecutionContext) -> Any:
if not expression:
return data
if expression.startswith("{{") and expression.endswith("}}"):
var_path = expression[2:-2].strip()
parts = var_path.split(".")
if parts[0] == "input":
result = data
for part in parts[1:]:
if isinstance(result, dict):
result = result.get(part)
else:
return None
return result
elif parts[0] == "variables":
return context.variables.get(".".join(parts[1:]))
return expression

View File

@@ -0,0 +1,51 @@
"""Variable Aggregator Node - aggregate variables from multiple branches
Node metadata is loaded from: ../../templates/metadata/nodes/variable_aggregator.yaml
"""
from __future__ import annotations
from typing import Any, ClassVar
from ..entities import ExecutionContext
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
@workflow_node('variable_aggregator')
class VariableAggregatorNode(WorkflowNode):
"""Variable aggregator node - aggregate variables from multiple branches"""
type_name = "variable_aggregator"
category = "control"
icon = "📊"
name = "variable_aggregator"
description = "variable_aggregator"
name_zh = "变量聚合器"
name_en = "Variable Aggregator"
description_zh = "聚合多个分支的变量输出"
description_en = "Aggregate variable outputs from multiple branches"
inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = []
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
variables = inputs.get("variables", {})
mode = self.get_config("aggregation_mode", "merge")
aggregated = {}
if mode == "merge":
if isinstance(variables, dict):
aggregated.update(variables)
elif mode == "override":
if isinstance(variables, dict):
aggregated = variables.copy()
elif mode == "append":
for key, value in (variables if isinstance(variables, dict) else {}).items():
if key in aggregated and isinstance(aggregated[key], list):
aggregated[key].append(value)
else:
aggregated[key] = [value]
return {"aggregated": aggregated}

View File

@@ -0,0 +1,45 @@
"""Wait Node - pause execution for a duration
Node metadata is loaded from: ../../templates/metadata/nodes/wait.yaml
"""
from __future__ import annotations
from typing import Any, ClassVar
from ..entities import ExecutionContext
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
@workflow_node('wait')
class WaitNode(WorkflowNode):
"""Wait node - pause execution for a duration"""
type_name = "wait"
category = "control"
icon = ""
name = "wait"
description = "wait"
name_zh = "等待"
name_en = "Wait"
description_zh = "暂停工作流执行指定时间"
description_en = "Pause workflow execution for a specified duration"
inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = []
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
import asyncio
duration = self.get_config("duration", 1)
duration_type = self.get_config("duration_type", "seconds")
if duration_type == "minutes":
duration *= 60
elif duration_type == "hours":
duration *= 3600
await asyncio.sleep(duration)
return {"output": inputs.get("input")}

View File

@@ -0,0 +1,40 @@
"""Webhook Trigger Node - triggers workflow via HTTP request
Node metadata is loaded from: ../../templates/metadata/nodes/webhook_trigger.yaml
"""
from __future__ import annotations
from typing import Any, ClassVar
from ..entities import ExecutionContext
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
@workflow_node('webhook_trigger')
class WebhookTriggerNode(WorkflowNode):
"""Webhook trigger node - triggers workflow via HTTP request"""
type_name = "webhook_trigger"
category = "trigger"
icon = "🌐"
name = "webhook_trigger"
description = "webhook_trigger"
name_zh = "Webhook 触发"
name_en = "Webhook Trigger"
description_zh = "通过 HTTP 请求触发工作流"
description_en = "Trigger workflow via HTTP webhook"
inputs: ClassVar[list[NodePort]] = []
outputs: ClassVar[list[NodePort]] = []
config_schema: ClassVar[list[NodeConfig]] = []
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
trigger_data = context.trigger_data
return {
"body": trigger_data.get("body", {}),
"headers": trigger_data.get("headers", {}),
"query": trigger_data.get("query", {}),
"method": trigger_data.get("method", "POST"),
}

View File

@@ -0,0 +1,161 @@
"""Node type registry"""
from __future__ import annotations
from typing import Any, Optional
from .node import WorkflowNode, get_pending_registrations, clear_pending_registrations
class NodeTypeRegistry:
"""
Central registry for all workflow node types.
Supports both built-in and plugin-provided nodes.
"""
_instance: Optional['NodeTypeRegistry'] = None
def __init__(self):
self._nodes: dict[str, type[WorkflowNode]] = {}
self._categories: dict[str, list[str]] = {
'trigger': [],
'process': [],
'control': [],
'action': [],
'integration': [],
'misc': [],
}
@classmethod
def instance(cls) -> 'NodeTypeRegistry':
"""Get singleton instance"""
if cls._instance is None:
cls._instance = cls()
return cls._instance
def register(self, node_type: str, node_class: type[WorkflowNode]):
"""
Register a node type.
Args:
node_type: Unique type identifier
node_class: WorkflowNode subclass
"""
self._nodes[node_type] = node_class
# Add to category
category = getattr(node_class, 'category', 'misc')
if category not in self._categories:
self._categories[category] = []
if node_type not in self._categories[category]:
self._categories[category].append(node_type)
def unregister(self, node_type: str):
"""Unregister a node type"""
if node_type in self._nodes:
node_class = self._nodes[node_type]
category = getattr(node_class, 'category', 'misc')
if category in self._categories and node_type in self._categories[category]:
self._categories[category].remove(node_type)
del self._nodes[node_type]
def get(self, node_type: str) -> Optional[type[WorkflowNode]]:
"""Get node class by type. Supports both 'category.type_name' and short 'type_name' formats."""
# First try exact match (category.type_name format)
if node_type in self._nodes:
return self._nodes[node_type]
# Try short name format (e.g., 'dify_workflow' -> 'integration.dify_workflow')
# Search through all registered nodes for a matching type_name
for registered_type, node_class in self._nodes.items():
if node_class.type_name == node_type:
return node_class
return None
def create_instance(self, node_type: str, node_id: str, config: dict[str, Any], ap: Optional['app.Application'] = None) -> Optional[WorkflowNode]:
"""Create a node instance. Supports both 'category.type_name' and short 'type_name' formats."""
node_class = self.get(node_type)
if node_class:
return node_class(node_id, config, ap=ap)
return None
def list_all(self) -> list[dict[str, Any]]:
"""
Get all registered node types as schema list.
Returns:
List of node schemas
"""
return [
node_class.to_schema()
for node_class in self._nodes.values()
]
def list_by_category(self, category: str) -> list[dict[str, Any]]:
"""
Get node types by category.
Args:
category: Category name (trigger, process, control, action, integration, misc)
Returns:
List of node schemas in the category
"""
if category not in self._categories:
return []
return [
self._nodes[node_type].to_schema()
for node_type in self._categories[category]
if node_type in self._nodes
]
def get_categories(self) -> dict[str, list[dict[str, Any]]]:
"""
Get all nodes organized by category.
Returns:
Dictionary mapping category names to lists of node schemas
"""
return {
category: self.list_by_category(category)
for category in self._categories.keys()
}
def has_type(self, node_type: str) -> bool:
"""Check if a node type is registered. Supports both formats."""
return self.get(node_type) is not None
def process_pending_registrations(self):
"""Process all pending node registrations from decorators"""
for node_type, node_class in get_pending_registrations():
# Use category.type_name format for consistency with frontend
category = getattr(node_class, 'category', 'misc')
full_type = f'{category}.{node_type}'
self.register(full_type, node_class)
clear_pending_registrations()
def count(self) -> int:
"""Get total number of registered node types"""
return len(self._nodes)
def clear(self):
"""Clear all registrations (for testing)"""
self._nodes.clear()
for category in self._categories:
self._categories[category] = []
# Convenience functions for module-level access
def register_node(node_type: str, node_class: type[WorkflowNode]):
"""Register a node type to the global registry"""
NodeTypeRegistry.instance().register(node_type, node_class)
def get_node_class(node_type: str) -> Optional[type[WorkflowNode]]:
"""Get a node class from the global registry"""
return NodeTypeRegistry.instance().get(node_type)
def list_node_types() -> list[dict[str, Any]]:
"""List all registered node types"""
return NodeTypeRegistry.instance().list_all()

View File

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

View File

@@ -0,0 +1,74 @@
# Call Pipeline Node Configuration
name: call_pipeline
category: action
icon: "⚙️"
color: '#ef4444'
description: 'workflows.nodes.callPipelineDescription'
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,73 @@
# 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
category: process
icon: "💻"
color: '#3b82f6'
description: 'workflows.nodes.codeExecutorDescription'
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,122 @@
# 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
category: control
icon: "🔀"
color: '#8b5cf6'
description: 'workflows.nodes.conditionDescription'
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,80 @@
# 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
category: trigger
icon: "⏰"
color: '#22c55e'
description: 'workflows.nodes.cronTriggerDescription'
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,67 @@
# 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
category: process
icon: "🔄"
color: '#3b82f6'
description: 'workflows.nodes.dataTransformDescription'
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,110 @@
# 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
category: integration
icon: "🗄️"
color: '#ec4899'
description: 'workflows.nodes.databaseQueryDescription'
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,48 @@
# 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
category: control
icon: "🛑"
color: '#8b5cf6'
description: 'workflows.nodes.endDescription'
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,84 @@
# 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
category: trigger
icon: "📡"
color: '#22c55e'
description: 'workflows.nodes.eventTriggerDescription'
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,157 @@
# 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
category: action
icon: "🌍"
color: '#10b981'
description: 'workflows.nodes.httpRequestDescription'
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,75 @@
# Iterator Node Configuration
name: iterator
category: control
icon: "🔄"
color: '#f59e0b'
description: 'workflows.nodes.iteratorDescription'
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,111 @@
# 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
category: process
icon: "📚"
color: '#8b5cf6'
description: 'workflows.nodes.knowledgeRetrievalDescription'
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: json
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: string
default: ""
label:
en_US: Rerank Model
zh_Hans: 重排序模型
description:
en_US: Model to use for reranking results
zh_Hans: 用于结果重排序的模型

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,150 @@
# 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
category: process
icon: "🤖"
color: '#8b5cf6'
description: 'workflows.nodes.llmCallDescription'
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
type: llm-model-selector
required: true
label:
en_US: Model
zh_Hans: 模型
description:
en_US: Select the LLM model to use
zh_Hans: 选择要使用的 LLM 模型
- name: system_prompt
type: textarea
label:
en_US: System Prompt
zh_Hans: 系统提示词
description:
en_US: System prompt to set the model behavior
zh_Hans: 设置模型行为的系统提示词
- name: user_prompt_template
type: textarea
required: true
default: "{{input}}"
label:
en_US: User Prompt Template
zh_Hans: 用户提示词模板
description:
en_US: User prompt template with variable placeholders
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: enable_tools
type: boolean
default: false
label:
en_US: Enable Tools
zh_Hans: 启用工具
description:
en_US: Allow the model to use function calling tools
zh_Hans: 允许模型使用函数调用工具
- name: tools
type: json
default: []
label:
en_US: Tools
zh_Hans: 工具
description:
en_US: Select tools that the model can use
zh_Hans: 选择模型可以使用的工具

View File

@@ -0,0 +1,112 @@
# 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
category: control
icon: "🔁"
color: '#8b5cf6'
description: 'workflows.nodes.loopDescription'
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,83 @@
# 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
category: integration
icon: 🔧
color: '#ec4899'
description: workflows.nodes.mcpToolDescription
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,94 @@
# 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
category: integration
icon: "💾"
color: '#ec4899'
description: 'workflows.nodes.memoryStoreDescription'
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,72 @@
# Merge Node Configuration
name: merge
category: control
icon: "🔗"
color: '#f59e0b'
description: 'workflows.nodes.mergeDescription'
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,100 @@
# 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
category: trigger
icon: "💬"
color: '#22c55e'
description: 'workflows.nodes.messageTriggerDescription'
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: 不对机器人发送的消息触发

View File

@@ -0,0 +1,192 @@
# n8n Workflow Node Configuration
name: n8n_workflow
label:
en_US: n8n Workflow
zh_Hans: n8n 工作流
description:
en_US: Call n8n workflow API
zh_Hans: 调用 n8n 工作流 API
category: integration
icon: Workflow
color: '#3b82f6'
inputs:
- name: payload
type: object
label:
en_US: Payload
zh_Hans: 输入数据
description:
en_US: Workflow input data
zh_Hans: 工作流输入数据
required: false
outputs:
- name: result
type: any
label:
en_US: Result
zh_Hans: 结果
description:
en_US: Workflow 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: webhook-url
label:
en_US: Webhook URL
zh_Hans: Webhook URL
description:
en_US: n8n workflow webhook URL
zh_Hans: n8n 工作流 Webhook URL
type: string
required: true
default: ''
- name: auth-type
label:
en_US: Authentication Type
zh_Hans: 认证类型
description:
en_US: Authentication type for webhook call
zh_Hans: Webhook 调用的认证类型
type: select
required: true
default: none
options:
- name: none
label:
en_US: None
zh_Hans: 无认证
- name: basic
label:
en_US: Basic Auth
zh_Hans: 基本认证
- name: jwt
label:
en_US: JWT
zh_Hans: JWT认证
- name: header
label:
en_US: Header Auth
zh_Hans: 请求头认证
- name: basic-username
label:
en_US: Username
zh_Hans: 用户名
description:
en_US: Username for Basic Auth
zh_Hans: 基本认证的用户名
type: string
required: false
default: ''
show_if:
field: auth-type
operator: eq
value: basic
- name: basic-password
label:
en_US: Password
zh_Hans: 密码
description:
en_US: Password for Basic Auth
zh_Hans: 基本认证的密码
type: string
required: false
default: ''
show_if:
field: auth-type
operator: eq
value: basic
- name: jwt-secret
label:
en_US: Secret
zh_Hans: 密钥
description:
en_US: Secret for JWT authentication
zh_Hans: JWT认证的密钥
type: string
required: false
default: ''
show_if:
field: auth-type
operator: eq
value: jwt
- name: jwt-algorithm
label:
en_US: Algorithm
zh_Hans: 算法
description:
en_US: Algorithm for JWT authentication
zh_Hans: JWT认证的算法
type: string
required: false
default: HS256
show_if:
field: auth-type
operator: eq
value: jwt
- name: header-name
label:
en_US: Header Name
zh_Hans: 请求头名称
description:
en_US: Header name for Header Auth
zh_Hans: 请求头认证的名称
type: string
required: false
default: ''
show_if:
field: auth-type
operator: eq
value: header
- name: header-value
label:
en_US: Header Value
zh_Hans: 请求头值
description:
en_US: Header value for Header Auth
zh_Hans: 请求头认证的值
type: string
required: false
default: ''
show_if:
field: auth-type
operator: eq
value: header
- name: timeout
label:
en_US: Timeout (seconds)
zh_Hans: 超时时间(秒)
description:
en_US: Request timeout in seconds
zh_Hans: 请求超时时间(秒)
type: integer
required: false
default: 120
- name: output-key
label:
en_US: Output Key
zh_Hans: 输出键名
description:
en_US: Key name of output in webhook response
zh_Hans: Webhook 响应中输出内容的键名
type: string
required: false
default: response

View File

@@ -0,0 +1,57 @@
# Opening Statement Node Configuration
name: opening_statement
category: action
icon: "👋"
color: '#ef4444'
description: 'workflows.nodes.openingStatementDescription'
inputs: []
outputs:
- name: statement
type: string
label:
en_US: Statement
zh_Hans: 声明
description:
en_US: Opening statement
zh_Hans: 开场声明
- name: suggested_questions
type: array
label:
en_US: Suggested Questions
zh_Hans: 建议问题
description:
en_US: Suggested questions
zh_Hans: 建议问题
config:
- name: statement
type: textarea
required: true
label:
en_US: Statement
zh_Hans: 声明
description:
en_US: Opening statement text
zh_Hans: 开场声明文本
- name: suggested_questions
type: json
default: ["问题1", "问题2", "问题3"]
label:
en_US: Suggested Questions
zh_Hans: 建议问题
description:
en_US: List of suggested questions
zh_Hans: 建议问题列表
- name: show_suggestions
type: boolean
default: true
label:
en_US: Show Suggestions
zh_Hans: 显示建议
description:
en_US: Whether to show suggestions
zh_Hans: 是否显示建议

View File

@@ -0,0 +1,77 @@
# Parallel Node Configuration
# This file defines the metadata for the Parallel workflow node
# The corresponding Python implementation is in: pkg/workflow/nodes/parallel.py
name: parallel
category: control
icon: "⚡"
color: '#8b5cf6'
description: 'workflows.nodes.parallelDescription'
inputs:
- name: input
type: any
label:
en_US: Input
zh_Hans: 输入
description:
en_US: Input data for all branches
zh_Hans: 所有分支的输入数据
outputs:
- name: branch_1
type: any
label:
en_US: Branch 1
zh_Hans: 分支 1
description:
en_US: Branch 1 output
zh_Hans: 分支 1 输出
- name: branch_2
type: any
label:
en_US: Branch 2
zh_Hans: 分支 2
description:
en_US: Branch 2 output
zh_Hans: 分支 2 输出
- name: results
type: object
label:
en_US: Results
zh_Hans: 结果
description:
en_US: Combined results from all branches
zh_Hans: 所有分支的合并结果
config:
- name: branches
type: json
required: true
default: "[]"
label:
en_US: Branches
zh_Hans: 分支
description:
en_US: Define branches as JSON array
zh_Hans: 使用 JSON 数组定义分支
- name: wait_for_all
type: boolean
default: true
label:
en_US: Wait for All
zh_Hans: 等待全部完成
description:
en_US: Wait for all branches to complete
zh_Hans: 等待所有分支完成
- name: fail_fast
type: boolean
default: false
label:
en_US: Fail Fast
zh_Hans: 快速失败
description:
en_US: Stop all branches if any one fails
zh_Hans: 如果任何一个分支失败则停止所有分支

View File

@@ -0,0 +1,87 @@
# Parameter Extractor Node Configuration
# This file defines the metadata for the Parameter Extractor workflow node
# The corresponding Python implementation is in: pkg/workflow/nodes/parameter_extractor.py
name: parameter_extractor
category: process
icon: "🔍"
color: '#8b5cf6'
description: 'workflows.nodes.parameterExtractorDescription'
inputs:
- name: text
type: string
label:
en_US: Text
zh_Hans: 文本
description:
en_US: Text to extract parameters from
zh_Hans: 要提取参数的文本
outputs:
- name: parameters
type: object
label:
en_US: Parameters
zh_Hans: 参数
description:
en_US: Extracted parameters as key-value pairs
zh_Hans: 提取的参数键值对
- name: missing
type: array
label:
en_US: Missing
zh_Hans: 缺失项
description:
en_US: List of required parameters that could not be extracted
zh_Hans: 无法提取的必需参数列表
- name: success
type: boolean
label:
en_US: Success
zh_Hans: 成功
description:
en_US: Whether all required parameters were extracted
zh_Hans: 是否所有必需参数都已提取
config:
- name: model
type: llm-model-selector
required: true
label:
en_US: Extraction Model
zh_Hans: 提取模型
description:
en_US: Select the model for parameter extraction
zh_Hans: 选择用于参数提取的模型
- name: parameters
type: textarea
required: true
default: "[]"
label:
en_US: Parameters Schema
zh_Hans: 参数架构
description:
en_US: JSON array defining expected parameters
zh_Hans: 定义期望参数的 JSON 数组
- name: extraction_prompt
type: textarea
default: ""
label:
en_US: Extraction Prompt
zh_Hans: 提取提示
description:
en_US: Additional instructions for the extraction model
zh_Hans: 提取模型的额外指令
- name: strict_mode
type: boolean
default: true
label:
en_US: Strict Mode
zh_Hans: 严格模式
description:
en_US: Fail if any required parameter cannot be extracted
zh_Hans: 如果任何必需参数无法提取则失败

View File

@@ -0,0 +1,87 @@
# Question Classifier Node Configuration
# This file defines the metadata for the Question Classifier workflow node
# The corresponding Python implementation is in: pkg/workflow/nodes/question_classifier.py
name: question_classifier
category: process
icon: "🏷️"
color: '#8b5cf6'
description: 'workflows.nodes.questionClassifierDescription'
inputs:
- name: question
type: string
label:
en_US: Question
zh_Hans: 问题
description:
en_US: The question to classify
zh_Hans: 要分类的问题
outputs:
- name: category
type: string
label:
en_US: Category
zh_Hans: 分类
description:
en_US: The classified category
zh_Hans: 分类结果
- name: confidence
type: number
label:
en_US: Confidence
zh_Hans: 置信度
description:
en_US: Classification confidence score
zh_Hans: 分类置信度分数
- name: all_scores
type: object
label:
en_US: All Scores
zh_Hans: 所有分数
description:
en_US: Scores for all categories
zh_Hans: 所有分类的分数
config:
- name: model
type: llm-model-selector
required: true
label:
en_US: Classification Model
zh_Hans: 分类模型
description:
en_US: Select the model to use for classification
zh_Hans: 选择用于分类的模型
- name: categories
type: textarea
required: true
default: "[]"
label:
en_US: Categories Definition
zh_Hans: 分类定义
description:
en_US: Define categories in JSON format
zh_Hans: 使用 JSON 格式定义分类
- name: confidence_threshold
type: number
default: 0.7
label:
en_US: Confidence Threshold
zh_Hans: 置信度阈值
description:
en_US: Minimum confidence score required
zh_Hans: 所需的最小置信度分数
- name: fallback_category
type: string
default: other
label:
en_US: Fallback Category
zh_Hans: 默认分类
description:
en_US: Category to use when confidence is below threshold
zh_Hans: 当置信度低于阈值时使用的分类

View File

@@ -0,0 +1,113 @@
# Redis Operation Node Configuration
# This file defines the metadata for the Redis Operation workflow node
# The corresponding Python implementation is in: pkg/workflow/nodes/redis_operation.py
name: redis_operation
category: integration
icon: "🔴"
color: '#ec4899'
description: 'workflows.nodes.redisOperationDescription'
inputs:
- name: key
type: string
required: false
label:
en_US: Key
zh_Hans:
description:
en_US: Redis key
zh_Hans: Redis 键
- 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: Operation result
zh_Hans: 操作结果
- name: success
type: boolean
label:
en_US: Success
zh_Hans: 成功
description:
en_US: Whether operation was successful
zh_Hans: 操作是否成功
config:
- name: connection_url
type: string
required: true
default: "redis://localhost:6379"
label:
en_US: Redis URL
zh_Hans: Redis URL
description:
en_US: Redis connection URL
zh_Hans: Redis 连接 URL
- name: operation
type: select
required: true
default: get
options:
- get
- set
- delete
- exists
- incr
- decr
- hget
- hset
- lpush
- rpush
- lpop
- rpop
label:
en_US: Operation
zh_Hans: 操作
description:
en_US: Redis operation to perform
zh_Hans: 要执行的 Redis 操作
- name: key_template
type: string
default: ""
label:
en_US: Key Template
zh_Hans: 键模板
description:
en_US: Redis key template
zh_Hans: Redis 键模板
- name: hash_field
type: string
default: ""
label:
en_US: Hash Field
zh_Hans: 哈希字段
description:
en_US: Field name for hash operations
zh_Hans: 哈希操作的字段名
- name: ttl
type: integer
default: 0
label:
en_US: TTL (seconds)
zh_Hans: TTL
description:
en_US: Time to live for SET operations
zh_Hans: SET 操作的过期时间

View File

@@ -0,0 +1,75 @@
# Reply Message Node Configuration
name: reply_message
category: action
icon: "↩️"
color: '#ef4444'
description: 'workflows.nodes.replyMessageDescription'
inputs:
- name: message
type: string
label:
en_US: Message
zh_Hans: 消息
description:
en_US: Message to reply
zh_Hans: 要回复的消息
outputs:
- name: status
type: string
label:
en_US: Status
zh_Hans: 状态
description:
en_US: Reply status
zh_Hans: 回复状态
- name: message_id
type: string
label:
en_US: Message ID
zh_Hans: 消息 ID
description:
en_US: Message ID
zh_Hans: 消息 ID
config:
- name: reply_mode
type: select
options: ["direct", "quote", "at"]
default: "direct"
label:
en_US: Reply Mode
zh_Hans: 回复模式
description:
en_US: How to reply
zh_Hans: 回复方式
- name: message_template
type: textarea
label:
en_US: Message Template
zh_Hans: 消息模板
description:
en_US: Message template with variable interpolation
zh_Hans: 支持变量插值的消息模板
- name: long_text_processing
type: json
label:
en_US: Long Text Processing
zh_Hans: 长文本处理
description:
en_US: Long text processing settings
zh_Hans: 长文本处理设置
pipeline_config_source: "pipeline:output:long-text-processing"
- name: force_delay
type: json
label:
en_US: Force Delay
zh_Hans: 强制延迟
description:
en_US: Force delay settings
zh_Hans: 强制延迟设置
pipeline_config_source: "pipeline:output:force-delay"

View File

@@ -0,0 +1,95 @@
# Send Message Node Configuration
# This file defines the metadata for the Send Message workflow node
# The corresponding Python implementation is in: pkg/workflow/nodes/send_message.py
name: send_message
category: action
icon: "📤"
color: '#10b981'
description: 'workflows.nodes.sendMessageDescription'
inputs:
- name: content
type: string
required: false
label:
en_US: Content
zh_Hans: 内容
description:
en_US: Message content to send
zh_Hans: 要发送的消息内容
- name: context
type: object
required: false
label:
en_US: Context
zh_Hans: 上下文
description:
en_US: Message context (for reply)
zh_Hans: 消息上下文(用于回复)
outputs:
- name: message_id
type: string
label:
en_US: Message ID
zh_Hans: 消息 ID
description:
en_US: ID of the sent message
zh_Hans: 已发送消息的 ID
- name: success
type: boolean
label:
en_US: Success
zh_Hans: 成功
description:
en_US: Whether the message was sent successfully
zh_Hans: 消息是否发送成功
config:
- name: message_type
type: select
required: true
default: text
options:
- text
- markdown
- image
- file
- card
label:
en_US: Message Type
zh_Hans: 消息类型
description:
en_US: Type of message to send
zh_Hans: 要发送的消息类型
- name: content_template
type: textarea
default: ""
label:
en_US: Content Template
zh_Hans: 内容模板
description:
en_US: Message content template
zh_Hans: 消息内容模板
- name: reply_to_original
type: boolean
default: true
label:
en_US: Reply to Original
zh_Hans: 回复原消息
description:
en_US: Reply to the original message
zh_Hans: 回复原始消息
- name: at_sender
type: boolean
default: false
label:
en_US: '@ Sender'
zh_Hans: '@ 发送者'
description:
en_US: Mention the original sender
zh_Hans: 提及原始发送者

View File

@@ -0,0 +1,59 @@
# Set Variable Node Configuration
name: set_variable
category: action
icon: "📝"
color: '#ef4444'
description: 'workflows.nodes.setVariableDescription'
inputs:
- name: value
type: any
label:
en_US: Value
zh_Hans:
description:
en_US: Value to set
zh_Hans: 要设置的值
outputs:
- name: value
type: any
label:
en_US: Value
zh_Hans:
description:
en_US: Set value
zh_Hans: 设置的值
config:
- name: variable_name
type: string
required: true
label:
en_US: Variable Name
zh_Hans: 变量名
description:
en_US: Name of the variable
zh_Hans: 变量名称
- name: variable_scope
type: select
options: ["workflow", "conversation"]
default: "workflow"
label:
en_US: Variable Scope
zh_Hans: 变量作用域
description:
en_US: Scope of the variable
zh_Hans: 变量作用域
- name: operation
type: select
options: ["set", "append", "increment", "decrement"]
default: "set"
label:
en_US: Operation
zh_Hans: 操作
description:
en_US: Operation to perform
zh_Hans: 要执行的操作

View File

@@ -0,0 +1,65 @@
# Store Data Node Configuration
name: store_data
category: action
icon: "💾"
color: '#ef4444'
description: 'workflows.nodes.storeDataDescription'
inputs:
- name: key
type: string
label:
en_US: Key
zh_Hans:
description:
en_US: Storage key
zh_Hans: 存储键
- name: value
type: any
label:
en_US: Value
zh_Hans:
description:
en_US: Value to store
zh_Hans: 要存储的值
outputs:
- name: status
type: string
label:
en_US: Status
zh_Hans: 状态
description:
en_US: Store status
zh_Hans: 存储状态
config:
- name: storage_type
type: select
options: ["session", "user", "global", "database"]
default: "session"
label:
en_US: Storage Type
zh_Hans: 存储类型
description:
en_US: Type of storage
zh_Hans: 存储类型
- name: ttl
type: integer
default: 0
label:
en_US: TTL (seconds)
zh_Hans: 过期时间(秒)
description:
en_US: Time to live in seconds
zh_Hans: 存活时间(秒)
- name: key_prefix
type: string
label:
en_US: Key Prefix
zh_Hans: 键前缀
description:
en_US: Key prefix
zh_Hans: 键前缀

View File

@@ -0,0 +1,78 @@
# Switch Node Configuration
# This file defines the metadata for the Switch workflow node
# The corresponding Python implementation is in: pkg/workflow/nodes/switch.py
name: switch
category: control
icon: "🔀"
color: '#8b5cf6'
description: 'workflows.nodes.switchDescription'
inputs:
- name: input
type: any
label:
en_US: Input
zh_Hans: 输入
description:
en_US: Value to switch on
zh_Hans: 用于切换的值
outputs:
- name: case_1
type: any
label:
en_US: Case 1
zh_Hans: 情况 1
description:
en_US: Case 1 output
zh_Hans: 情况 1 输出
- name: case_2
type: any
label:
en_US: Case 2
zh_Hans: 情况 2
description:
en_US: Case 2 output
zh_Hans: 情况 2 输出
- name: default
type: any
label:
en_US: Default
zh_Hans: 默认
description:
en_US: Default output
zh_Hans: 默认输出
config:
- name: switch_expression
type: string
required: true
default: "{{input}}"
label:
en_US: Switch Expression
zh_Hans: 开关表达式
description:
en_US: Expression to evaluate for switching
zh_Hans: 用于切换的表达式
- name: cases
type: json
required: true
default: "[]"
label:
en_US: Cases
zh_Hans: 情况
description:
en_US: Define cases as JSON array
zh_Hans: 使用 JSON 数组定义情况
- name: case_sensitive
type: boolean
default: true
label:
en_US: Case Sensitive
zh_Hans: 区分大小写
description:
en_US: Whether string comparisons are case-sensitive
zh_Hans: 字符串比较是否区分大小写

View File

@@ -0,0 +1,48 @@
# Variable Aggregator Node Configuration
name: variable_aggregator
category: control
icon: "📊"
color: '#f59e0b'
description: 'workflows.nodes.variableAggregatorDescription'
inputs:
- name: variables
type: object
label:
en_US: Variables
zh_Hans: 变量
description:
en_US: Variables to aggregate
zh_Hans: 要聚合的变量
outputs:
- name: aggregated
type: object
label:
en_US: Aggregated
zh_Hans: 聚合结果
description:
en_US: Aggregated variables
zh_Hans: 聚合后的变量
config:
- name: variable_mappings
type: json
default: []
label:
en_US: Variable Mappings
zh_Hans: 变量映射
description:
en_US: Variable mapping configurations
zh_Hans: 变量映射配置
- name: aggregation_mode
type: select
options: ["merge", "override", "append"]
default: "merge"
label:
en_US: Aggregation Mode
zh_Hans: 聚合模式
description:
en_US: Mode for aggregating variables
zh_Hans: 变量聚合模式

View File

@@ -0,0 +1,65 @@
# Wait Node Configuration
# This file defines the metadata for the Wait workflow node
# The corresponding Python implementation is in: pkg/workflow/nodes/wait.py
name: wait
category: control
icon: "⏳"
color: '#8b5cf6'
description: 'workflows.nodes.waitDescription'
inputs:
- name: input
type: any
required: false
label:
en_US: Input
zh_Hans: 输入
description:
en_US: Input to pass through
zh_Hans: 传递的输入
outputs:
- name: output
type: any
label:
en_US: Output
zh_Hans: 输出
description:
en_US: Passed through input
zh_Hans: 传递的输入
config:
- name: wait_type
type: select
required: true
default: duration
options:
- duration
- until
label:
en_US: Wait Type
zh_Hans: 等待类型
description:
en_US: Type of wait operation
zh_Hans: 等待操作的类型
- name: duration
type: integer
default: 5
label:
en_US: Duration (seconds)
zh_Hans: 时长(秒)
description:
en_US: Number of seconds to wait
zh_Hans: 等待的秒数
- name: until_time
type: string
default: ""
label:
en_US: Until Time
zh_Hans: 直到时间
description:
en_US: Wait until this time
zh_Hans: 等待直到此时间

View File

@@ -0,0 +1,138 @@
# Webhook Trigger Node Configuration
# This file defines the metadata for the Webhook Trigger workflow node
# The corresponding Python implementation is in: pkg/workflow/nodes/webhook_trigger.py
name: webhook_trigger
category: trigger
icon: "🌐"
color: '#22c55e'
description: 'workflows.nodes.webhookTriggerDescription'
inputs: []
outputs:
- name: body
type: object
label:
en_US: Body
zh_Hans: 请求体
description:
en_US: Request body data
zh_Hans: 请求体数据
- name: headers
type: object
label:
en_US: Headers
zh_Hans: 请求头
description:
en_US: Request headers
zh_Hans: 请求头
- name: query
type: object
label:
en_US: Query
zh_Hans: 查询参数
description:
en_US: Query parameters
zh_Hans: 查询参数
- name: method
type: string
label:
en_US: Method
zh_Hans: HTTP 方法
description:
en_US: HTTP method
zh_Hans: HTTP 请求方法
config:
- name: webhook_path
type: string
required: true
default: ""
label:
en_US: Webhook Path
zh_Hans: Webhook 路径
description:
en_US: Unique path for this webhook
zh_Hans: 此 Webhook 的唯一路径
- name: auth_type
type: select
required: true
default: none
options:
- none
- token
- signature
- basic
label:
en_US: Authentication
zh_Hans: 认证方式
description:
en_US: How to authenticate incoming webhook requests
zh_Hans: 如何验证传入的 Webhook 请求
- name: auth_token
type: string
default: ""
label:
en_US: Auth Token
zh_Hans: 认证令牌
description:
en_US: Token or secret for authentication
zh_Hans: 用于认证的令牌或密钥
- name: allowed_ips
type: json
default: []
label:
en_US: Allowed IPs
zh_Hans: 允许的 IP
description:
en_US: List of allowed IP addresses
zh_Hans: 允许的 IP 地址列表
- name: allowed_methods
type: json
default: ["POST"]
label:
en_US: Allowed Methods
zh_Hans: 允许的方法
description:
en_US: Allowed HTTP methods
zh_Hans: 允许的 HTTP 方法
- name: content_type
type: select
default: "application/json"
options:
- "application/json"
- "application/x-www-form-urlencoded"
- "multipart/form-data"
- "text/plain"
label:
en_US: Content Type
zh_Hans: 内容类型
description:
en_US: Expected Content-Type
zh_Hans: 期望的内容类型
- name: validation
type: json
default: "{}"
label:
en_US: Validation Rules
zh_Hans: 验证规则
description:
en_US: Request validation rules
zh_Hans: 请求验证规则
- name: timeout
type: integer
default: 30
label:
en_US: Timeout (seconds)
zh_Hans: 超时时间(秒)
description:
en_US: Request timeout in seconds
zh_Hans: 请求超时时间(秒)

View File

View File

@@ -0,0 +1,351 @@
"""Tests for the workflow execution engine."""
import sys
import os
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..', 'src'))
from langbot.pkg.workflow.entities import (
WorkflowDefinition,
NodeDefinition,
EdgeDefinition,
ExecutionContext,
ExecutionStatus,
NodeStatus,
MessageContext,
)
from langbot.pkg.workflow.executor import WorkflowExecutor, LoopExecutor
from langbot.pkg.workflow.node import WorkflowNode, NodePort
from langbot.pkg.workflow.registry import NodeTypeRegistry
# ── Test helpers ─────────────────────────────────────────────────────
class PassthroughNode(WorkflowNode):
"""Simple node that passes input to output."""
type_name = "passthrough"
category = "process"
async def execute(self, inputs, context):
return {"output": inputs.get("input", "default")}
class FailingNode(WorkflowNode):
"""Node that always fails."""
type_name = "failing"
category = "process"
async def execute(self, inputs, context):
raise RuntimeError("intentional failure")
class AccumulatorNode(WorkflowNode):
"""Node that appends its id to a context variable for tracking execution order."""
type_name = "accumulator"
category = "process"
async def execute(self, inputs, context):
order = context.variables.get("_exec_order", [])
order.append(self.node_id)
context.variables["_exec_order"] = order
return {"output": self.node_id}
class ConditionTrueNode(WorkflowNode):
"""Node that outputs a truthy value."""
type_name = "cond_true"
category = "control"
async def execute(self, inputs, context):
return {"result": True}
def _make_registry(*node_classes) -> NodeTypeRegistry:
"""Create a fresh registry with given node classes."""
reg = NodeTypeRegistry()
for cls in node_classes:
cat = getattr(cls, 'category', 'process')
reg.register(f"{cat}.{cls.type_name}", cls)
return reg
def _make_context(workflow_id="wf-test") -> ExecutionContext:
return ExecutionContext(
execution_id="exec-test",
workflow_id=workflow_id,
)
def _node(id: str, type: str, config=None) -> NodeDefinition:
return NodeDefinition(id=id, type=type, config=config or {})
def _edge(id: str, src: str, tgt: str, condition=None) -> EdgeDefinition:
return EdgeDefinition(
id=id, source_node=src, target_node=tgt, condition=condition
)
# ── Tests ────────────────────────────────────────────────────────────
class TestLinearWorkflow:
"""Test simple linear A → B → C workflows."""
@pytest.mark.asyncio
async def test_single_node(self):
reg = _make_registry(PassthroughNode)
executor = WorkflowExecutor()
executor.registry = reg
wf = WorkflowDefinition(
uuid="wf-1", name="test",
nodes=[_node("n1", "process.passthrough")],
edges=[],
)
ctx = _make_context()
result = await executor.execute(wf, ctx)
assert result.status == ExecutionStatus.COMPLETED
assert result.node_states["n1"].status == NodeStatus.COMPLETED
@pytest.mark.asyncio
async def test_two_node_chain(self):
reg = _make_registry(AccumulatorNode)
executor = WorkflowExecutor()
executor.registry = reg
wf = WorkflowDefinition(
uuid="wf-2", name="test",
nodes=[
_node("a", "process.accumulator"),
_node("b", "process.accumulator"),
],
edges=[_edge("e1", "a", "b")],
)
ctx = _make_context()
result = await executor.execute(wf, ctx)
assert result.status == ExecutionStatus.COMPLETED
assert result.variables["_exec_order"] == ["a", "b"]
@pytest.mark.asyncio
async def test_three_node_chain(self):
reg = _make_registry(AccumulatorNode)
executor = WorkflowExecutor()
executor.registry = reg
wf = WorkflowDefinition(
uuid="wf-3", name="test",
nodes=[
_node("a", "process.accumulator"),
_node("b", "process.accumulator"),
_node("c", "process.accumulator"),
],
edges=[
_edge("e1", "a", "b"),
_edge("e2", "b", "c"),
],
)
ctx = _make_context()
result = await executor.execute(wf, ctx)
assert result.status == ExecutionStatus.COMPLETED
assert result.variables["_exec_order"] == ["a", "b", "c"]
class TestFailureHandling:
"""Test node failure and retry behavior."""
@pytest.mark.asyncio
async def test_failing_node_marks_failed(self):
reg = _make_registry(FailingNode)
executor = WorkflowExecutor()
executor.registry = reg
wf = WorkflowDefinition(
uuid="wf-fail", name="test",
nodes=[_node("n1", "process.failing")],
edges=[],
settings={"max_retries": 0},
)
ctx = _make_context()
result = await executor.execute(wf, ctx)
assert result.status == ExecutionStatus.FAILED
assert result.node_states["n1"].status == NodeStatus.FAILED
assert "intentional failure" in result.node_states["n1"].error
@pytest.mark.asyncio
async def test_failure_stops_downstream(self):
reg = _make_registry(FailingNode, AccumulatorNode)
executor = WorkflowExecutor()
executor.registry = reg
wf = WorkflowDefinition(
uuid="wf-stop", name="test",
nodes=[
_node("a", "process.failing"),
_node("b", "process.accumulator"),
],
edges=[_edge("e1", "a", "b")],
settings={"max_retries": 0},
)
ctx = _make_context()
result = await executor.execute(wf, ctx)
assert result.node_states["a"].status == NodeStatus.FAILED
# b should not have been executed
assert result.node_states["b"].status == NodeStatus.PENDING
class TestConditionalEdges:
"""Test edge condition evaluation."""
@pytest.mark.asyncio
async def test_true_condition_passes(self):
reg = _make_registry(AccumulatorNode)
executor = WorkflowExecutor()
executor.registry = reg
wf = WorkflowDefinition(
uuid="wf-cond", name="test",
nodes=[
_node("a", "process.accumulator"),
_node("b", "process.accumulator"),
],
edges=[_edge("e1", "a", "b", condition="1 == 1")],
)
ctx = _make_context()
result = await executor.execute(wf, ctx)
assert result.variables["_exec_order"] == ["a", "b"]
@pytest.mark.asyncio
async def test_false_condition_skips(self):
reg = _make_registry(AccumulatorNode)
executor = WorkflowExecutor()
executor.registry = reg
wf = WorkflowDefinition(
uuid="wf-cond2", name="test",
nodes=[
_node("a", "process.accumulator"),
_node("b", "process.accumulator"),
],
edges=[_edge("e1", "a", "b", condition="1 == 2")],
)
ctx = _make_context()
result = await executor.execute(wf, ctx)
# Only a should execute; b is skipped because condition is false
assert result.variables["_exec_order"] == ["a"]
class TestDiamondGraph:
"""Test diamond-shaped DAG: A → B, A → C, B → D, C → D."""
@pytest.mark.asyncio
async def test_diamond_executes_all(self):
"""D should execute once (not be skipped as circular)."""
reg = _make_registry(AccumulatorNode)
executor = WorkflowExecutor()
executor.registry = reg
wf = WorkflowDefinition(
uuid="wf-diamond", name="test",
nodes=[
_node("a", "process.accumulator"),
_node("b", "process.accumulator"),
_node("c", "process.accumulator"),
_node("d", "process.accumulator"),
],
edges=[
_edge("e1", "a", "b"),
_edge("e2", "a", "c"),
_edge("e3", "b", "d"),
_edge("e4", "c", "d"),
],
)
ctx = _make_context()
result = await executor.execute(wf, ctx)
assert result.status == ExecutionStatus.COMPLETED
# All four nodes should complete
for nid in ["a", "b", "c", "d"]:
assert result.node_states[nid].status == NodeStatus.COMPLETED
class TestUnknownNodeType:
"""Test handling of unregistered node types."""
@pytest.mark.asyncio
async def test_unknown_type_fails(self):
reg = _make_registry() # empty registry
executor = WorkflowExecutor()
executor.registry = reg
wf = WorkflowDefinition(
uuid="wf-unk", name="test",
nodes=[_node("n1", "process.nonexistent")],
edges=[],
)
ctx = _make_context()
result = await executor.execute(wf, ctx)
assert result.node_states["n1"].status == NodeStatus.FAILED
assert "Unknown node type" in result.node_states["n1"].error
class TestMessageContext:
"""Test that message context is available to nodes."""
@pytest.mark.asyncio
async def test_message_context_in_inputs(self):
reg = _make_registry(PassthroughNode)
executor = WorkflowExecutor()
executor.registry = reg
wf = WorkflowDefinition(
uuid="wf-msg", name="test",
nodes=[_node("n1", "process.passthrough")],
edges=[],
)
ctx = _make_context()
ctx.message_context = MessageContext(
message_id="msg-1",
message_content="hello world",
sender_id="user-1",
)
result = await executor.execute(wf, ctx)
assert result.status == ExecutionStatus.COMPLETED
# message_content should be in the resolved inputs
n1_inputs = result.node_states["n1"].inputs
assert n1_inputs.get("message_content") == "hello world"
class TestExecutionHistory:
"""Test that execution steps are recorded."""
@pytest.mark.asyncio
async def test_history_recorded(self):
reg = _make_registry(AccumulatorNode)
executor = WorkflowExecutor()
executor.registry = reg
wf = WorkflowDefinition(
uuid="wf-hist", name="test",
nodes=[
_node("a", "process.accumulator"),
_node("b", "process.accumulator"),
],
edges=[_edge("e1", "a", "b")],
)
ctx = _make_context()
result = await executor.execute(wf, ctx)
assert len(result.history) == 2
assert result.history[0].node_id == "a"
assert result.history[1].node_id == "b"
assert result.history[0].status == "completed"

View File

@@ -0,0 +1,133 @@
"""Tests for the node type registry."""
import sys
import os
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..', 'src'))
from langbot.pkg.workflow.node import WorkflowNode, NodePort
from langbot.pkg.workflow.registry import NodeTypeRegistry
class DummyNode(WorkflowNode):
type_name = "dummy"
category = "process"
name = "dummy"
description = "A dummy node"
async def execute(self, inputs, context):
return {"output": "ok"}
class TriggerNode(WorkflowNode):
type_name = "my_trigger"
category = "trigger"
name = "my_trigger"
async def execute(self, inputs, context):
return {}
class TestRegistryBasics:
def setup_method(self):
self.reg = NodeTypeRegistry()
def test_register_and_get(self):
self.reg.register("process.dummy", DummyNode)
assert self.reg.get("process.dummy") is DummyNode
def test_get_missing_returns_none(self):
assert self.reg.get("process.nonexistent") is None
def test_has_type(self):
self.reg.register("process.dummy", DummyNode)
assert self.reg.has_type("process.dummy") is True
assert self.reg.has_type("process.missing") is False
def test_count(self):
assert self.reg.count() == 0
self.reg.register("process.dummy", DummyNode)
assert self.reg.count() == 1
def test_clear(self):
self.reg.register("process.dummy", DummyNode)
self.reg.clear()
assert self.reg.count() == 0
def test_unregister(self):
self.reg.register("process.dummy", DummyNode)
self.reg.unregister("process.dummy")
assert self.reg.get("process.dummy") is None
assert self.reg.count() == 0
class TestRegistryLookupFormats:
"""Test that both full and short name lookups work."""
def setup_method(self):
self.reg = NodeTypeRegistry()
self.reg.register("process.dummy", DummyNode)
def test_full_name_lookup(self):
assert self.reg.get("process.dummy") is DummyNode
def test_short_name_lookup(self):
"""Short name (type_name only) should also resolve."""
assert self.reg.get("dummy") is DummyNode
class TestRegistryCategories:
def setup_method(self):
self.reg = NodeTypeRegistry()
self.reg.register("process.dummy", DummyNode)
self.reg.register("trigger.my_trigger", TriggerNode)
def test_list_by_category(self):
process_nodes = self.reg.list_by_category("process")
assert len(process_nodes) == 1
assert process_nodes[0]["type"] == "process.dummy"
def test_list_by_category_empty(self):
assert self.reg.list_by_category("action") == []
def test_get_categories(self):
cats = self.reg.get_categories()
assert "process" in cats
assert "trigger" in cats
assert len(cats["process"]) == 1
assert len(cats["trigger"]) == 1
class TestCreateInstance:
def setup_method(self):
self.reg = NodeTypeRegistry()
self.reg.register("process.dummy", DummyNode)
def test_create_instance(self):
inst = self.reg.create_instance("process.dummy", "node-1", {"key": "val"})
assert inst is not None
assert inst.node_id == "node-1"
assert inst.config == {"key": "val"}
def test_create_instance_short_name(self):
inst = self.reg.create_instance("dummy", "node-2", {})
assert inst is not None
def test_create_instance_missing(self):
inst = self.reg.create_instance("process.nonexistent", "node-3", {})
assert inst is None
class TestNodeSchema:
"""Test to_schema() output."""
def test_schema_has_required_fields(self):
schema = DummyNode.to_schema()
assert schema["type"] == "process.dummy"
assert schema["category"] == "process"
assert "label" in schema
assert "description" in schema
assert "inputs" in schema
assert "outputs" in schema
assert "config_schema" in schema

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