mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-02 03:55:55 +00:00
后端没修完版
This commit is contained in:
163
compare_nodes.py
Normal file
163
compare_nodes.py
Normal 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")
|
||||
713
docs/development/workflow-system.md
Normal file
713
docs/development/workflow-system.md
Normal 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)
|
||||
425
docs/user-guide/workflow-guide.md
Normal file
425
docs/user-guide/workflow-guide.md
Normal 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. 查看每次执行的状态、耗时、输入输出
|
||||
|
||||
### Q6:Workflow 可以被多个 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
1468
node_comparison.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
||||
# Workflow router group
|
||||
from .workflows import WorkflowsRouterGroup, ExecutionsRouterGroup
|
||||
from .websocket_chat import WorkflowWebSocketChatRouterGroup
|
||||
|
||||
__all__ = ['WorkflowsRouterGroup', 'ExecutionsRouterGroup', 'WorkflowWebSocketChatRouterGroup']
|
||||
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
1132
src/langbot/pkg/api/http/service/workflow.py
Normal file
1132
src/langbot/pkg/api/http/service/workflow.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}')
|
||||
|
||||
@@ -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,
|
||||
|
||||
127
src/langbot/pkg/entity/persistence/workflow.py
Normal file
127
src/langbot/pkg/entity/persistence/workflow.py
Normal 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)
|
||||
141
src/langbot/pkg/persistence/migrations/dbm026_workflow_tables.py
Normal file
141
src/langbot/pkg/persistence/migrations/dbm026_workflow_tables.py
Normal 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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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()))
|
||||
|
||||
|
||||
53
src/langbot/pkg/workflow/__init__.py
Normal file
53
src/langbot/pkg/workflow/__init__.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""Workflow package for LangBot
|
||||
|
||||
This package provides a visual workflow system for LangBot, including:
|
||||
- Workflow definition models
|
||||
- Execution engine
|
||||
- Node types (trigger, process, control, action, integration)
|
||||
- Trigger system for automation
|
||||
"""
|
||||
|
||||
from .entities import (
|
||||
WorkflowDefinition,
|
||||
NodeDefinition,
|
||||
EdgeDefinition,
|
||||
Position,
|
||||
PortDefinition,
|
||||
TriggerDefinition,
|
||||
WorkflowSettings,
|
||||
ExecutionContext,
|
||||
NodeState,
|
||||
ExecutionStatus,
|
||||
NodeStatus,
|
||||
)
|
||||
|
||||
from .node import WorkflowNode, NodePort, NodeConfig, workflow_node
|
||||
from .registry import NodeTypeRegistry
|
||||
from .executor import WorkflowExecutor
|
||||
|
||||
# Import nodes module to trigger node registration
|
||||
from . import nodes as nodes
|
||||
|
||||
__all__ = [
|
||||
# Entities
|
||||
'WorkflowDefinition',
|
||||
'NodeDefinition',
|
||||
'EdgeDefinition',
|
||||
'Position',
|
||||
'PortDefinition',
|
||||
'TriggerDefinition',
|
||||
'WorkflowSettings',
|
||||
'ExecutionContext',
|
||||
'NodeState',
|
||||
'ExecutionStatus',
|
||||
'NodeStatus',
|
||||
# Node
|
||||
'WorkflowNode',
|
||||
'NodePort',
|
||||
'NodeConfig',
|
||||
'workflow_node',
|
||||
# Registry
|
||||
'NodeTypeRegistry',
|
||||
# Executor
|
||||
'WorkflowExecutor',
|
||||
]
|
||||
278
src/langbot/pkg/workflow/entities.py
Normal file
278
src/langbot/pkg/workflow/entities.py
Normal file
@@ -0,0 +1,278 @@
|
||||
"""Workflow entities and data models"""
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
import pydantic
|
||||
|
||||
|
||||
class Position(pydantic.BaseModel):
|
||||
"""Node position on canvas"""
|
||||
x: float = 0
|
||||
y: float = 0
|
||||
|
||||
|
||||
class PortDefinition(pydantic.BaseModel):
|
||||
"""Node port definition"""
|
||||
name: str
|
||||
type: str = "any" # any, string, number, boolean, object, array
|
||||
description: str = ""
|
||||
required: bool = True
|
||||
|
||||
|
||||
class NodeDefinition(pydantic.BaseModel):
|
||||
"""Workflow node definition"""
|
||||
id: str
|
||||
type: str
|
||||
name: str = ""
|
||||
position: Position = Position()
|
||||
config: dict[str, Any] = {}
|
||||
inputs: list[PortDefinition] = []
|
||||
outputs: list[PortDefinition] = []
|
||||
|
||||
# UI metadata
|
||||
description: str = ""
|
||||
comment: str = "" # User comment/annotation
|
||||
|
||||
|
||||
class EdgeDefinition(pydantic.BaseModel):
|
||||
"""Workflow edge definition (connection between nodes)"""
|
||||
id: str
|
||||
source_node: str
|
||||
source_port: str = "output"
|
||||
target_node: str
|
||||
target_port: str = "input"
|
||||
condition: Optional[str] = None # Optional condition expression
|
||||
|
||||
|
||||
class TriggerDefinition(pydantic.BaseModel):
|
||||
"""Workflow trigger definition"""
|
||||
id: str
|
||||
type: str # message, cron, event, webhook
|
||||
config: dict[str, Any] = {}
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class WorkflowSettings(pydantic.BaseModel):
|
||||
"""Workflow settings"""
|
||||
# Execution settings
|
||||
max_execution_time: int = 300 # seconds
|
||||
max_retries: int = 3
|
||||
retry_delay: int = 5 # seconds
|
||||
|
||||
# Error handling
|
||||
error_handling: str = "stop" # stop, continue, retry
|
||||
|
||||
# Logging
|
||||
log_level: str = "info"
|
||||
save_execution_history: bool = True
|
||||
|
||||
# Concurrency
|
||||
max_concurrent_executions: int = 10
|
||||
|
||||
|
||||
class SafetyConfig(pydantic.BaseModel):
|
||||
"""Safety configuration (inherited from Pipeline)"""
|
||||
content_filter: dict[str, Any] = {
|
||||
"enable": False,
|
||||
"sensitive_words": [],
|
||||
"replace_with": "***"
|
||||
}
|
||||
rate_limit: dict[str, Any] = {
|
||||
"enable": False,
|
||||
"requests_per_minute": 60,
|
||||
"burst_limit": 10
|
||||
}
|
||||
|
||||
|
||||
class OutputConfig(pydantic.BaseModel):
|
||||
"""Output configuration (inherited from Pipeline)"""
|
||||
long_text_processing: dict[str, Any] = {
|
||||
"strategy": "split", # split, truncate, file
|
||||
"max_length": 4000,
|
||||
"split_separator": "\n\n"
|
||||
}
|
||||
force_delay: dict[str, Any] = {
|
||||
"enable": False,
|
||||
"min_delay_ms": 0,
|
||||
"max_delay_ms": 0
|
||||
}
|
||||
misc: dict[str, Any] = {}
|
||||
|
||||
|
||||
class WorkflowGlobalConfig(pydantic.BaseModel):
|
||||
"""Workflow global configuration (inherited from Pipeline capabilities)"""
|
||||
safety: SafetyConfig = SafetyConfig()
|
||||
output: OutputConfig = OutputConfig()
|
||||
|
||||
|
||||
class ExtensionsPreferences(pydantic.BaseModel):
|
||||
"""Extensions preferences (same as Pipeline)"""
|
||||
enable_all_plugins: bool = True
|
||||
enable_all_mcp_servers: bool = True
|
||||
plugins: list[str] = []
|
||||
mcp_servers: list[str] = []
|
||||
|
||||
|
||||
class ConversationVariable(pydantic.BaseModel):
|
||||
"""Conversation-level variable definition"""
|
||||
name: str
|
||||
type: str = "string" # string, number, boolean, object, array
|
||||
description: str = ""
|
||||
default_value: Any = None
|
||||
max_length: Optional[int] = None # For strings
|
||||
|
||||
|
||||
class WorkflowDefinition(pydantic.BaseModel):
|
||||
"""Complete workflow definition"""
|
||||
uuid: str
|
||||
name: str
|
||||
description: str = ""
|
||||
emoji: str = "🔄"
|
||||
version: int = 1
|
||||
|
||||
# Workflow graph
|
||||
nodes: list[NodeDefinition] = []
|
||||
edges: list[EdgeDefinition] = []
|
||||
|
||||
# Variables
|
||||
variables: dict[str, Any] = {} # Global variables
|
||||
conversation_variables: list[ConversationVariable] = [] # Session-level variables
|
||||
|
||||
# Settings
|
||||
settings: WorkflowSettings = WorkflowSettings()
|
||||
|
||||
# Triggers (for automation)
|
||||
triggers: list[TriggerDefinition] = []
|
||||
|
||||
# Global configuration (inherited from Pipeline)
|
||||
global_config: WorkflowGlobalConfig = WorkflowGlobalConfig()
|
||||
|
||||
# Extensions
|
||||
extensions_preferences: ExtensionsPreferences = ExtensionsPreferences()
|
||||
|
||||
# Metadata
|
||||
is_enabled: bool = True
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
# Source tracking (for imported workflows)
|
||||
source: Optional[str] = None # dify, n8n, langflow, etc.
|
||||
source_id: Optional[str] = None
|
||||
|
||||
|
||||
class ExecutionStatus(enum.Enum):
|
||||
"""Workflow execution status"""
|
||||
PENDING = "pending"
|
||||
RUNNING = "running"
|
||||
WAITING = "waiting"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class NodeStatus(enum.Enum):
|
||||
"""Node execution status"""
|
||||
PENDING = "pending"
|
||||
RUNNING = "running"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
SKIPPED = "skipped"
|
||||
|
||||
|
||||
class NodeState(pydantic.BaseModel):
|
||||
"""Runtime state of a node during execution"""
|
||||
node_id: str
|
||||
status: NodeStatus = NodeStatus.PENDING
|
||||
inputs: dict[str, Any] = {}
|
||||
outputs: dict[str, Any] = {}
|
||||
start_time: Optional[datetime] = None
|
||||
end_time: Optional[datetime] = None
|
||||
error: Optional[str] = None
|
||||
retry_count: int = 0
|
||||
|
||||
|
||||
class MessageContext(pydantic.BaseModel):
|
||||
"""Message context for message-triggered workflows"""
|
||||
message_id: str
|
||||
message_content: str
|
||||
sender_id: str
|
||||
sender_name: str = ""
|
||||
platform: str = ""
|
||||
conversation_id: str = ""
|
||||
is_group: bool = False
|
||||
group_id: Optional[str] = None
|
||||
mentions: list[str] = []
|
||||
reply_to: Optional[str] = None
|
||||
raw_message: dict[str, Any] = {}
|
||||
|
||||
|
||||
class ExecutionStep(pydantic.BaseModel):
|
||||
"""Execution history step"""
|
||||
timestamp: datetime
|
||||
node_id: str
|
||||
node_type: str
|
||||
status: str
|
||||
inputs: dict[str, Any] = {}
|
||||
outputs: dict[str, Any] = {}
|
||||
duration_ms: int = 0
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class ExecutionContext(pydantic.BaseModel):
|
||||
"""Workflow execution context"""
|
||||
execution_id: str
|
||||
workflow_id: str
|
||||
workflow_version: int = 1
|
||||
status: ExecutionStatus = ExecutionStatus.PENDING
|
||||
|
||||
# Runtime data
|
||||
variables: dict[str, Any] = {}
|
||||
conversation_variables: dict[str, Any] = {} # Session-level persistent variables
|
||||
node_states: dict[str, NodeState] = {}
|
||||
memory: dict[str, Any] = {} # Workflow memory for storing/retrieving data
|
||||
|
||||
# Timing
|
||||
start_time: Optional[datetime] = None
|
||||
end_time: Optional[datetime] = None
|
||||
|
||||
# Error
|
||||
error: Optional[str] = None
|
||||
|
||||
# Message context (if triggered by message)
|
||||
message_context: Optional[MessageContext] = None
|
||||
|
||||
# Trigger info
|
||||
trigger_type: Optional[str] = None
|
||||
trigger_data: dict[str, Any] = {}
|
||||
|
||||
# Execution history
|
||||
history: list[ExecutionStep] = []
|
||||
|
||||
# Session info
|
||||
session_id: Optional[str] = None
|
||||
user_id: Optional[str] = None
|
||||
bot_id: Optional[str] = None
|
||||
|
||||
def get_node_output(self, node_id: str, output_name: str = "output") -> Any:
|
||||
"""Get output from a specific node"""
|
||||
if node_id in self.node_states:
|
||||
return self.node_states[node_id].outputs.get(output_name)
|
||||
return None
|
||||
|
||||
def set_variable(self, name: str, value: Any):
|
||||
"""Set a workflow variable"""
|
||||
self.variables[name] = value
|
||||
|
||||
def get_variable(self, name: str, default: Any = None) -> Any:
|
||||
"""Get a workflow variable"""
|
||||
return self.variables.get(name, default)
|
||||
|
||||
def set_conversation_variable(self, name: str, value: Any):
|
||||
"""Set a conversation-level variable (persisted across executions)"""
|
||||
self.conversation_variables[name] = value
|
||||
|
||||
def get_conversation_variable(self, name: str, default: Any = None) -> Any:
|
||||
"""Get a conversation-level variable"""
|
||||
return self.conversation_variables.get(name, default)
|
||||
1283
src/langbot/pkg/workflow/executor.py
Normal file
1283
src/langbot/pkg/workflow/executor.py
Normal file
File diff suppressed because it is too large
Load Diff
280
src/langbot/pkg/workflow/node.py
Normal file
280
src/langbot/pkg/workflow/node.py
Normal file
@@ -0,0 +1,280 @@
|
||||
"""Workflow node base class and decorators"""
|
||||
from __future__ import annotations
|
||||
|
||||
import abc
|
||||
from typing import Any, Callable, Optional, TYPE_CHECKING
|
||||
|
||||
import pydantic
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .entities import ExecutionContext
|
||||
from ..core import app
|
||||
|
||||
|
||||
class NodePort(pydantic.BaseModel):
|
||||
"""Node port definition"""
|
||||
name: str
|
||||
type: str = "any" # any, string, number, boolean, object, array
|
||||
description: str = ""
|
||||
required: bool = True
|
||||
|
||||
|
||||
class NodeConfig(pydantic.BaseModel):
|
||||
"""Node configuration field definition"""
|
||||
name: str
|
||||
type: str # string, integer, number, boolean, select, json, secret, etc.
|
||||
required: bool = False
|
||||
default: Any = None
|
||||
description: str = ""
|
||||
options: Optional[list[str]] = None # For select type
|
||||
|
||||
# Validation
|
||||
min_value: Optional[float] = None
|
||||
max_value: Optional[float] = None
|
||||
min_length: Optional[int] = None
|
||||
max_length: Optional[int] = None
|
||||
pattern: Optional[str] = None # Regex pattern
|
||||
|
||||
# UI hints
|
||||
placeholder: str = ""
|
||||
show_if: Optional[dict] = None # Conditional display
|
||||
|
||||
# Pipeline config source (for reusing Pipeline config metadata)
|
||||
pipeline_config_source: Optional[str] = None # e.g., "pipeline:trigger"
|
||||
|
||||
# i18n support for label
|
||||
label: Optional[dict[str, str]] = None # e.g., {"en_US": "Name", "zh_Hans": "名称"}
|
||||
label_zh: Optional[str] = None # Chinese label
|
||||
label_en: Optional[str] = None # English label
|
||||
|
||||
|
||||
class WorkflowNode(abc.ABC):
|
||||
"""Base class for all workflow nodes"""
|
||||
|
||||
# Node metadata
|
||||
type_name: str = ""
|
||||
name: str = ""
|
||||
description: str = ""
|
||||
category: str = "misc" # trigger, process, control, action, integration
|
||||
icon: str = ""
|
||||
|
||||
# Port definitions
|
||||
inputs: list[NodePort] = []
|
||||
outputs: list[NodePort] = []
|
||||
|
||||
# Configuration schema
|
||||
config_schema: list[NodeConfig] = []
|
||||
|
||||
# Pipeline config reuse
|
||||
config_schema_source: Optional[str] = None # e.g., "pipeline:ai"
|
||||
config_stages: list[str] = [] # Specific stages to reuse
|
||||
|
||||
def __init__(self, node_id: str, config: dict[str, Any], ap: Optional['app.Application'] = None):
|
||||
"""Initialize node with ID and configuration"""
|
||||
self.node_id = node_id
|
||||
self.config = config
|
||||
self.ap = ap # Reference to the application instance for accessing services
|
||||
|
||||
@abc.abstractmethod
|
||||
async def execute(
|
||||
self,
|
||||
inputs: dict[str, Any],
|
||||
context: ExecutionContext
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Execute the node logic.
|
||||
|
||||
Args:
|
||||
inputs: Input data from connected nodes
|
||||
context: Execution context with workflow state
|
||||
|
||||
Returns:
|
||||
Dictionary of output values
|
||||
"""
|
||||
pass
|
||||
|
||||
async def validate_inputs(self, inputs: dict[str, Any]) -> list[str]:
|
||||
"""
|
||||
Validate input data against port definitions.
|
||||
|
||||
Returns:
|
||||
List of validation error messages (empty if valid)
|
||||
"""
|
||||
errors = []
|
||||
for port in self.inputs:
|
||||
if port.required and port.name not in inputs:
|
||||
errors.append(f"Missing required input: {port.name}")
|
||||
return errors
|
||||
|
||||
async def validate_config(self) -> list[str]:
|
||||
"""
|
||||
Validate node configuration.
|
||||
|
||||
Returns:
|
||||
List of validation error messages (empty if valid)
|
||||
"""
|
||||
errors = []
|
||||
for cfg in self.config_schema:
|
||||
if cfg.required and cfg.name not in self.config:
|
||||
errors.append(f"Missing required config: {cfg.name}")
|
||||
elif cfg.name in self.config:
|
||||
value = self.config[cfg.name]
|
||||
# Type validation
|
||||
if cfg.type == "integer" and not isinstance(value, int):
|
||||
errors.append(f"Config {cfg.name} must be an integer")
|
||||
elif cfg.type == "number" and not isinstance(value, (int, float)):
|
||||
errors.append(f"Config {cfg.name} must be a number")
|
||||
elif cfg.type == "boolean" and not isinstance(value, bool):
|
||||
errors.append(f"Config {cfg.name} must be a boolean")
|
||||
# Range validation
|
||||
if cfg.min_value is not None and isinstance(value, (int, float)):
|
||||
if value < cfg.min_value:
|
||||
errors.append(f"Config {cfg.name} must be >= {cfg.min_value}")
|
||||
if cfg.max_value is not None and isinstance(value, (int, float)):
|
||||
if value > cfg.max_value:
|
||||
errors.append(f"Config {cfg.name} must be <= {cfg.max_value}")
|
||||
return errors
|
||||
|
||||
# Type mapping from backend to frontend DynamicFormItemType
|
||||
_TYPE_MAP = {
|
||||
'string': 'string',
|
||||
'integer': 'integer',
|
||||
'number': 'float',
|
||||
'boolean': 'boolean',
|
||||
'select': 'select',
|
||||
'json': 'text',
|
||||
'textarea': 'text',
|
||||
'secret': 'secret',
|
||||
'llm-model-selector': 'llm-model-selector',
|
||||
'embedding-model-selector': 'embedding-model-selector',
|
||||
'rerank-model-selector': 'rerank-model-selector',
|
||||
'knowledge-base-selector': 'knowledge-base-selector',
|
||||
'knowledge-base-multi-selector': 'knowledge-base-multi-selector',
|
||||
'bot-selector': 'bot-selector',
|
||||
'tools-selector': 'tools-selector',
|
||||
'model-fallback-selector': 'model-fallback-selector',
|
||||
'prompt-editor': 'prompt-editor',
|
||||
}
|
||||
|
||||
def get_config(self, key: str, default: Any = None) -> Any:
|
||||
"""Get configuration value with default"""
|
||||
return self.config.get(key, default)
|
||||
|
||||
@classmethod
|
||||
def _config_to_schema_item(cls, cfg: NodeConfig) -> dict[str, Any]:
|
||||
"""Convert a NodeConfig to frontend-compatible schema item"""
|
||||
# Map type to frontend type
|
||||
frontend_type = cls._TYPE_MAP.get(cfg.type, 'string')
|
||||
|
||||
# Build i18n label from name
|
||||
label = {
|
||||
'zh_Hans': cfg.name,
|
||||
'en_US': cfg.name,
|
||||
}
|
||||
|
||||
# Build i18n description
|
||||
desc = cfg.description or ''
|
||||
description = {
|
||||
'zh_Hans': desc,
|
||||
'en_US': desc,
|
||||
}
|
||||
|
||||
result = {
|
||||
'id': cfg.name,
|
||||
'name': cfg.name,
|
||||
'type': frontend_type,
|
||||
'label': label,
|
||||
'description': description,
|
||||
'required': cfg.required,
|
||||
'default': cfg.default,
|
||||
}
|
||||
|
||||
# Add placeholder if present
|
||||
if cfg.placeholder:
|
||||
result['placeholder'] = cfg.placeholder
|
||||
|
||||
# Add options if present
|
||||
if cfg.options:
|
||||
result['options'] = [
|
||||
{
|
||||
'name': opt,
|
||||
'label': {
|
||||
'zh_Hans': opt,
|
||||
'en_US': opt,
|
||||
}
|
||||
}
|
||||
for opt in cfg.options
|
||||
]
|
||||
|
||||
# Add show_if if present
|
||||
if cfg.show_if:
|
||||
result['show_if'] = cfg.show_if
|
||||
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def to_schema(cls) -> dict[str, Any]:
|
||||
"""
|
||||
Convert node class to JSON schema for frontend.
|
||||
|
||||
Returns:
|
||||
Node schema dictionary
|
||||
"""
|
||||
# Build label dict for i18n support
|
||||
# Use underscore format to match frontend I18nObject interface
|
||||
name_zh = getattr(cls, 'name_zh', None) or cls.name
|
||||
name_en = getattr(cls, 'name_en', None) or cls.name
|
||||
desc_zh = getattr(cls, 'description_zh', None) or cls.description
|
||||
desc_en = getattr(cls, 'description_en', None) or cls.description
|
||||
label = {
|
||||
'zh_Hans': name_zh,
|
||||
'en_US': name_en,
|
||||
}
|
||||
description = {
|
||||
'zh_Hans': desc_zh,
|
||||
'en_US': desc_en,
|
||||
}
|
||||
|
||||
return {
|
||||
'type': f'{cls.category}.{cls.type_name}',
|
||||
'name': cls.name,
|
||||
'label': label,
|
||||
'description': description,
|
||||
'category': cls.category,
|
||||
'icon': cls.icon,
|
||||
'inputs': [port.model_dump() for port in cls.inputs],
|
||||
'outputs': [port.model_dump() for port in cls.outputs],
|
||||
'config_schema': [cls._config_to_schema_item(cfg) for cfg in cls.config_schema],
|
||||
'config_schema_source': cls.config_schema_source,
|
||||
'config_stages': cls.config_stages,
|
||||
}
|
||||
|
||||
|
||||
# Registry for node type decorator
|
||||
_pending_registrations: list[tuple[str, type[WorkflowNode]]] = []
|
||||
|
||||
|
||||
def workflow_node(type_name: str) -> Callable[[type[WorkflowNode]], type[WorkflowNode]]:
|
||||
"""
|
||||
Decorator to register a workflow node type.
|
||||
|
||||
Usage:
|
||||
@workflow_node('llm_call')
|
||||
class LLMCallNode(WorkflowNode):
|
||||
...
|
||||
"""
|
||||
def decorator(cls: type[WorkflowNode]) -> type[WorkflowNode]:
|
||||
cls.type_name = type_name
|
||||
_pending_registrations.append((type_name, cls))
|
||||
return cls
|
||||
return decorator
|
||||
|
||||
|
||||
def get_pending_registrations() -> list[tuple[str, type[WorkflowNode]]]:
|
||||
"""Get pending node registrations"""
|
||||
return _pending_registrations.copy()
|
||||
|
||||
|
||||
def clear_pending_registrations():
|
||||
"""Clear pending registrations after they're processed"""
|
||||
_pending_registrations.clear()
|
||||
91
src/langbot/pkg/workflow/nodes/__init__.py
Normal file
91
src/langbot/pkg/workflow/nodes/__init__.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""Core workflow nodes package"""
|
||||
|
||||
# Import all node modules to trigger registration
|
||||
# Trigger nodes
|
||||
from . import message_trigger
|
||||
from . import cron_trigger
|
||||
from . import webhook_trigger
|
||||
from . import event_trigger
|
||||
|
||||
# Process nodes
|
||||
from . import llm_call
|
||||
from . import code_executor
|
||||
from . import http_request
|
||||
from . import data_transform
|
||||
from . import question_classifier
|
||||
from . import parameter_extractor
|
||||
from . import knowledge_retrieval
|
||||
|
||||
# Control nodes
|
||||
from . import condition
|
||||
from . import switch
|
||||
from . import loop
|
||||
from . import iterator
|
||||
from . import parallel
|
||||
from . import wait
|
||||
from . import merge
|
||||
from . import variable_aggregator
|
||||
|
||||
# Action nodes
|
||||
from . import send_message
|
||||
from . import reply_message
|
||||
from . import call_pipeline
|
||||
from . import store_data
|
||||
from . import set_variable
|
||||
from . import opening_statement
|
||||
from . import end
|
||||
|
||||
# Integration nodes
|
||||
from . import database_query
|
||||
from . import redis_operation
|
||||
from . import mcp_tool
|
||||
from . import memory_store
|
||||
from . import dify_workflow
|
||||
from . import dify_knowledge_query
|
||||
from . import n8n_workflow
|
||||
from . import langflow_flow
|
||||
from . import coze_bot
|
||||
# from . import plugin_call
|
||||
|
||||
__all__ = [
|
||||
# Trigger nodes
|
||||
'message_trigger',
|
||||
'cron_trigger',
|
||||
'webhook_trigger',
|
||||
'event_trigger',
|
||||
# Process nodes
|
||||
'llm_call',
|
||||
'code_executor',
|
||||
'http_request',
|
||||
'data_transform',
|
||||
'question_classifier',
|
||||
'parameter_extractor',
|
||||
'knowledge_retrieval',
|
||||
# Control nodes
|
||||
'condition',
|
||||
'switch',
|
||||
'loop',
|
||||
'iterator',
|
||||
'parallel',
|
||||
'wait',
|
||||
'merge',
|
||||
'variable_aggregator',
|
||||
# Action nodes
|
||||
'send_message',
|
||||
'reply_message',
|
||||
'call_pipeline',
|
||||
'store_data',
|
||||
'set_variable',
|
||||
'opening_statement',
|
||||
'end',
|
||||
# Integration nodes
|
||||
'database_query',
|
||||
'redis_operation',
|
||||
'mcp_tool',
|
||||
'memory_store',
|
||||
'dify_workflow',
|
||||
'dify_knowledge_query',
|
||||
'n8n_workflow',
|
||||
'langflow_flow',
|
||||
'coze_bot',
|
||||
]
|
||||
36
src/langbot/pkg/workflow/nodes/call_pipeline.py
Normal file
36
src/langbot/pkg/workflow/nodes/call_pipeline.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""Call Pipeline Node - invoke an existing pipeline
|
||||
|
||||
Node metadata is loaded from: ../../templates/metadata/nodes/call_pipeline.yaml
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from ..entities import ExecutionContext
|
||||
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
|
||||
|
||||
|
||||
@workflow_node('call_pipeline')
|
||||
class CallPipelineNode(WorkflowNode):
|
||||
"""Call pipeline node - invoke an existing pipeline"""
|
||||
|
||||
type_name = "call_pipeline"
|
||||
category = "action"
|
||||
icon = "⚙️"
|
||||
name = "call_pipeline"
|
||||
description = "call_pipeline"
|
||||
name_zh = "调用 Pipeline"
|
||||
name_en = "Call Pipeline"
|
||||
description_zh = "调用现有的 Pipeline 进行处理"
|
||||
description_en = "Invoke an existing Pipeline for processing"
|
||||
|
||||
inputs: ClassVar[list[NodePort]] = []
|
||||
outputs: ClassVar[list[NodePort]] = []
|
||||
config_schema: ClassVar[list[NodeConfig]] = []
|
||||
|
||||
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
|
||||
query = inputs.get("query", "")
|
||||
pipeline_uuid = self.get_config("pipeline_uuid", "")
|
||||
|
||||
return {"response": f"[Pipeline {pipeline_uuid} response for: {query[:50]}...]", "result": {}}
|
||||
73
src/langbot/pkg/workflow/nodes/code_executor.py
Normal file
73
src/langbot/pkg/workflow/nodes/code_executor.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""Code Executor Node - run Python or JavaScript code
|
||||
|
||||
Node metadata is loaded from: ../../templates/metadata/nodes/code_executor.yaml
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from ..entities import ExecutionContext
|
||||
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
|
||||
|
||||
|
||||
@workflow_node('code_executor')
|
||||
class CodeExecutorNode(WorkflowNode):
|
||||
"""Code executor node - run Python or JavaScript code"""
|
||||
|
||||
type_name = "code_executor"
|
||||
category = "process"
|
||||
icon = "💻"
|
||||
name = "code_executor"
|
||||
description = "code_executor"
|
||||
name_zh = "代码执行"
|
||||
name_en = "Code Executor"
|
||||
description_zh = "执行自定义代码处理数据"
|
||||
description_en = "Execute custom code to process data"
|
||||
|
||||
inputs: ClassVar[list[NodePort]] = []
|
||||
outputs: ClassVar[list[NodePort]] = []
|
||||
config_schema: ClassVar[list[NodeConfig]] = []
|
||||
|
||||
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
|
||||
code = self.get_config("code", "")
|
||||
language = self.get_config("language", "python")
|
||||
|
||||
if language == "python":
|
||||
return await self._execute_python(code, inputs, context)
|
||||
else:
|
||||
return await self._execute_javascript(code, inputs, context)
|
||||
|
||||
async def _execute_python(self, code: str, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
|
||||
import io
|
||||
import sys
|
||||
|
||||
stdout_capture = io.StringIO()
|
||||
old_stdout = sys.stdout
|
||||
|
||||
try:
|
||||
sys.stdout = stdout_capture
|
||||
|
||||
restricted_globals = {
|
||||
'__builtins__': {
|
||||
'len': len, 'str': str, 'int': int, 'float': float, 'bool': bool,
|
||||
'list': list, 'dict': dict, 'set': set, 'tuple': tuple,
|
||||
'range': range, 'enumerate': enumerate, 'zip': zip,
|
||||
'map': map, 'filter': filter, 'sorted': sorted, 'reversed': reversed,
|
||||
'sum': sum, 'min': min, 'max': max, 'abs': abs, 'round': round,
|
||||
'print': print, 'isinstance': isinstance, 'type': type,
|
||||
'hasattr': hasattr, 'getattr': getattr, 'json': json, 're': re,
|
||||
}
|
||||
}
|
||||
|
||||
local_vars = {'inputs': inputs, 'output': None}
|
||||
exec(code, restricted_globals, local_vars)
|
||||
|
||||
return {"output": local_vars.get('output'), "console": stdout_capture.getvalue()}
|
||||
finally:
|
||||
sys.stdout = old_stdout
|
||||
|
||||
async def _execute_javascript(self, code: str, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
|
||||
return {"output": f"[JS execution not implemented: {code[:50]}...]", "console": ""}
|
||||
88
src/langbot/pkg/workflow/nodes/condition.py
Normal file
88
src/langbot/pkg/workflow/nodes/condition.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""Condition Node - branch based on condition
|
||||
|
||||
Node metadata is loaded from: ../../templates/metadata/nodes/condition.yaml
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from ..entities import ExecutionContext
|
||||
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
|
||||
from ..safe_eval import safe_eval_with_vars
|
||||
|
||||
|
||||
@workflow_node('condition')
|
||||
class ConditionNode(WorkflowNode):
|
||||
"""Condition node - branch based on condition"""
|
||||
|
||||
type_name = "condition"
|
||||
category = "control"
|
||||
icon = "🔀"
|
||||
name = "condition"
|
||||
description = "condition"
|
||||
name_zh = "条件分支"
|
||||
name_en = "Condition"
|
||||
description_zh = "根据条件分支工作流"
|
||||
description_en = "Branch workflow based on a condition"
|
||||
|
||||
inputs: ClassVar[list[NodePort]] = []
|
||||
outputs: ClassVar[list[NodePort]] = []
|
||||
config_schema: ClassVar[list[NodeConfig]] = []
|
||||
|
||||
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
|
||||
condition_type = self.get_config("condition_type", "expression")
|
||||
input_data = inputs.get("input")
|
||||
|
||||
result = False
|
||||
|
||||
if condition_type == "expression":
|
||||
expression = self.get_config("expression", "false")
|
||||
result = await self._evaluate_expression(expression, input_data, context)
|
||||
elif condition_type == "comparison":
|
||||
result = await self._evaluate_comparison(input_data, context)
|
||||
elif condition_type == "contains":
|
||||
left = self.get_config("left_value", "")
|
||||
right = self.get_config("right_value", "")
|
||||
result = right in left
|
||||
elif condition_type == "empty":
|
||||
result = not bool(input_data)
|
||||
elif condition_type == "regex":
|
||||
import re
|
||||
left = self.get_config("left_value", "")
|
||||
pattern = self.get_config("right_value", "")
|
||||
result = bool(re.match(pattern, str(left)))
|
||||
|
||||
if result:
|
||||
return {"true": input_data, "false": None}
|
||||
else:
|
||||
return {"true": None, "false": input_data}
|
||||
|
||||
async def _evaluate_expression(self, expression: str, data: Any, context: ExecutionContext) -> bool:
|
||||
try:
|
||||
local_vars = {"input": data, "data": data, "variables": context.variables}
|
||||
return bool(safe_eval_with_vars(expression, local_vars))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def _evaluate_comparison(self, data: Any, context: ExecutionContext) -> bool:
|
||||
left = self.get_config("left_value", "")
|
||||
right = self.get_config("right_value", "")
|
||||
operator = self.get_config("operator", "==")
|
||||
|
||||
try:
|
||||
left_num = float(left)
|
||||
right_num = float(right)
|
||||
|
||||
if operator == "==": return left_num == right_num
|
||||
elif operator == "!=": return left_num != right_num
|
||||
elif operator == ">": return left_num > right_num
|
||||
elif operator == "<": return left_num < right_num
|
||||
elif operator == ">=": return left_num >= right_num
|
||||
elif operator == "<=": return left_num <= right_num
|
||||
except ValueError:
|
||||
if operator == "==": return left == right
|
||||
elif operator == "!=": return left != right
|
||||
elif operator in (">", "<", ">=", "<="): return False
|
||||
|
||||
return False
|
||||
49
src/langbot/pkg/workflow/nodes/coze_bot.py
Normal file
49
src/langbot/pkg/workflow/nodes/coze_bot.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""Coze Bot Node - call Coze API bot
|
||||
|
||||
Node metadata is loaded from: ../../templates/metadata/nodes/coze_bot.yaml
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from ..entities import ExecutionContext
|
||||
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
|
||||
|
||||
|
||||
@workflow_node('coze_bot')
|
||||
class CozeBotNode(WorkflowNode):
|
||||
"""Coze bot node - call Coze API bot"""
|
||||
|
||||
type_name = "coze_bot"
|
||||
category = "integration"
|
||||
icon = "MessageSquare"
|
||||
name = "coze_bot"
|
||||
description = "coze_bot"
|
||||
name_zh = "Coze Bot"
|
||||
name_en = "Coze Bot"
|
||||
description_zh = "调用扣子 Bot"
|
||||
description_en = "Call a Coze Bot"
|
||||
|
||||
inputs: ClassVar[list[NodePort]] = []
|
||||
outputs: ClassVar[list[NodePort]] = []
|
||||
config_schema: ClassVar[list[NodeConfig]] = []
|
||||
|
||||
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
|
||||
api_key = self.get_config("api_key", "")
|
||||
bot_id = self.get_config("bot_id", "")
|
||||
api_base = self.get_config("api_base", "https://api.coze.cn")
|
||||
query = inputs.get("query", "")
|
||||
conversation_id = inputs.get("conversation_id")
|
||||
|
||||
return {
|
||||
"answer": "",
|
||||
"conversation_id": conversation_id,
|
||||
"success": False,
|
||||
"_debug": {
|
||||
"api_key": api_key[:8] + "..." if api_key else "",
|
||||
"bot_id": bot_id,
|
||||
"api_base": api_base,
|
||||
"query": query,
|
||||
},
|
||||
}
|
||||
39
src/langbot/pkg/workflow/nodes/cron_trigger.py
Normal file
39
src/langbot/pkg/workflow/nodes/cron_trigger.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""Cron Trigger Node - triggers workflow on schedule
|
||||
|
||||
Node metadata is loaded from: ../../templates/metadata/nodes/cron_trigger.yaml
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from ..entities import ExecutionContext
|
||||
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
|
||||
|
||||
|
||||
@workflow_node('cron_trigger')
|
||||
class CronTriggerNode(WorkflowNode):
|
||||
"""Cron trigger node - triggers workflow on schedule"""
|
||||
|
||||
type_name = "cron_trigger"
|
||||
category = "trigger"
|
||||
icon = "⏰"
|
||||
name = "cron_trigger"
|
||||
description = "cron_trigger"
|
||||
name_zh = "定时触发"
|
||||
name_en = "Scheduled Trigger"
|
||||
description_zh = "按定时计划触发工作流"
|
||||
description_en = "Trigger workflow on a scheduled time"
|
||||
|
||||
inputs: ClassVar[list[NodePort]] = []
|
||||
outputs: ClassVar[list[NodePort]] = []
|
||||
config_schema: ClassVar[list[NodeConfig]] = []
|
||||
|
||||
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
|
||||
from datetime import datetime
|
||||
|
||||
return {
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"schedule": self.get_config("cron", ""),
|
||||
"context": context.trigger_data,
|
||||
}
|
||||
81
src/langbot/pkg/workflow/nodes/data_transform.py
Normal file
81
src/langbot/pkg/workflow/nodes/data_transform.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""Data Transform Node - transform data using templates or JSONPath
|
||||
|
||||
Node metadata is loaded from: ../../templates/metadata/nodes/data_transform.yaml
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from ..entities import ExecutionContext
|
||||
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
|
||||
from ..safe_eval import safe_eval_with_vars
|
||||
|
||||
|
||||
@workflow_node('data_transform')
|
||||
class DataTransformNode(WorkflowNode):
|
||||
"""Data transform node - transform data using templates or JSONPath"""
|
||||
|
||||
type_name = "data_transform"
|
||||
category = "process"
|
||||
icon = "🔄"
|
||||
name = "data_transform"
|
||||
description = "data_transform"
|
||||
name_zh = "数据转换"
|
||||
name_en = "Data Transform"
|
||||
description_zh = "使用模板或 JSONPath 转换数据"
|
||||
description_en = "Transform data using templates or JSONPath"
|
||||
|
||||
inputs: ClassVar[list[NodePort]] = []
|
||||
outputs: ClassVar[list[NodePort]] = []
|
||||
config_schema: ClassVar[list[NodeConfig]] = []
|
||||
|
||||
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
|
||||
data = inputs.get("data")
|
||||
transform_type = self.get_config("transform_type", "template")
|
||||
|
||||
if transform_type == "template":
|
||||
template = self.get_config("template", "")
|
||||
result = self._apply_template(template, data, context)
|
||||
elif transform_type == "jsonpath":
|
||||
expression = self.get_config("expression", "$")
|
||||
result = self._apply_jsonpath(expression, data)
|
||||
elif transform_type == "expression":
|
||||
expression = self.get_config("expression", "")
|
||||
result = self._evaluate_expression(expression, data, context)
|
||||
else:
|
||||
result = data
|
||||
|
||||
return {"result": result}
|
||||
|
||||
def _apply_template(self, template: str, data: Any, context: ExecutionContext) -> str:
|
||||
result = template
|
||||
if isinstance(data, dict):
|
||||
for key, value in data.items():
|
||||
result = result.replace(f"{{{{data.{key}}}}}", str(value))
|
||||
for key, value in context.variables.items():
|
||||
result = result.replace(f"{{{{variables.{key}}}}}", str(value))
|
||||
return result
|
||||
|
||||
def _apply_jsonpath(self, expression: str, data: Any) -> Any:
|
||||
if expression == "$":
|
||||
return data
|
||||
if expression.startswith("$."):
|
||||
parts = expression[2:].split(".")
|
||||
result = data
|
||||
for part in parts:
|
||||
if isinstance(result, dict):
|
||||
result = result.get(part)
|
||||
elif isinstance(result, list) and part.isdigit():
|
||||
result = result[int(part)]
|
||||
else:
|
||||
return None
|
||||
return result
|
||||
return data
|
||||
|
||||
def _evaluate_expression(self, expression: str, data: Any, context: ExecutionContext) -> Any:
|
||||
local_vars = {"data": data, "variables": context.variables}
|
||||
try:
|
||||
return safe_eval_with_vars(expression, local_vars)
|
||||
except Exception:
|
||||
return None
|
||||
52
src/langbot/pkg/workflow/nodes/database_query.py
Normal file
52
src/langbot/pkg/workflow/nodes/database_query.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""Database Query Node - execute database queries
|
||||
|
||||
Node metadata is loaded from: ../../templates/metadata/nodes/database_query.yaml
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from ..entities import ExecutionContext
|
||||
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
|
||||
|
||||
|
||||
@workflow_node('database_query')
|
||||
class DatabaseQueryNode(WorkflowNode):
|
||||
"""Database query node - execute database queries"""
|
||||
|
||||
type_name = "database_query"
|
||||
category = "integration"
|
||||
icon = "Database"
|
||||
name = "database_query"
|
||||
description = "database_query"
|
||||
name_zh = "数据库查询"
|
||||
name_en = "Database Query"
|
||||
description_zh = "执行数据库查询"
|
||||
description_en = "Execute database queries"
|
||||
|
||||
inputs: ClassVar[list[NodePort]] = []
|
||||
outputs: ClassVar[list[NodePort]] = []
|
||||
config_schema: ClassVar[list[NodeConfig]] = []
|
||||
|
||||
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
|
||||
connection_type = self.get_config("connection_type", "postgresql")
|
||||
connection_string = self.get_config("connection_string", "")
|
||||
query = self.get_config("query", "")
|
||||
query_type = self.get_config("query_type", "select")
|
||||
timeout = self.get_config("timeout", 30)
|
||||
|
||||
parameters = inputs.get("parameters", {})
|
||||
|
||||
return {
|
||||
"results": [],
|
||||
"row_count": 0,
|
||||
"success": False,
|
||||
"_debug": {
|
||||
"connection_type": connection_type,
|
||||
"query": query,
|
||||
"query_type": query_type,
|
||||
"timeout": timeout,
|
||||
"parameters": parameters,
|
||||
},
|
||||
}
|
||||
47
src/langbot/pkg/workflow/nodes/dify_knowledge_query.py
Normal file
47
src/langbot/pkg/workflow/nodes/dify_knowledge_query.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""Dify Knowledge Query Node - query Dify knowledge base
|
||||
|
||||
Node metadata is loaded from: ../../templates/metadata/nodes/dify_knowledge_query.yaml
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from ..entities import ExecutionContext
|
||||
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
|
||||
|
||||
|
||||
@workflow_node('dify_knowledge_query')
|
||||
class DifyKnowledgeQueryNode(WorkflowNode):
|
||||
"""Dify knowledge base query node - query Dify knowledge base"""
|
||||
|
||||
type_name = "dify_knowledge_query"
|
||||
category = "integration"
|
||||
icon = "BookOpen"
|
||||
name = "dify_knowledge_query"
|
||||
description = "dify_knowledge_query"
|
||||
name_zh = "Dify 知识库查询"
|
||||
name_en = "Dify Knowledge Query"
|
||||
description_zh = "查询 Dify 知识库"
|
||||
description_en = "Query Dify knowledge base"
|
||||
|
||||
inputs: ClassVar[list[NodePort]] = []
|
||||
outputs: ClassVar[list[NodePort]] = []
|
||||
config_schema: ClassVar[list[NodeConfig]] = []
|
||||
|
||||
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
|
||||
base_url = self.get_config("base_url", "https://api.dify.ai/v1")
|
||||
api_key = self.get_config("api_key", "")
|
||||
dataset_id = self.get_config("dataset_id", "")
|
||||
query = inputs.get("query", "")
|
||||
|
||||
return {
|
||||
"results": [],
|
||||
"success": False,
|
||||
"_debug": {
|
||||
"base_url": base_url,
|
||||
"api_key": api_key[:8] + "..." if api_key else "",
|
||||
"dataset_id": dataset_id,
|
||||
"query": query,
|
||||
},
|
||||
}
|
||||
49
src/langbot/pkg/workflow/nodes/dify_workflow.py
Normal file
49
src/langbot/pkg/workflow/nodes/dify_workflow.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""Dify Workflow Node - call Dify service API
|
||||
|
||||
Node metadata is loaded from: ../../templates/metadata/nodes/dify_workflow.yaml
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from ..entities import ExecutionContext
|
||||
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
|
||||
|
||||
|
||||
@workflow_node('dify_workflow')
|
||||
class DifyWorkflowNode(WorkflowNode):
|
||||
"""Dify workflow node - call Dify service API"""
|
||||
|
||||
type_name = "dify_workflow"
|
||||
category = "integration"
|
||||
icon = "Bot"
|
||||
name = "dify_workflow"
|
||||
description = "dify_workflow"
|
||||
name_zh = "Dify 工作流"
|
||||
name_en = "Dify Workflow"
|
||||
description_zh = "调用 Dify 平台工作流"
|
||||
description_en = "Call a Dify platform workflow"
|
||||
|
||||
inputs: ClassVar[list[NodePort]] = []
|
||||
outputs: ClassVar[list[NodePort]] = []
|
||||
config_schema: ClassVar[list[NodeConfig]] = []
|
||||
|
||||
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
|
||||
base_url = self.get_config("base_url", "https://api.dify.ai/v1")
|
||||
api_key = self.get_config("api_key", "")
|
||||
app_type = self.get_config("app_type", "chat")
|
||||
query = inputs.get("query", "")
|
||||
conversation_id = inputs.get("conversation_id")
|
||||
|
||||
return {
|
||||
"answer": "",
|
||||
"conversation_id": conversation_id,
|
||||
"success": False,
|
||||
"_debug": {
|
||||
"base_url": base_url,
|
||||
"api_key": api_key[:8] + "..." if api_key else "",
|
||||
"app_type": app_type,
|
||||
"query": query,
|
||||
},
|
||||
}
|
||||
45
src/langbot/pkg/workflow/nodes/end.py
Normal file
45
src/langbot/pkg/workflow/nodes/end.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""End Node - marks the end of workflow execution
|
||||
|
||||
Node metadata is loaded from: ../../templates/metadata/nodes/end.yaml
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from ..entities import ExecutionContext
|
||||
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
|
||||
|
||||
|
||||
@workflow_node('end')
|
||||
class EndNode(WorkflowNode):
|
||||
"""End node - marks the end of workflow execution"""
|
||||
|
||||
type_name = "end"
|
||||
category = "action"
|
||||
icon = "🏁"
|
||||
name = "end"
|
||||
description = "end"
|
||||
name_zh = "结束"
|
||||
name_en = "End"
|
||||
description_zh = "结束工作流执行"
|
||||
description_en = "End the workflow execution"
|
||||
|
||||
inputs: ClassVar[list[NodePort]] = []
|
||||
outputs: ClassVar[list[NodePort]] = []
|
||||
config_schema: ClassVar[list[NodeConfig]] = []
|
||||
|
||||
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
|
||||
result = inputs.get("result")
|
||||
output_format = self.get_config("output_format", "passthrough")
|
||||
|
||||
if output_format == "text":
|
||||
return {"output": str(result)}
|
||||
elif output_format == "json":
|
||||
import json
|
||||
try:
|
||||
return {"output": json.dumps(result, ensure_ascii=False)}
|
||||
except Exception:
|
||||
return {"output": str(result)}
|
||||
else:
|
||||
return {"output": result}
|
||||
41
src/langbot/pkg/workflow/nodes/event_trigger.py
Normal file
41
src/langbot/pkg/workflow/nodes/event_trigger.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""Event Trigger Node - triggers workflow on system events
|
||||
|
||||
Node metadata is loaded from: ../../templates/metadata/nodes/event_trigger.yaml
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from ..entities import ExecutionContext
|
||||
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
|
||||
|
||||
|
||||
@workflow_node('event_trigger')
|
||||
class EventTriggerNode(WorkflowNode):
|
||||
"""Event trigger node - triggers workflow on system events"""
|
||||
|
||||
type_name = "event_trigger"
|
||||
category = "trigger"
|
||||
icon = "📡"
|
||||
name = "event_trigger"
|
||||
description = "event_trigger"
|
||||
name_zh = "事件触发"
|
||||
name_en = "Event Trigger"
|
||||
description_zh = "当系统事件发生时触发工作流"
|
||||
description_en = "Trigger workflow when a system event occurs"
|
||||
|
||||
inputs: ClassVar[list[NodePort]] = []
|
||||
outputs: ClassVar[list[NodePort]] = []
|
||||
config_schema: ClassVar[list[NodeConfig]] = []
|
||||
|
||||
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
|
||||
from datetime import datetime
|
||||
|
||||
trigger_data = context.trigger_data
|
||||
|
||||
return {
|
||||
"event_type": trigger_data.get("event_type", ""),
|
||||
"event_data": trigger_data.get("event_data", {}),
|
||||
"timestamp": trigger_data.get("timestamp", datetime.now().isoformat()),
|
||||
}
|
||||
70
src/langbot/pkg/workflow/nodes/http_request.py
Normal file
70
src/langbot/pkg/workflow/nodes/http_request.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""HTTP Request Node - make HTTP API calls
|
||||
|
||||
Node metadata is loaded from: ../../templates/metadata/nodes/http_request.yaml
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from ..entities import ExecutionContext
|
||||
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
|
||||
|
||||
|
||||
@workflow_node('http_request')
|
||||
class HTTPRequestNode(WorkflowNode):
|
||||
"""HTTP request node - make HTTP API calls"""
|
||||
|
||||
type_name = "http_request"
|
||||
category = "process"
|
||||
icon = "🌐"
|
||||
name = "http_request"
|
||||
description = "http_request"
|
||||
name_zh = "HTTP 请求"
|
||||
name_en = "HTTP Request"
|
||||
description_zh = "向外部 API 发送 HTTP 请求"
|
||||
description_en = "Make HTTP requests to external APIs"
|
||||
|
||||
inputs: ClassVar[list[NodePort]] = []
|
||||
outputs: ClassVar[list[NodePort]] = []
|
||||
config_schema: ClassVar[list[NodeConfig]] = []
|
||||
|
||||
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
|
||||
import aiohttp
|
||||
|
||||
url = self.get_config("url", "")
|
||||
method = self.get_config("method", "GET")
|
||||
timeout = self.get_config("timeout", 30)
|
||||
content_type = self.get_config("content_type", "application/json")
|
||||
|
||||
headers = inputs.get("headers", {})
|
||||
headers["Content-Type"] = content_type
|
||||
|
||||
auth_type = self.get_config("auth_type", "none")
|
||||
auth_config = self.get_config("auth_config", {})
|
||||
|
||||
if auth_type == "bearer":
|
||||
headers["Authorization"] = f"Bearer {auth_config.get('token', '')}"
|
||||
elif auth_type == "api_key":
|
||||
header_name = auth_config.get("header", "X-API-Key")
|
||||
headers[header_name] = auth_config.get("key", "")
|
||||
|
||||
body = inputs.get("body")
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.request(
|
||||
method=method, url=url,
|
||||
json=body if content_type == "application/json" else None,
|
||||
data=body if content_type != "application/json" else None,
|
||||
headers=headers,
|
||||
timeout=aiohttp.ClientTimeout(total=timeout)
|
||||
) as response:
|
||||
try:
|
||||
response_data = await response.json()
|
||||
except Exception:
|
||||
response_data = await response.text()
|
||||
|
||||
return {"response": response_data, "status_code": response.status, "headers": dict(response.headers)}
|
||||
except Exception as e:
|
||||
return {"response": None, "status_code": 0, "headers": {}, "error": str(e)}
|
||||
60
src/langbot/pkg/workflow/nodes/iterator.py
Normal file
60
src/langbot/pkg/workflow/nodes/iterator.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""Iterator Node - Dify-style iterator for processing array items"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from ..entities import ExecutionContext
|
||||
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
|
||||
|
||||
|
||||
@workflow_node('iterator')
|
||||
class IteratorNode(WorkflowNode):
|
||||
"""Iterator node - iterate over array items one by one"""
|
||||
|
||||
type_name = "iterator"
|
||||
category = "control"
|
||||
icon = "🔄"
|
||||
name = "iterator"
|
||||
name_zh = "迭代器"
|
||||
name_en = "Iterator"
|
||||
description = "iterator"
|
||||
description_zh = "逐个遍历数组元素"
|
||||
description_en = "Iterate over array elements one by one"
|
||||
|
||||
inputs: ClassVar[list[NodePort]] = [
|
||||
NodePort(name="items", type="array", description="Array to iterate over", required=True),
|
||||
]
|
||||
outputs: ClassVar[list[NodePort]] = [
|
||||
NodePort(name="item", type="any", description="Current item"),
|
||||
NodePort(name="index", type="number", description="Current index"),
|
||||
NodePort(name="is_first", type="boolean", description="Whether this is the first item"),
|
||||
NodePort(name="is_last", type="boolean", description="Whether this is the last item"),
|
||||
NodePort(name="results", type="array", description="All iteration results"),
|
||||
NodePort(name="completed", type="boolean", description="Whether iteration completed"),
|
||||
]
|
||||
config_schema: ClassVar[list[NodeConfig]] = [
|
||||
NodeConfig(
|
||||
name="max_iterations", type="integer", required=False, default=1000,
|
||||
description="Maximum iterations (safety limit)",
|
||||
label={"en_US": "Max Iterations", "zh_Hans": "最大迭代次数"},
|
||||
),
|
||||
]
|
||||
|
||||
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
|
||||
items = inputs.get("items", [])
|
||||
if not isinstance(items, list):
|
||||
items = [items] if items else []
|
||||
|
||||
max_iterations = self.get_config("max_iterations", 1000)
|
||||
items = items[:max_iterations]
|
||||
|
||||
return {
|
||||
"item": items[0] if items else None,
|
||||
"index": 0,
|
||||
"is_first": True,
|
||||
"is_last": len(items) <= 1,
|
||||
"results": [],
|
||||
"completed": len(items) == 0,
|
||||
"_items": items,
|
||||
}
|
||||
34
src/langbot/pkg/workflow/nodes/knowledge_retrieval.py
Normal file
34
src/langbot/pkg/workflow/nodes/knowledge_retrieval.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""Knowledge Retrieval Node - search in knowledge base
|
||||
|
||||
Node metadata is loaded from: ../../templates/metadata/nodes/knowledge_retrieval.yaml
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from ..entities import ExecutionContext
|
||||
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
|
||||
|
||||
|
||||
@workflow_node('knowledge_retrieval')
|
||||
class KnowledgeRetrievalNode(WorkflowNode):
|
||||
"""Knowledge retrieval node - search in knowledge base"""
|
||||
|
||||
type_name = "knowledge_retrieval"
|
||||
category = "process"
|
||||
icon = "📚"
|
||||
name = "knowledge_retrieval"
|
||||
description = "knowledge_retrieval"
|
||||
name_zh = "知识库检索"
|
||||
name_en = "Knowledge Retrieval"
|
||||
description_zh = "从知识库中检索相关信息"
|
||||
description_en = "Retrieve relevant information from knowledge bases"
|
||||
|
||||
inputs: ClassVar[list[NodePort]] = []
|
||||
outputs: ClassVar[list[NodePort]] = []
|
||||
config_schema: ClassVar[list[NodeConfig]] = []
|
||||
|
||||
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
|
||||
query = inputs.get("query", "")
|
||||
return {"documents": [], "citations": [], "context": f"[Knowledge base search for: {query}]"}
|
||||
47
src/langbot/pkg/workflow/nodes/langflow_flow.py
Normal file
47
src/langbot/pkg/workflow/nodes/langflow_flow.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""Langflow Flow Node - call Langflow API
|
||||
|
||||
Node metadata is loaded from: ../../templates/metadata/nodes/langflow_flow.yaml
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from ..entities import ExecutionContext
|
||||
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
|
||||
|
||||
|
||||
@workflow_node('langflow_flow')
|
||||
class LangflowFlowNode(WorkflowNode):
|
||||
"""Langflow flow node - call Langflow API"""
|
||||
|
||||
type_name = "langflow_flow"
|
||||
category = "integration"
|
||||
icon = "GitBranch"
|
||||
name = "langflow_flow"
|
||||
description = "langflow_flow"
|
||||
name_zh = "Langflow 流程"
|
||||
name_en = "Langflow Flow"
|
||||
description_zh = "调用 Langflow 流程"
|
||||
description_en = "Call a Langflow flow"
|
||||
|
||||
inputs: ClassVar[list[NodePort]] = []
|
||||
outputs: ClassVar[list[NodePort]] = []
|
||||
config_schema: ClassVar[list[NodeConfig]] = []
|
||||
|
||||
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
|
||||
base_url = self.get_config("base_url", "http://localhost:7860")
|
||||
api_key = self.get_config("api_key", "")
|
||||
flow_id = self.get_config("flow_id", "")
|
||||
input_value = inputs.get("input_value", "")
|
||||
|
||||
return {
|
||||
"result": None,
|
||||
"success": False,
|
||||
"_debug": {
|
||||
"base_url": base_url,
|
||||
"api_key": api_key[:8] + "..." if api_key else "",
|
||||
"flow_id": flow_id,
|
||||
"input_value": input_value,
|
||||
},
|
||||
}
|
||||
163
src/langbot/pkg/workflow/nodes/llm_call.py
Normal file
163
src/langbot/pkg/workflow/nodes/llm_call.py
Normal file
@@ -0,0 +1,163 @@
|
||||
"""LLM Call Node - invoke large language model."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, ClassVar
|
||||
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
|
||||
from ..entities import ExecutionContext
|
||||
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@workflow_node('llm_call')
|
||||
class LLMCallNode(WorkflowNode):
|
||||
"""LLM call node - invoke large language model"""
|
||||
|
||||
type_name = "llm_call"
|
||||
category = "process"
|
||||
icon = "🤖"
|
||||
name = "llm_call"
|
||||
name_zh = "LLM 调用"
|
||||
name_en = "LLM Call"
|
||||
description = "llm_call"
|
||||
description_zh = "调用大语言模型生成响应"
|
||||
description_en = "Call a large language model to generate responses"
|
||||
|
||||
inputs: ClassVar[list[NodePort]] = [
|
||||
NodePort(name="input", type="string", description="Input text to send to the model", required=False),
|
||||
NodePort(name="context", type="object", description="Additional context data", required=False),
|
||||
]
|
||||
outputs: ClassVar[list[NodePort]] = [
|
||||
NodePort(name="response", type="string", description="Model response text"),
|
||||
NodePort(name="usage", type="object", description="Token usage information"),
|
||||
]
|
||||
config_schema: ClassVar[list[NodeConfig]] = [
|
||||
NodeConfig(
|
||||
name="model", type="llm-model-selector", required=True,
|
||||
description="Select the LLM model to use",
|
||||
label={"en_US": "Model", "zh_Hans": "模型"},
|
||||
),
|
||||
NodeConfig(
|
||||
name="system_prompt", type="textarea", required=False, default="",
|
||||
description="System prompt to set model behavior",
|
||||
label={"en_US": "System Prompt", "zh_Hans": "系统提示词"},
|
||||
),
|
||||
NodeConfig(
|
||||
name="user_prompt_template", type="textarea", required=True, default="{{input}}",
|
||||
description="User prompt template with variable placeholders",
|
||||
label={"en_US": "User Prompt Template", "zh_Hans": "用户提示词模板"},
|
||||
),
|
||||
NodeConfig(
|
||||
name="temperature", type="number", required=False, default=0.7,
|
||||
description="Controls randomness (0.0-2.0)",
|
||||
label={"en_US": "Temperature", "zh_Hans": "温度"},
|
||||
min_value=0.0, max_value=2.0,
|
||||
),
|
||||
NodeConfig(
|
||||
name="max_tokens", type="integer", required=False, default=0,
|
||||
description="Max tokens to generate (0 = model default)",
|
||||
label={"en_US": "Max Tokens", "zh_Hans": "最大令牌数"},
|
||||
),
|
||||
]
|
||||
|
||||
def _resolve_template(self, template: str, inputs: dict[str, Any], context: ExecutionContext) -> str:
|
||||
"""Resolve {{variable}} placeholders in a template string."""
|
||||
def replacer(match: re.Match) -> str:
|
||||
expr = match.group(1).strip()
|
||||
# Try inputs first
|
||||
if expr in inputs:
|
||||
return str(inputs[expr])
|
||||
# Try context variables
|
||||
if expr.startswith("variables."):
|
||||
var_name = expr[len("variables."):]
|
||||
return str(context.variables.get(var_name, ""))
|
||||
# Try message context
|
||||
if expr.startswith("message.") and context.message_context:
|
||||
attr = expr[len("message."):]
|
||||
return str(getattr(context.message_context, attr, ""))
|
||||
return match.group(0) # leave unresolved
|
||||
|
||||
return re.sub(r"\{\{([^}]+)\}\}", replacer, template)
|
||||
|
||||
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
|
||||
model_uuid = self.get_config("model", "")
|
||||
if not model_uuid:
|
||||
raise ValueError("No model configured for LLM call node")
|
||||
|
||||
if not self.ap:
|
||||
raise RuntimeError("Application instance not available — cannot call LLM")
|
||||
|
||||
# Resolve prompts
|
||||
system_prompt = self._resolve_template(
|
||||
self.get_config("system_prompt", ""), inputs, context
|
||||
)
|
||||
user_prompt = self._resolve_template(
|
||||
self.get_config("user_prompt_template", "{{input}}"), inputs, context
|
||||
)
|
||||
|
||||
# Build messages
|
||||
messages: list[provider_message.Message] = []
|
||||
if system_prompt:
|
||||
messages.append(provider_message.Message(role="system", content=system_prompt))
|
||||
messages.append(provider_message.Message(role="user", content=user_prompt))
|
||||
|
||||
# Get model
|
||||
runtime_model = await self.ap.model_mgr.get_model_by_uuid(model_uuid)
|
||||
|
||||
# Build extra args from config
|
||||
extra_args: dict[str, Any] = {}
|
||||
temperature = self.get_config("temperature")
|
||||
if temperature is not None:
|
||||
extra_args["temperature"] = float(temperature)
|
||||
max_tokens = self.get_config("max_tokens", 0)
|
||||
if max_tokens and int(max_tokens) > 0:
|
||||
extra_args["max_tokens"] = int(max_tokens)
|
||||
|
||||
# Invoke LLM
|
||||
logger.info(f"LLM call node {self.node_id}: invoking model {model_uuid}")
|
||||
result_message = await runtime_model.provider.invoke_llm(
|
||||
query=None,
|
||||
model=runtime_model,
|
||||
messages=messages,
|
||||
funcs=None,
|
||||
extra_args=extra_args,
|
||||
)
|
||||
|
||||
# Extract response text
|
||||
response_text = ""
|
||||
if isinstance(result_message.content, str):
|
||||
response_text = result_message.content
|
||||
elif isinstance(result_message.content, list):
|
||||
# ContentElement list — concatenate text elements
|
||||
for elem in result_message.content:
|
||||
if hasattr(elem, 'text') and elem.text:
|
||||
response_text += elem.text
|
||||
elif isinstance(elem, str):
|
||||
response_text += elem
|
||||
|
||||
# Extract usage info if available
|
||||
usage = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
|
||||
if hasattr(result_message, 'usage') and result_message.usage:
|
||||
u = result_message.usage
|
||||
usage = {
|
||||
"prompt_tokens": getattr(u, 'prompt_tokens', 0) or 0,
|
||||
"completion_tokens": getattr(u, 'completion_tokens', 0) or 0,
|
||||
"total_tokens": getattr(u, 'total_tokens', 0) or 0,
|
||||
}
|
||||
elif hasattr(result_message, 'token_usage') and result_message.token_usage:
|
||||
u = result_message.token_usage
|
||||
usage = {
|
||||
"prompt_tokens": getattr(u, 'prompt_tokens', 0) or 0,
|
||||
"completion_tokens": getattr(u, 'completion_tokens', 0) or 0,
|
||||
"total_tokens": getattr(u, 'total_tokens', 0) or 0,
|
||||
}
|
||||
|
||||
return {
|
||||
"response": response_text,
|
||||
"usage": usage,
|
||||
}
|
||||
62
src/langbot/pkg/workflow/nodes/loop.py
Normal file
62
src/langbot/pkg/workflow/nodes/loop.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""Loop Node - iterate over items"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from ..entities import ExecutionContext
|
||||
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
|
||||
|
||||
|
||||
@workflow_node('loop')
|
||||
class LoopNode(WorkflowNode):
|
||||
"""Loop node - iterate over items"""
|
||||
|
||||
type_name = "loop"
|
||||
category = "control"
|
||||
icon = "🔁"
|
||||
name = "loop"
|
||||
name_zh = "循环"
|
||||
name_en = "Loop"
|
||||
description = "loop"
|
||||
description_zh = "遍历项目或重复直到满足条件"
|
||||
description_en = "Iterate over items or repeat until condition"
|
||||
|
||||
inputs: ClassVar[list[NodePort]] = [
|
||||
NodePort(name="items", type="array", description="Items to iterate over", required=False),
|
||||
]
|
||||
outputs: ClassVar[list[NodePort]] = [
|
||||
NodePort(name="item", type="any", description="Current item in iteration"),
|
||||
NodePort(name="index", type="number", description="Current iteration index"),
|
||||
NodePort(name="results", type="array", description="All iteration results"),
|
||||
NodePort(name="completed", type="boolean", description="Whether loop completed"),
|
||||
]
|
||||
config_schema: ClassVar[list[NodeConfig]] = [
|
||||
NodeConfig(
|
||||
name="loop_type", type="select", required=True, default="foreach",
|
||||
description="Type of loop",
|
||||
label={"en_US": "Loop Type", "zh_Hans": "循环类型"},
|
||||
options=["foreach", "while", "count"],
|
||||
),
|
||||
NodeConfig(
|
||||
name="max_iterations", type="integer", required=False, default=100,
|
||||
description="Maximum iterations (safety limit)",
|
||||
label={"en_US": "Max Iterations", "zh_Hans": "最大迭代次数"},
|
||||
),
|
||||
]
|
||||
|
||||
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
|
||||
items = inputs.get("items", [])
|
||||
if not isinstance(items, list):
|
||||
items = [items] if items else []
|
||||
|
||||
max_iterations = self.get_config("max_iterations", 100)
|
||||
items = items[:max_iterations]
|
||||
|
||||
return {
|
||||
"item": items[0] if items else None,
|
||||
"index": 0,
|
||||
"results": [],
|
||||
"completed": len(items) == 0,
|
||||
"_items": items,
|
||||
}
|
||||
70
src/langbot/pkg/workflow/nodes/mcp_tool.py
Normal file
70
src/langbot/pkg/workflow/nodes/mcp_tool.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""MCP Tool Node - Invoke MCP (Model Context Protocol) tools
|
||||
|
||||
This module contains the implementation for the MCP Tool workflow node.
|
||||
Node metadata (label, description, inputs, outputs, config) is loaded from:
|
||||
../../templates/metadata/nodes/mcp_tool.yaml
|
||||
|
||||
The i18n for label and description is handled on the frontend side.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from ..entities import ExecutionContext
|
||||
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
|
||||
|
||||
|
||||
@workflow_node('mcp_tool')
|
||||
class MCPToolNode(WorkflowNode):
|
||||
"""MCP tool node - invoke MCP (Model Context Protocol) tools"""
|
||||
|
||||
# Node type for registration
|
||||
type_name = "mcp_tool"
|
||||
|
||||
# Category and icon - these are not i18n
|
||||
category = "integration"
|
||||
icon = "Wrench"
|
||||
|
||||
# Name and description - i18n handled on frontend side
|
||||
# Frontend will use node type key to look up translation
|
||||
name = "mcp_tool"
|
||||
description = "mcp_tool"
|
||||
name_zh = "MCP 工具"
|
||||
name_en = "MCP Tool"
|
||||
description_zh = "调用 MCP 工具"
|
||||
description_en = "Invoke an MCP (Model Context Protocol) tool"
|
||||
|
||||
# Inputs/outputs/config - loaded from YAML at runtime
|
||||
inputs: ClassVar[list[NodePort]] = []
|
||||
outputs: ClassVar[list[NodePort]] = []
|
||||
config_schema: ClassVar[list[NodeConfig]] = []
|
||||
|
||||
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
|
||||
"""Execute the MCP tool node
|
||||
|
||||
Args:
|
||||
inputs: Input data from connected nodes
|
||||
context: Execution context with workflow state
|
||||
|
||||
Returns:
|
||||
Dictionary of output values
|
||||
"""
|
||||
server_name = self.get_config("server_name", "")
|
||||
tool_name = self.get_config("tool_name", "")
|
||||
arguments_template = self.get_config("arguments_template", "")
|
||||
timeout = self.get_config("timeout", 30)
|
||||
|
||||
arguments = inputs.get("arguments", arguments_template)
|
||||
|
||||
return {
|
||||
"result": None,
|
||||
"success": False,
|
||||
"error": f"MCP tool '{server_name}/{tool_name}' not implemented yet",
|
||||
"_debug": {
|
||||
"server_name": server_name,
|
||||
"tool_name": tool_name,
|
||||
"arguments": arguments,
|
||||
"timeout": timeout,
|
||||
},
|
||||
}
|
||||
103
src/langbot/pkg/workflow/nodes/memory_store.py
Normal file
103
src/langbot/pkg/workflow/nodes/memory_store.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""Memory Store Node - store and retrieve from workflow memory
|
||||
|
||||
Node metadata is loaded from: ../../templates/metadata/nodes/memory_store.yaml
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from ..entities import ExecutionContext
|
||||
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
|
||||
|
||||
|
||||
class MemoryHelper:
|
||||
"""Helper class wrapping context.memory dict with get/set/delete/list_all/append operations"""
|
||||
|
||||
def __init__(self, memory_dict: dict[str, Any]):
|
||||
self._data = memory_dict
|
||||
|
||||
def get(self, key: str, scope: str = "execution", default: Any = None) -> Any:
|
||||
"""Get a value from memory by key"""
|
||||
scoped_key = f"{scope}:{key}" if scope else key
|
||||
return self._data.get(scoped_key, default)
|
||||
|
||||
def set(self, key: str, value: Any, scope: str = "execution", ttl: int = 0) -> None:
|
||||
"""Set a value in memory"""
|
||||
scoped_key = f"{scope}:{key}" if scope else key
|
||||
self._data[scoped_key] = value
|
||||
|
||||
def delete(self, key: str, scope: str = "execution") -> None:
|
||||
"""Delete a value from memory"""
|
||||
scoped_key = f"{scope}:{key}" if scope else key
|
||||
self._data.pop(scoped_key, None)
|
||||
|
||||
def list_all(self, scope: str = "execution") -> dict[str, Any]:
|
||||
"""List all values in the given scope"""
|
||||
prefix = f"{scope}:"
|
||||
return {
|
||||
k[len(prefix):]: v
|
||||
for k, v in self._data.items()
|
||||
if k.startswith(prefix)
|
||||
}
|
||||
|
||||
def append(self, key: str, value: Any, scope: str = "execution", ttl: int = 0) -> list:
|
||||
"""Append a value to a list in memory"""
|
||||
current = self.get(key, scope=scope, default=[])
|
||||
if isinstance(current, list):
|
||||
current.append(value)
|
||||
else:
|
||||
current = [current, value]
|
||||
self.set(key, current, scope=scope, ttl=ttl)
|
||||
return current
|
||||
|
||||
|
||||
@workflow_node('memory_store')
|
||||
class MemoryStoreNode(WorkflowNode):
|
||||
"""Memory store node - store and retrieve from workflow memory"""
|
||||
|
||||
type_name = "memory_store"
|
||||
category = "integration"
|
||||
icon = "HardDrive"
|
||||
name = "memory_store"
|
||||
description = "memory_store"
|
||||
name_zh = "记忆存储"
|
||||
name_en = "Memory Store"
|
||||
description_zh = "从工作流记忆中存储和检索数据"
|
||||
description_en = "Store and retrieve data from workflow memory"
|
||||
|
||||
inputs: ClassVar[list[NodePort]] = []
|
||||
outputs: ClassVar[list[NodePort]] = []
|
||||
config_schema: ClassVar[list[NodeConfig]] = []
|
||||
|
||||
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
|
||||
operation = self.get_config("operation", "get")
|
||||
key = self.get_config("key", "")
|
||||
scope = self.get_config("scope", "execution")
|
||||
ttl = self.get_config("ttl", 0)
|
||||
|
||||
value = inputs.get("value")
|
||||
|
||||
# Wrap context.memory dict with MemoryHelper for structured operations
|
||||
memory = MemoryHelper(context.memory)
|
||||
|
||||
try:
|
||||
if operation == "get":
|
||||
result = memory.get(key, scope=scope)
|
||||
return {"result": result, "success": True}
|
||||
elif operation == "set":
|
||||
memory.set(key, value, scope=scope, ttl=ttl)
|
||||
return {"result": value, "success": True}
|
||||
elif operation == "delete":
|
||||
memory.delete(key, scope=scope)
|
||||
return {"result": None, "success": True}
|
||||
elif operation == "append":
|
||||
result = memory.append(key, value, scope=scope, ttl=ttl)
|
||||
return {"result": result, "success": True}
|
||||
elif operation == "list":
|
||||
result = memory.list_all(scope=scope)
|
||||
return {"result": result, "success": True}
|
||||
else:
|
||||
return {"result": None, "success": False, "error": f"Unknown operation: {operation}"}
|
||||
except Exception as e:
|
||||
return {"result": None, "success": False, "error": str(e)}
|
||||
65
src/langbot/pkg/workflow/nodes/merge.py
Normal file
65
src/langbot/pkg/workflow/nodes/merge.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""Merge Node - combine multiple inputs
|
||||
|
||||
Node metadata is loaded from: ../../templates/metadata/nodes/merge.yaml
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from ..entities import ExecutionContext
|
||||
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
|
||||
|
||||
|
||||
@workflow_node('merge')
|
||||
class MergeNode(WorkflowNode):
|
||||
"""Merge node - combine multiple inputs"""
|
||||
|
||||
type_name = "merge"
|
||||
category = "control"
|
||||
icon = "🔗"
|
||||
name = "merge"
|
||||
description = "merge"
|
||||
name_zh = "合并"
|
||||
name_en = "Merge"
|
||||
description_zh = "将多个分支合并在一起"
|
||||
description_en = "Merge multiple branches back together"
|
||||
|
||||
inputs: ClassVar[list[NodePort]] = []
|
||||
outputs: ClassVar[list[NodePort]] = []
|
||||
config_schema: ClassVar[list[NodeConfig]] = []
|
||||
|
||||
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
|
||||
strategy = self.get_config("merge_strategy", "object")
|
||||
|
||||
values = [inputs.get("input_1"), inputs.get("input_2"), inputs.get("input_3"), inputs.get("input_4")]
|
||||
non_null_values = [v for v in values if v is not None]
|
||||
|
||||
if strategy == "object":
|
||||
merged = {}
|
||||
for i, v in enumerate(non_null_values):
|
||||
if isinstance(v, dict):
|
||||
merged.update(v)
|
||||
else:
|
||||
merged[f"value_{i}"] = v
|
||||
return {"merged": merged, "array": non_null_values}
|
||||
|
||||
elif strategy == "array":
|
||||
return {"merged": non_null_values, "array": non_null_values}
|
||||
|
||||
elif strategy == "first_non_null":
|
||||
first = non_null_values[0] if non_null_values else None
|
||||
return {"merged": first, "array": non_null_values}
|
||||
|
||||
elif strategy == "concat":
|
||||
if all(isinstance(v, str) for v in non_null_values):
|
||||
return {"merged": "".join(non_null_values), "array": non_null_values}
|
||||
elif all(isinstance(v, list) for v in non_null_values):
|
||||
merged_list = []
|
||||
for v in non_null_values:
|
||||
merged_list.extend(v)
|
||||
return {"merged": merged_list, "array": merged_list}
|
||||
else:
|
||||
return {"merged": non_null_values, "array": non_null_values}
|
||||
|
||||
return {"merged": non_null_values, "array": non_null_values}
|
||||
56
src/langbot/pkg/workflow/nodes/message_trigger.py
Normal file
56
src/langbot/pkg/workflow/nodes/message_trigger.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""Message Trigger Node - triggers workflow on message arrival
|
||||
|
||||
This module contains the implementation for the Message Trigger workflow node.
|
||||
Node metadata (label, description, inputs, outputs, config) is loaded from:
|
||||
../../templates/metadata/nodes/message_trigger.yaml
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from ..entities import ExecutionContext
|
||||
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
|
||||
|
||||
|
||||
@workflow_node('message_trigger')
|
||||
class MessageTriggerNode(WorkflowNode):
|
||||
"""Message trigger node - triggers workflow on message arrival"""
|
||||
|
||||
type_name = "message_trigger"
|
||||
category = "trigger"
|
||||
icon = "💬"
|
||||
name = "message_trigger"
|
||||
description = "message_trigger"
|
||||
name_zh = "消息触发"
|
||||
name_en = "Message Trigger"
|
||||
description_zh = "当收到消息时触发工作流"
|
||||
description_en = "Trigger workflow when a message is received"
|
||||
|
||||
inputs: ClassVar[list[NodePort]] = []
|
||||
outputs: ClassVar[list[NodePort]] = []
|
||||
config_schema: ClassVar[list[NodeConfig]] = []
|
||||
|
||||
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
|
||||
msg_ctx = context.message_context
|
||||
|
||||
if msg_ctx:
|
||||
return {
|
||||
"message": msg_ctx.message_content,
|
||||
"sender_id": msg_ctx.sender_id,
|
||||
"sender_name": msg_ctx.sender_name,
|
||||
"platform": msg_ctx.platform,
|
||||
"conversation_id": msg_ctx.conversation_id,
|
||||
"is_group": msg_ctx.is_group,
|
||||
"context": msg_ctx.model_dump(),
|
||||
}
|
||||
|
||||
return {
|
||||
"message": context.get_variable("message", ""),
|
||||
"sender_id": context.get_variable("sender_id", ""),
|
||||
"sender_name": context.get_variable("sender_name", ""),
|
||||
"platform": context.get_variable("platform", ""),
|
||||
"conversation_id": context.get_variable("conversation_id", ""),
|
||||
"is_group": context.get_variable("is_group", False),
|
||||
"context": context.trigger_data,
|
||||
}
|
||||
47
src/langbot/pkg/workflow/nodes/n8n_workflow.py
Normal file
47
src/langbot/pkg/workflow/nodes/n8n_workflow.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""N8n Workflow Node - call n8n workflow API
|
||||
|
||||
Node metadata is loaded from: ../../templates/metadata/nodes/n8n_workflow.yaml
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from ..entities import ExecutionContext
|
||||
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
|
||||
|
||||
|
||||
@workflow_node('n8n_workflow')
|
||||
class N8nWorkflowNode(WorkflowNode):
|
||||
"""n8n workflow node - call n8n workflow API"""
|
||||
|
||||
type_name = "n8n_workflow"
|
||||
category = "integration"
|
||||
icon = "Workflow"
|
||||
name = "n8n_workflow"
|
||||
description = "n8n_workflow"
|
||||
name_zh = "n8n 工作流"
|
||||
name_en = "N8n Workflow"
|
||||
description_zh = "通过 webhook 调用 n8n 工作流"
|
||||
description_en = "Call an n8n workflow via webhook"
|
||||
|
||||
inputs: ClassVar[list[NodePort]] = []
|
||||
outputs: ClassVar[list[NodePort]] = []
|
||||
config_schema: ClassVar[list[NodeConfig]] = []
|
||||
|
||||
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
|
||||
webhook_url = self.get_config("webhook_url", "")
|
||||
auth_type = self.get_config("auth_type", "none")
|
||||
timeout = self.get_config("timeout", 120)
|
||||
payload = inputs.get("payload", {})
|
||||
|
||||
return {
|
||||
"result": None,
|
||||
"success": False,
|
||||
"_debug": {
|
||||
"webhook_url": webhook_url,
|
||||
"auth_type": auth_type,
|
||||
"timeout": timeout,
|
||||
"payload": payload,
|
||||
},
|
||||
}
|
||||
37
src/langbot/pkg/workflow/nodes/opening_statement.py
Normal file
37
src/langbot/pkg/workflow/nodes/opening_statement.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""Opening Statement Node - provide conversation opener and suggested questions
|
||||
|
||||
Node metadata is loaded from: ../../templates/metadata/nodes/opening_statement.yaml
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from ..entities import ExecutionContext
|
||||
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
|
||||
|
||||
|
||||
@workflow_node('opening_statement')
|
||||
class OpeningStatementNode(WorkflowNode):
|
||||
"""Opening statement node - provide conversation opener and suggested questions"""
|
||||
|
||||
type_name = "opening_statement"
|
||||
category = "action"
|
||||
icon = "👋"
|
||||
name = "opening_statement"
|
||||
description = "opening_statement"
|
||||
name_zh = "对话开场白"
|
||||
name_en = "Opening Statement"
|
||||
description_zh = "提供对话开场白和建议问题"
|
||||
description_en = "Provide conversation opener and suggested questions"
|
||||
|
||||
inputs: ClassVar[list[NodePort]] = []
|
||||
outputs: ClassVar[list[NodePort]] = []
|
||||
config_schema: ClassVar[list[NodeConfig]] = []
|
||||
|
||||
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
|
||||
statement = self.get_config("statement", "")
|
||||
suggestions = self.get_config("suggested_questions", [])
|
||||
show = self.get_config("show_suggestions", True)
|
||||
|
||||
return {"statement": statement, "suggested_questions": suggestions if show else []}
|
||||
49
src/langbot/pkg/workflow/nodes/parallel.py
Normal file
49
src/langbot/pkg/workflow/nodes/parallel.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""Parallel Node - execute multiple branches simultaneously"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from ..entities import ExecutionContext
|
||||
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
|
||||
|
||||
|
||||
@workflow_node('parallel')
|
||||
class ParallelNode(WorkflowNode):
|
||||
"""Parallel node - execute multiple branches simultaneously"""
|
||||
|
||||
type_name = "parallel"
|
||||
category = "control"
|
||||
icon = "⚡"
|
||||
name = "parallel"
|
||||
name_zh = "并行执行"
|
||||
name_en = "Parallel"
|
||||
description = "parallel"
|
||||
description_zh = "并行执行多个分支"
|
||||
description_en = "Execute multiple branches in parallel"
|
||||
|
||||
inputs: ClassVar[list[NodePort]] = [
|
||||
NodePort(name="input", type="any", description="Input data for all branches", required=False),
|
||||
]
|
||||
outputs: ClassVar[list[NodePort]] = [
|
||||
NodePort(name="results", type="object", description="Combined results from all branches"),
|
||||
NodePort(name="errors", type="array", description="Errors from branches (if any)"),
|
||||
]
|
||||
config_schema: ClassVar[list[NodeConfig]] = [
|
||||
NodeConfig(
|
||||
name="wait_all", type="boolean", required=False, default=True,
|
||||
description="Wait for all branches to complete",
|
||||
label={"en_US": "Wait for All", "zh_Hans": "等待全部完成"},
|
||||
),
|
||||
NodeConfig(
|
||||
name="fail_fast", type="boolean", required=False, default=False,
|
||||
description="Stop all branches if any fails",
|
||||
label={"en_US": "Fail Fast", "zh_Hans": "快速失败"},
|
||||
),
|
||||
]
|
||||
|
||||
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
|
||||
return {
|
||||
"results": {},
|
||||
"errors": [],
|
||||
}
|
||||
40
src/langbot/pkg/workflow/nodes/parameter_extractor.py
Normal file
40
src/langbot/pkg/workflow/nodes/parameter_extractor.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""Parameter Extractor Node - extract structured parameters from text
|
||||
|
||||
Node metadata is loaded from: ../../templates/metadata/nodes/parameter_extractor.yaml
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from ..entities import ExecutionContext
|
||||
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
|
||||
|
||||
|
||||
@workflow_node('parameter_extractor')
|
||||
class ParameterExtractorNode(WorkflowNode):
|
||||
"""Parameter extractor node - extract structured parameters from text"""
|
||||
|
||||
type_name = "parameter_extractor"
|
||||
category = "process"
|
||||
icon: str = "📤"
|
||||
name = "parameter_extractor"
|
||||
description = "parameter_extractor"
|
||||
name_zh = "参数提取器"
|
||||
name_en = "Parameter Extractor"
|
||||
description_zh = "使用 AI 从文本中提取结构化参数"
|
||||
description_en = "Extract structured parameters from text using AI"
|
||||
|
||||
inputs: ClassVar[list[NodePort]] = []
|
||||
outputs: ClassVar[list[NodePort]] = []
|
||||
config_schema: ClassVar[list[NodeConfig]] = []
|
||||
|
||||
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
|
||||
text = inputs.get("text", "")
|
||||
param_defs = self.get_config("parameters", [])
|
||||
|
||||
extracted = {}
|
||||
for param in param_defs:
|
||||
extracted[param.get("name", "")] = None
|
||||
|
||||
return {"parameters": extracted, "extraction_success": False}
|
||||
42
src/langbot/pkg/workflow/nodes/plugin_call.py
Normal file
42
src/langbot/pkg/workflow/nodes/plugin_call.py
Normal file
@@ -0,0 +1,42 @@
|
||||
# """Plugin Call Node - invoke a plugin
|
||||
|
||||
# Node metadata is loaded from: ../../templates/metadata/nodes/plugin_call.yaml
|
||||
# """
|
||||
|
||||
# from __future__ import annotations
|
||||
|
||||
# from typing import Any, ClassVar
|
||||
|
||||
# from ..entities import ExecutionContext
|
||||
# from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
|
||||
|
||||
|
||||
# @workflow_node('plugin_call')
|
||||
# class PluginCallNode(WorkflowNode):
|
||||
# """Plugin call node - invoke a plugin"""
|
||||
|
||||
# type_name = "plugin_call"
|
||||
# category = "action"
|
||||
# icon = "🔌"
|
||||
# name = "plugin_call"
|
||||
# description = "plugin_call"
|
||||
|
||||
# inputs: ClassVar[list[NodePort]] = []
|
||||
# outputs: ClassVar[list[NodePort]] = []
|
||||
# config_schema: ClassVar[list[NodeConfig]] = []
|
||||
|
||||
# async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
|
||||
# plugin_name = self.get_config("plugin_name", "")
|
||||
# method_name = self.get_config("method_name", "")
|
||||
# arguments = inputs.get("arguments", {})
|
||||
|
||||
# return {
|
||||
# "result": None,
|
||||
# "success": False,
|
||||
# "error": f"Plugin call '{plugin_name}/{method_name}' not implemented yet",
|
||||
# "_debug": {
|
||||
# "plugin_name": plugin_name,
|
||||
# "method_name": method_name,
|
||||
# "arguments": arguments,
|
||||
# },
|
||||
# }
|
||||
43
src/langbot/pkg/workflow/nodes/question_classifier.py
Normal file
43
src/langbot/pkg/workflow/nodes/question_classifier.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""Question Classifier Node - classify user questions into categories
|
||||
|
||||
Node metadata is loaded from: ../../templates/metadata/nodes/question_classifier.yaml
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from ..entities import ExecutionContext
|
||||
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
|
||||
|
||||
|
||||
@workflow_node('question_classifier')
|
||||
class QuestionClassifierNode(WorkflowNode):
|
||||
"""Question classifier node - classify user questions into categories"""
|
||||
|
||||
type_name = "question_classifier"
|
||||
category = "process"
|
||||
icon = "🏷️"
|
||||
name = "question_classifier"
|
||||
description = "question_classifier"
|
||||
name_zh = "问题分类器"
|
||||
name_en = "Question Classifier"
|
||||
description_zh = "使用 AI 将问题分类到预定义类别"
|
||||
description_en = "Classify questions into predefined categories using AI"
|
||||
|
||||
inputs: ClassVar[list[NodePort]] = []
|
||||
outputs: ClassVar[list[NodePort]] = []
|
||||
config_schema: ClassVar[list[NodeConfig]] = []
|
||||
|
||||
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
|
||||
question = inputs.get("question", "")
|
||||
categories = self.get_config("categories", [])
|
||||
|
||||
if categories:
|
||||
return {
|
||||
"category": categories[0].get("name", "unknown"),
|
||||
"confidence": 0.8,
|
||||
"all_scores": {cat.get("name"): 0.1 for cat in categories},
|
||||
}
|
||||
|
||||
return {"category": "unknown", "confidence": 0.0, "all_scores": {}}
|
||||
53
src/langbot/pkg/workflow/nodes/redis_operation.py
Normal file
53
src/langbot/pkg/workflow/nodes/redis_operation.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""Redis Operation Node - perform Redis cache operations
|
||||
|
||||
Node metadata is loaded from: ../../templates/metadata/nodes/redis_operation.yaml
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from ..entities import ExecutionContext
|
||||
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
|
||||
|
||||
|
||||
@workflow_node('redis_operation')
|
||||
class RedisOperationNode(WorkflowNode):
|
||||
"""Redis operation node - perform Redis cache operations"""
|
||||
|
||||
type_name = "redis_operation"
|
||||
category = "integration"
|
||||
icon = "Server"
|
||||
name = "redis_operation"
|
||||
description = "redis_operation"
|
||||
name_zh = "Redis 操作"
|
||||
name_en = "Redis Operation"
|
||||
description_zh = "执行 Redis 缓存操作"
|
||||
description_en = "Perform Redis cache operations"
|
||||
|
||||
inputs: ClassVar[list[NodePort]] = []
|
||||
outputs: ClassVar[list[NodePort]] = []
|
||||
config_schema: ClassVar[list[NodeConfig]] = []
|
||||
|
||||
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
|
||||
connection_url = self.get_config("connection_url", "redis://localhost:6379")
|
||||
operation = self.get_config("operation", "get")
|
||||
key_template = self.get_config("key_template", "")
|
||||
hash_field = self.get_config("hash_field", "")
|
||||
ttl = self.get_config("ttl", 0)
|
||||
|
||||
key = inputs.get("key", key_template)
|
||||
value = inputs.get("value")
|
||||
|
||||
return {
|
||||
"result": None,
|
||||
"success": False,
|
||||
"_debug": {
|
||||
"connection_url": connection_url,
|
||||
"operation": operation,
|
||||
"key": key,
|
||||
"hash_field": hash_field,
|
||||
"ttl": ttl,
|
||||
"value": value,
|
||||
},
|
||||
}
|
||||
95
src/langbot/pkg/workflow/nodes/reply_message.py
Normal file
95
src/langbot/pkg/workflow/nodes/reply_message.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""Reply Message Node - reply to the triggering message
|
||||
|
||||
Node metadata is loaded from: ../../templates/metadata/nodes/reply_message.yaml
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from ..entities import ExecutionContext
|
||||
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@workflow_node('reply_message')
|
||||
class ReplyMessageNode(WorkflowNode):
|
||||
"""Reply message node - reply to the triggering message"""
|
||||
|
||||
type_name = "reply_message"
|
||||
category = "action"
|
||||
icon = "↩️"
|
||||
name = "reply_message"
|
||||
description = "reply_message"
|
||||
name_zh = "回复消息"
|
||||
name_en = "Reply Message"
|
||||
description_zh = "回复触发工作流的消息"
|
||||
description_en = "Reply to the message that triggered the workflow"
|
||||
|
||||
inputs: ClassVar[list[NodePort]] = []
|
||||
outputs: ClassVar[list[NodePort]] = []
|
||||
config_schema: ClassVar[list[NodeConfig]] = []
|
||||
|
||||
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
|
||||
message = inputs.get("message")
|
||||
if message in (None, ""):
|
||||
message = inputs.get("input")
|
||||
if message in (None, ""):
|
||||
message = inputs.get("response")
|
||||
if message in (None, "") and context.message_context:
|
||||
message = context.message_context.message_content
|
||||
if message is None:
|
||||
message = ""
|
||||
|
||||
template = self.get_config("message_template")
|
||||
|
||||
if template:
|
||||
message = template
|
||||
for key, value in inputs.items():
|
||||
message = message.replace(f"{{{{{key}}}}}", str(value))
|
||||
for key, value in context.variables.items():
|
||||
message = message.replace(f"{{{{variables.{key}}}}}", str(value))
|
||||
|
||||
logger.info(
|
||||
"ReplyMessageNode resolved message",
|
||||
extra={
|
||||
'node_id': self.node_id,
|
||||
'execution_id': context.execution_id,
|
||||
'input_keys': list(inputs.keys()),
|
||||
'message_preview': str(message)[:200],
|
||||
'has_template': bool(template),
|
||||
'session_id': context.session_id,
|
||||
},
|
||||
)
|
||||
|
||||
if not str(message).strip():
|
||||
logger.warning(
|
||||
"ReplyMessageNode has empty message after resolution",
|
||||
extra={
|
||||
'node_id': self.node_id,
|
||||
'execution_id': context.execution_id,
|
||||
'input_keys': list(inputs.keys()),
|
||||
},
|
||||
)
|
||||
|
||||
# 实际发送消息
|
||||
if self.ap:
|
||||
from langbot_plugin.api.entities.builtin.platform.message import MessageChain, Plain
|
||||
message_chain = MessageChain([Plain(text=str(message))])
|
||||
await self.ap.platform_mgr.websocket_proxy_bot.adapter.send_message(
|
||||
target_type='person',
|
||||
target_id=f'websocket_{context.session_id}',
|
||||
message=message_chain,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"ReplyMessageNode missing application instance",
|
||||
extra={
|
||||
'node_id': self.node_id,
|
||||
'execution_id': context.execution_id,
|
||||
},
|
||||
)
|
||||
|
||||
return {"status": "sent", "message_id": f"reply_{context.execution_id}"}
|
||||
36
src/langbot/pkg/workflow/nodes/send_message.py
Normal file
36
src/langbot/pkg/workflow/nodes/send_message.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""Send Message Node - send message to a target
|
||||
|
||||
Node metadata is loaded from: ../../templates/metadata/nodes/send_message.yaml
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from ..entities import ExecutionContext
|
||||
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
|
||||
|
||||
|
||||
@workflow_node('send_message')
|
||||
class SendMessageNode(WorkflowNode):
|
||||
"""Send message node - send message to a target"""
|
||||
|
||||
type_name = "send_message"
|
||||
category = "action"
|
||||
icon = "📤"
|
||||
name = "send_message"
|
||||
description = "send_message"
|
||||
name_zh = "发送消息"
|
||||
name_en = "Send Message"
|
||||
description_zh = "向聊天或用户发送消息"
|
||||
description_en = "Send a message to a chat or user"
|
||||
|
||||
inputs: ClassVar[list[NodePort]] = []
|
||||
outputs: ClassVar[list[NodePort]] = []
|
||||
config_schema: ClassVar[list[NodeConfig]] = []
|
||||
|
||||
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
|
||||
message = inputs.get("message", "")
|
||||
target = inputs.get("target") or self.get_config("target_id", "")
|
||||
|
||||
return {"status": "sent", "message_id": f"msg_{context.execution_id}"}
|
||||
64
src/langbot/pkg/workflow/nodes/set_variable.py
Normal file
64
src/langbot/pkg/workflow/nodes/set_variable.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""Set Variable Node - set workflow or conversation variable
|
||||
|
||||
Node metadata is loaded from: ../../templates/metadata/nodes/set_variable.yaml
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from ..entities import ExecutionContext
|
||||
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
|
||||
|
||||
|
||||
@workflow_node('set_variable')
|
||||
class SetVariableNode(WorkflowNode):
|
||||
"""Set variable node - set workflow or conversation variable"""
|
||||
|
||||
type_name = "set_variable"
|
||||
category = "action"
|
||||
icon = "📝"
|
||||
name = "set_variable"
|
||||
description = "set_variable"
|
||||
name_zh = "设置变量"
|
||||
name_en = "Set Variable"
|
||||
description_zh = "设置上下文变量值"
|
||||
description_en = "Set a context variable value"
|
||||
|
||||
inputs: ClassVar[list[NodePort]] = []
|
||||
outputs: ClassVar[list[NodePort]] = []
|
||||
config_schema: ClassVar[list[NodeConfig]] = []
|
||||
|
||||
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
|
||||
value = inputs.get("value")
|
||||
name = self.get_config("variable_name", "")
|
||||
scope = self.get_config("variable_scope", "workflow")
|
||||
operation = self.get_config("operation", "set")
|
||||
|
||||
if scope == "conversation":
|
||||
current = context.get_conversation_variable(name)
|
||||
else:
|
||||
current = context.get_variable(name)
|
||||
|
||||
if operation == "set":
|
||||
final_value = value
|
||||
elif operation == "append":
|
||||
if isinstance(current, list):
|
||||
final_value = current + [value]
|
||||
elif isinstance(current, str):
|
||||
final_value = current + str(value)
|
||||
else:
|
||||
final_value = [current, value] if current else [value]
|
||||
elif operation == "increment":
|
||||
final_value = (current or 0) + (value if isinstance(value, (int, float)) else 1)
|
||||
elif operation == "decrement":
|
||||
final_value = (current or 0) - (value if isinstance(value, (int, float)) else 1)
|
||||
else:
|
||||
final_value = value
|
||||
|
||||
if scope == "conversation":
|
||||
context.set_conversation_variable(name, final_value)
|
||||
else:
|
||||
context.set_variable(name, final_value)
|
||||
|
||||
return {"value": final_value}
|
||||
45
src/langbot/pkg/workflow/nodes/store_data.py
Normal file
45
src/langbot/pkg/workflow/nodes/store_data.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""Store Data Node - save data to storage
|
||||
|
||||
Node metadata is loaded from: ../../templates/metadata/nodes/store_data.yaml
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from ..entities import ExecutionContext
|
||||
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
|
||||
|
||||
|
||||
@workflow_node('store_data')
|
||||
class StoreDataNode(WorkflowNode):
|
||||
"""Store data node - save data to storage"""
|
||||
|
||||
type_name = "store_data"
|
||||
category = "action"
|
||||
icon = "💾"
|
||||
name = "store_data"
|
||||
description = "store_data"
|
||||
name_zh = "存储数据"
|
||||
name_en = "Store Data"
|
||||
description_zh = "将数据存储到持久化存储"
|
||||
description_en = "Store data to persistent storage"
|
||||
|
||||
inputs: ClassVar[list[NodePort]] = []
|
||||
outputs: ClassVar[list[NodePort]] = []
|
||||
config_schema: ClassVar[list[NodeConfig]] = []
|
||||
|
||||
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
|
||||
key = inputs.get("key", "")
|
||||
value = inputs.get("value")
|
||||
storage_type = self.get_config("storage_type", "session")
|
||||
prefix = self.get_config("key_prefix", "")
|
||||
|
||||
full_key = f"{prefix}{key}" if prefix else key
|
||||
|
||||
if storage_type == "session":
|
||||
context.set_conversation_variable(full_key, value)
|
||||
else:
|
||||
context.set_variable(full_key, value)
|
||||
|
||||
return {"status": "stored"}
|
||||
64
src/langbot/pkg/workflow/nodes/switch.py
Normal file
64
src/langbot/pkg/workflow/nodes/switch.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""Switch Node - multi-way branch based on value
|
||||
|
||||
Node metadata is loaded from: ../../templates/metadata/nodes/switch.yaml
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from ..entities import ExecutionContext
|
||||
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
|
||||
|
||||
|
||||
@workflow_node('switch')
|
||||
class SwitchNode(WorkflowNode):
|
||||
"""Switch node - multi-way branch based on value"""
|
||||
|
||||
type_name = "switch"
|
||||
category = "control"
|
||||
icon = "🔃"
|
||||
name = "switch"
|
||||
description = "switch"
|
||||
name_zh = "多路分支"
|
||||
name_en = "Switch"
|
||||
description_zh = "根据多个条件分支工作流"
|
||||
description_en = "Branch workflow based on multiple cases"
|
||||
|
||||
inputs: ClassVar[list[NodePort]] = []
|
||||
outputs: ClassVar[list[NodePort]] = []
|
||||
config_schema: ClassVar[list[NodeConfig]] = []
|
||||
|
||||
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
|
||||
expression = self.get_config("expression", "")
|
||||
cases = self.get_config("cases", [])
|
||||
input_data = inputs.get("input")
|
||||
|
||||
value = await self._evaluate_expression(expression, input_data, context)
|
||||
|
||||
for case in cases:
|
||||
if str(case.get("value")) == str(value):
|
||||
return {"matched_case": input_data, "default": None, "_matched_output": case.get("output")}
|
||||
|
||||
return {"matched_case": None, "default": input_data}
|
||||
|
||||
async def _evaluate_expression(self, expression: str, data: Any, context: ExecutionContext) -> Any:
|
||||
if not expression:
|
||||
return data
|
||||
|
||||
if expression.startswith("{{") and expression.endswith("}}"):
|
||||
var_path = expression[2:-2].strip()
|
||||
parts = var_path.split(".")
|
||||
|
||||
if parts[0] == "input":
|
||||
result = data
|
||||
for part in parts[1:]:
|
||||
if isinstance(result, dict):
|
||||
result = result.get(part)
|
||||
else:
|
||||
return None
|
||||
return result
|
||||
elif parts[0] == "variables":
|
||||
return context.variables.get(".".join(parts[1:]))
|
||||
|
||||
return expression
|
||||
51
src/langbot/pkg/workflow/nodes/variable_aggregator.py
Normal file
51
src/langbot/pkg/workflow/nodes/variable_aggregator.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""Variable Aggregator Node - aggregate variables from multiple branches
|
||||
|
||||
Node metadata is loaded from: ../../templates/metadata/nodes/variable_aggregator.yaml
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from ..entities import ExecutionContext
|
||||
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
|
||||
|
||||
|
||||
@workflow_node('variable_aggregator')
|
||||
class VariableAggregatorNode(WorkflowNode):
|
||||
"""Variable aggregator node - aggregate variables from multiple branches"""
|
||||
|
||||
type_name = "variable_aggregator"
|
||||
category = "control"
|
||||
icon = "📊"
|
||||
name = "variable_aggregator"
|
||||
description = "variable_aggregator"
|
||||
name_zh = "变量聚合器"
|
||||
name_en = "Variable Aggregator"
|
||||
description_zh = "聚合多个分支的变量输出"
|
||||
description_en = "Aggregate variable outputs from multiple branches"
|
||||
|
||||
inputs: ClassVar[list[NodePort]] = []
|
||||
outputs: ClassVar[list[NodePort]] = []
|
||||
config_schema: ClassVar[list[NodeConfig]] = []
|
||||
|
||||
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
|
||||
variables = inputs.get("variables", {})
|
||||
mode = self.get_config("aggregation_mode", "merge")
|
||||
|
||||
aggregated = {}
|
||||
|
||||
if mode == "merge":
|
||||
if isinstance(variables, dict):
|
||||
aggregated.update(variables)
|
||||
elif mode == "override":
|
||||
if isinstance(variables, dict):
|
||||
aggregated = variables.copy()
|
||||
elif mode == "append":
|
||||
for key, value in (variables if isinstance(variables, dict) else {}).items():
|
||||
if key in aggregated and isinstance(aggregated[key], list):
|
||||
aggregated[key].append(value)
|
||||
else:
|
||||
aggregated[key] = [value]
|
||||
|
||||
return {"aggregated": aggregated}
|
||||
45
src/langbot/pkg/workflow/nodes/wait.py
Normal file
45
src/langbot/pkg/workflow/nodes/wait.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""Wait Node - pause execution for a duration
|
||||
|
||||
Node metadata is loaded from: ../../templates/metadata/nodes/wait.yaml
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from ..entities import ExecutionContext
|
||||
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
|
||||
|
||||
|
||||
@workflow_node('wait')
|
||||
class WaitNode(WorkflowNode):
|
||||
"""Wait node - pause execution for a duration"""
|
||||
|
||||
type_name = "wait"
|
||||
category = "control"
|
||||
icon = "⏳"
|
||||
name = "wait"
|
||||
description = "wait"
|
||||
name_zh = "等待"
|
||||
name_en = "Wait"
|
||||
description_zh = "暂停工作流执行指定时间"
|
||||
description_en = "Pause workflow execution for a specified duration"
|
||||
|
||||
inputs: ClassVar[list[NodePort]] = []
|
||||
outputs: ClassVar[list[NodePort]] = []
|
||||
config_schema: ClassVar[list[NodeConfig]] = []
|
||||
|
||||
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
|
||||
import asyncio
|
||||
|
||||
duration = self.get_config("duration", 1)
|
||||
duration_type = self.get_config("duration_type", "seconds")
|
||||
|
||||
if duration_type == "minutes":
|
||||
duration *= 60
|
||||
elif duration_type == "hours":
|
||||
duration *= 3600
|
||||
|
||||
await asyncio.sleep(duration)
|
||||
|
||||
return {"output": inputs.get("input")}
|
||||
40
src/langbot/pkg/workflow/nodes/webhook_trigger.py
Normal file
40
src/langbot/pkg/workflow/nodes/webhook_trigger.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""Webhook Trigger Node - triggers workflow via HTTP request
|
||||
|
||||
Node metadata is loaded from: ../../templates/metadata/nodes/webhook_trigger.yaml
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from ..entities import ExecutionContext
|
||||
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
|
||||
|
||||
|
||||
@workflow_node('webhook_trigger')
|
||||
class WebhookTriggerNode(WorkflowNode):
|
||||
"""Webhook trigger node - triggers workflow via HTTP request"""
|
||||
|
||||
type_name = "webhook_trigger"
|
||||
category = "trigger"
|
||||
icon = "🌐"
|
||||
name = "webhook_trigger"
|
||||
description = "webhook_trigger"
|
||||
name_zh = "Webhook 触发"
|
||||
name_en = "Webhook Trigger"
|
||||
description_zh = "通过 HTTP 请求触发工作流"
|
||||
description_en = "Trigger workflow via HTTP webhook"
|
||||
|
||||
inputs: ClassVar[list[NodePort]] = []
|
||||
outputs: ClassVar[list[NodePort]] = []
|
||||
config_schema: ClassVar[list[NodeConfig]] = []
|
||||
|
||||
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
|
||||
trigger_data = context.trigger_data
|
||||
|
||||
return {
|
||||
"body": trigger_data.get("body", {}),
|
||||
"headers": trigger_data.get("headers", {}),
|
||||
"query": trigger_data.get("query", {}),
|
||||
"method": trigger_data.get("method", "POST"),
|
||||
}
|
||||
161
src/langbot/pkg/workflow/registry.py
Normal file
161
src/langbot/pkg/workflow/registry.py
Normal file
@@ -0,0 +1,161 @@
|
||||
"""Node type registry"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from .node import WorkflowNode, get_pending_registrations, clear_pending_registrations
|
||||
|
||||
|
||||
class NodeTypeRegistry:
|
||||
"""
|
||||
Central registry for all workflow node types.
|
||||
Supports both built-in and plugin-provided nodes.
|
||||
"""
|
||||
|
||||
_instance: Optional['NodeTypeRegistry'] = None
|
||||
|
||||
def __init__(self):
|
||||
self._nodes: dict[str, type[WorkflowNode]] = {}
|
||||
self._categories: dict[str, list[str]] = {
|
||||
'trigger': [],
|
||||
'process': [],
|
||||
'control': [],
|
||||
'action': [],
|
||||
'integration': [],
|
||||
'misc': [],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def instance(cls) -> 'NodeTypeRegistry':
|
||||
"""Get singleton instance"""
|
||||
if cls._instance is None:
|
||||
cls._instance = cls()
|
||||
return cls._instance
|
||||
|
||||
def register(self, node_type: str, node_class: type[WorkflowNode]):
|
||||
"""
|
||||
Register a node type.
|
||||
|
||||
Args:
|
||||
node_type: Unique type identifier
|
||||
node_class: WorkflowNode subclass
|
||||
"""
|
||||
self._nodes[node_type] = node_class
|
||||
|
||||
# Add to category
|
||||
category = getattr(node_class, 'category', 'misc')
|
||||
if category not in self._categories:
|
||||
self._categories[category] = []
|
||||
if node_type not in self._categories[category]:
|
||||
self._categories[category].append(node_type)
|
||||
|
||||
def unregister(self, node_type: str):
|
||||
"""Unregister a node type"""
|
||||
if node_type in self._nodes:
|
||||
node_class = self._nodes[node_type]
|
||||
category = getattr(node_class, 'category', 'misc')
|
||||
if category in self._categories and node_type in self._categories[category]:
|
||||
self._categories[category].remove(node_type)
|
||||
del self._nodes[node_type]
|
||||
|
||||
def get(self, node_type: str) -> Optional[type[WorkflowNode]]:
|
||||
"""Get node class by type. Supports both 'category.type_name' and short 'type_name' formats."""
|
||||
# First try exact match (category.type_name format)
|
||||
if node_type in self._nodes:
|
||||
return self._nodes[node_type]
|
||||
|
||||
# Try short name format (e.g., 'dify_workflow' -> 'integration.dify_workflow')
|
||||
# Search through all registered nodes for a matching type_name
|
||||
for registered_type, node_class in self._nodes.items():
|
||||
if node_class.type_name == node_type:
|
||||
return node_class
|
||||
|
||||
return None
|
||||
|
||||
def create_instance(self, node_type: str, node_id: str, config: dict[str, Any], ap: Optional['app.Application'] = None) -> Optional[WorkflowNode]:
|
||||
"""Create a node instance. Supports both 'category.type_name' and short 'type_name' formats."""
|
||||
node_class = self.get(node_type)
|
||||
if node_class:
|
||||
return node_class(node_id, config, ap=ap)
|
||||
return None
|
||||
|
||||
def list_all(self) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Get all registered node types as schema list.
|
||||
|
||||
Returns:
|
||||
List of node schemas
|
||||
"""
|
||||
return [
|
||||
node_class.to_schema()
|
||||
for node_class in self._nodes.values()
|
||||
]
|
||||
|
||||
def list_by_category(self, category: str) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Get node types by category.
|
||||
|
||||
Args:
|
||||
category: Category name (trigger, process, control, action, integration, misc)
|
||||
|
||||
Returns:
|
||||
List of node schemas in the category
|
||||
"""
|
||||
if category not in self._categories:
|
||||
return []
|
||||
return [
|
||||
self._nodes[node_type].to_schema()
|
||||
for node_type in self._categories[category]
|
||||
if node_type in self._nodes
|
||||
]
|
||||
|
||||
def get_categories(self) -> dict[str, list[dict[str, Any]]]:
|
||||
"""
|
||||
Get all nodes organized by category.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping category names to lists of node schemas
|
||||
"""
|
||||
return {
|
||||
category: self.list_by_category(category)
|
||||
for category in self._categories.keys()
|
||||
}
|
||||
|
||||
def has_type(self, node_type: str) -> bool:
|
||||
"""Check if a node type is registered. Supports both formats."""
|
||||
return self.get(node_type) is not None
|
||||
|
||||
def process_pending_registrations(self):
|
||||
"""Process all pending node registrations from decorators"""
|
||||
for node_type, node_class in get_pending_registrations():
|
||||
# Use category.type_name format for consistency with frontend
|
||||
category = getattr(node_class, 'category', 'misc')
|
||||
full_type = f'{category}.{node_type}'
|
||||
self.register(full_type, node_class)
|
||||
clear_pending_registrations()
|
||||
|
||||
def count(self) -> int:
|
||||
"""Get total number of registered node types"""
|
||||
return len(self._nodes)
|
||||
|
||||
def clear(self):
|
||||
"""Clear all registrations (for testing)"""
|
||||
self._nodes.clear()
|
||||
for category in self._categories:
|
||||
self._categories[category] = []
|
||||
|
||||
|
||||
# Convenience functions for module-level access
|
||||
def register_node(node_type: str, node_class: type[WorkflowNode]):
|
||||
"""Register a node type to the global registry"""
|
||||
NodeTypeRegistry.instance().register(node_type, node_class)
|
||||
|
||||
|
||||
def get_node_class(node_type: str) -> Optional[type[WorkflowNode]]:
|
||||
"""Get a node class from the global registry"""
|
||||
return NodeTypeRegistry.instance().get(node_type)
|
||||
|
||||
|
||||
def list_node_types() -> list[dict[str, Any]]:
|
||||
"""List all registered node types"""
|
||||
return NodeTypeRegistry.instance().list_all()
|
||||
151
src/langbot/pkg/workflow/safe_eval.py
Normal file
151
src/langbot/pkg/workflow/safe_eval.py
Normal file
@@ -0,0 +1,151 @@
|
||||
"""Safe expression evaluator for workflow nodes.
|
||||
|
||||
Uses Python's ``ast`` module to whitelist only comparison, boolean, arithmetic,
|
||||
and simple attribute / subscript access. No function calls, imports, or
|
||||
arbitrary code execution.
|
||||
|
||||
The public API is :func:`safe_eval_with_vars` which accepts a mapping of
|
||||
allowed variable names so that expressions like ``input == "hello"`` or
|
||||
``data.x > 3`` work without resorting to :func:`eval`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import operator
|
||||
from typing import Any
|
||||
|
||||
|
||||
_SAFE_OPS = {
|
||||
# Arithmetic
|
||||
ast.Add: operator.add,
|
||||
ast.Sub: operator.sub,
|
||||
ast.Mult: operator.mul,
|
||||
ast.Div: operator.truediv,
|
||||
ast.FloorDiv: operator.floordiv,
|
||||
ast.Mod: operator.mod,
|
||||
ast.Pow: operator.pow,
|
||||
# Unary
|
||||
ast.USub: operator.neg,
|
||||
ast.UAdd: operator.pos,
|
||||
ast.Not: operator.not_,
|
||||
# Comparison
|
||||
ast.Eq: operator.eq,
|
||||
ast.NotEq: operator.ne,
|
||||
ast.Lt: operator.lt,
|
||||
ast.LtE: operator.le,
|
||||
ast.Gt: operator.gt,
|
||||
ast.GtE: operator.ge,
|
||||
ast.Is: operator.is_,
|
||||
ast.IsNot: operator.is_not,
|
||||
ast.In: lambda a, b: a in b,
|
||||
ast.NotIn: lambda a, b: a not in b,
|
||||
}
|
||||
|
||||
|
||||
def safe_eval_with_vars(expr: str, variables: dict[str, Any] | None = None) -> Any:
|
||||
"""Evaluate an expression safely with an optional variable mapping.
|
||||
|
||||
Supports:
|
||||
- Literals (numbers, strings, booleans, None)
|
||||
- Comparisons (==, !=, <, >, <=, >=, in, not in, is, is not)
|
||||
- Boolean logic (and, or, not)
|
||||
- Arithmetic (+, -, *, /, //, %, **)
|
||||
- Ternary (x if cond else y)
|
||||
- Variable references from *variables* dict (e.g. ``input``, ``data``)
|
||||
- Attribute access on known variables (e.g. ``data.name``)
|
||||
- Subscript access on known variables (e.g. ``data["key"]``, ``items[0]``)
|
||||
|
||||
Raises :class:`ValueError` on any disallowed construct (function calls,
|
||||
starred expressions, walrus operator, etc.).
|
||||
"""
|
||||
variables = variables or {}
|
||||
tree = ast.parse(expr.strip(), mode='eval')
|
||||
return _eval_node(tree.body, variables)
|
||||
|
||||
|
||||
def _eval_node(node: ast.AST, variables: dict[str, Any]) -> Any:
|
||||
# Literals
|
||||
if isinstance(node, ast.Constant):
|
||||
return node.value
|
||||
|
||||
# Variable references
|
||||
if isinstance(node, ast.Name):
|
||||
if node.id in ('None', 'True', 'False'):
|
||||
return {'None': None, 'True': True, 'False': False}[node.id]
|
||||
if node.id in variables:
|
||||
return variables[node.id]
|
||||
raise ValueError(f"Unsupported variable reference: {node.id}")
|
||||
|
||||
# Attribute access: obj.attr (only on allowed variables)
|
||||
if isinstance(node, ast.Attribute):
|
||||
obj = _eval_node(node.value, variables)
|
||||
attr = node.attr
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(attr)
|
||||
if hasattr(obj, attr):
|
||||
return getattr(obj, attr)
|
||||
return None
|
||||
|
||||
# Subscript access: obj[key] (only on allowed variables)
|
||||
if isinstance(node, ast.Subscript):
|
||||
obj = _eval_node(node.value, variables)
|
||||
key = _eval_node(node.slice, variables)
|
||||
try:
|
||||
return obj[key]
|
||||
except (KeyError, IndexError, TypeError):
|
||||
return None
|
||||
|
||||
# Unary operators
|
||||
if isinstance(node, ast.UnaryOp):
|
||||
op_fn = _SAFE_OPS.get(type(node.op))
|
||||
if op_fn is None:
|
||||
raise ValueError(f"Unsupported unary op: {type(node.op).__name__}")
|
||||
return op_fn(_eval_node(node.operand, variables))
|
||||
|
||||
# Binary operators
|
||||
if isinstance(node, ast.BinOp):
|
||||
op_fn = _SAFE_OPS.get(type(node.op))
|
||||
if op_fn is None:
|
||||
raise ValueError(f"Unsupported binary op: {type(node.op).__name__}")
|
||||
return op_fn(_eval_node(node.left, variables), _eval_node(node.right, variables))
|
||||
|
||||
# Comparisons (chained)
|
||||
if isinstance(node, ast.Compare):
|
||||
left = _eval_node(node.left, variables)
|
||||
for op, comparator in zip(node.ops, node.comparators):
|
||||
op_fn = _SAFE_OPS.get(type(op))
|
||||
if op_fn is None:
|
||||
raise ValueError(f"Unsupported comparison: {type(op).__name__}")
|
||||
right = _eval_node(comparator, variables)
|
||||
if not op_fn(left, right):
|
||||
return False
|
||||
left = right
|
||||
return True
|
||||
|
||||
# Boolean operators
|
||||
if isinstance(node, ast.BoolOp):
|
||||
if isinstance(node.op, ast.And):
|
||||
return all(_eval_node(v, variables) for v in node.values)
|
||||
if isinstance(node.op, ast.Or):
|
||||
return any(_eval_node(v, variables) for v in node.values)
|
||||
|
||||
# Ternary
|
||||
if isinstance(node, ast.IfExp):
|
||||
return (
|
||||
_eval_node(node.body, variables)
|
||||
if _eval_node(node.test, variables)
|
||||
else _eval_node(node.orelse, variables)
|
||||
)
|
||||
|
||||
# Tuples / Lists (e.g. ``x in [1, 2, 3]``)
|
||||
if isinstance(node, (ast.Tuple, ast.List)):
|
||||
return [_eval_node(e, variables) for e in node.elts]
|
||||
|
||||
# Dict literals (e.g. ``{"a": 1}``)
|
||||
if isinstance(node, ast.Dict):
|
||||
return {
|
||||
_eval_node(k, variables): _eval_node(v, variables)
|
||||
for k, v in zip(node.keys, node.values)
|
||||
}
|
||||
|
||||
raise ValueError(f"Unsupported expression node: {type(node).__name__}")
|
||||
74
src/langbot/templates/metadata/nodes/call_pipeline.yaml
Normal file
74
src/langbot/templates/metadata/nodes/call_pipeline.yaml
Normal 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: 超时时间(秒)
|
||||
73
src/langbot/templates/metadata/nodes/code_executor.yaml
Normal file
73
src/langbot/templates/metadata/nodes/code_executor.yaml
Normal 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: 最大执行时间(毫秒)
|
||||
122
src/langbot/templates/metadata/nodes/condition.yaml
Normal file
122
src/langbot/templates/metadata/nodes/condition.yaml
Normal 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: 要检查的类型
|
||||
122
src/langbot/templates/metadata/nodes/coze_bot.yaml
Normal file
122
src/langbot/templates/metadata/nodes/coze_bot.yaml
Normal 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
|
||||
80
src/langbot/templates/metadata/nodes/cron_trigger.yaml
Normal file
80
src/langbot/templates/metadata/nodes/cron_trigger.yaml
Normal 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: 此定时触发器是否激活
|
||||
67
src/langbot/templates/metadata/nodes/data_transform.yaml
Normal file
67
src/langbot/templates/metadata/nodes/data_transform.yaml
Normal 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 表达式
|
||||
110
src/langbot/templates/metadata/nodes/database_query.yaml
Normal file
110
src/langbot/templates/metadata/nodes/database_query.yaml
Normal 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: 查询超时时间
|
||||
@@ -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: ''
|
||||
120
src/langbot/templates/metadata/nodes/dify_workflow.yaml
Normal file
120
src/langbot/templates/metadata/nodes/dify_workflow.yaml
Normal 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: ''
|
||||
48
src/langbot/templates/metadata/nodes/end.yaml
Normal file
48
src/langbot/templates/metadata/nodes/end.yaml
Normal 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: 与结束状态一起包含的可选消息
|
||||
84
src/langbot/templates/metadata/nodes/event_trigger.yaml
Normal file
84
src/langbot/templates/metadata/nodes/event_trigger.yaml
Normal 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: 仅对来自这些平台的事件触发
|
||||
157
src/langbot/templates/metadata/nodes/http_request.yaml
Normal file
157
src/langbot/templates/metadata/nodes/http_request.yaml
Normal 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 证书验证错误
|
||||
75
src/langbot/templates/metadata/nodes/iterator.yaml
Normal file
75
src/langbot/templates/metadata/nodes/iterator.yaml
Normal 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
|
||||
111
src/langbot/templates/metadata/nodes/knowledge_retrieval.yaml
Normal file
111
src/langbot/templates/metadata/nodes/knowledge_retrieval.yaml
Normal 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: 用于结果重排序的模型
|
||||
107
src/langbot/templates/metadata/nodes/langflow_flow.yaml
Normal file
107
src/langbot/templates/metadata/nodes/langflow_flow.yaml
Normal 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: '{}'
|
||||
150
src/langbot/templates/metadata/nodes/llm_call.yaml
Normal file
150
src/langbot/templates/metadata/nodes/llm_call.yaml
Normal 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: 选择模型可以使用的工具
|
||||
112
src/langbot/templates/metadata/nodes/loop.yaml
Normal file
112
src/langbot/templates/metadata/nodes/loop.yaml
Normal 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: 最大并行执行数
|
||||
83
src/langbot/templates/metadata/nodes/mcp_tool.yaml
Normal file
83
src/langbot/templates/metadata/nodes/mcp_tool.yaml
Normal 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: 最大执行时间
|
||||
94
src/langbot/templates/metadata/nodes/memory_store.yaml
Normal file
94
src/langbot/templates/metadata/nodes/memory_store.yaml
Normal 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 = 不过期)
|
||||
72
src/langbot/templates/metadata/nodes/merge.yaml
Normal file
72
src/langbot/templates/metadata/nodes/merge.yaml
Normal 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: 合并输入的策略
|
||||
100
src/langbot/templates/metadata/nodes/message_trigger.yaml
Normal file
100
src/langbot/templates/metadata/nodes/message_trigger.yaml
Normal 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: 不对机器人发送的消息触发
|
||||
192
src/langbot/templates/metadata/nodes/n8n_workflow.yaml
Normal file
192
src/langbot/templates/metadata/nodes/n8n_workflow.yaml
Normal 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
|
||||
57
src/langbot/templates/metadata/nodes/opening_statement.yaml
Normal file
57
src/langbot/templates/metadata/nodes/opening_statement.yaml
Normal 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: 是否显示建议
|
||||
77
src/langbot/templates/metadata/nodes/parallel.yaml
Normal file
77
src/langbot/templates/metadata/nodes/parallel.yaml
Normal 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: 如果任何一个分支失败则停止所有分支
|
||||
@@ -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: 如果任何必需参数无法提取则失败
|
||||
@@ -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: 当置信度低于阈值时使用的分类
|
||||
113
src/langbot/templates/metadata/nodes/redis_operation.yaml
Normal file
113
src/langbot/templates/metadata/nodes/redis_operation.yaml
Normal 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 操作的过期时间
|
||||
75
src/langbot/templates/metadata/nodes/reply_message.yaml
Normal file
75
src/langbot/templates/metadata/nodes/reply_message.yaml
Normal 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"
|
||||
95
src/langbot/templates/metadata/nodes/send_message.yaml
Normal file
95
src/langbot/templates/metadata/nodes/send_message.yaml
Normal 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: 提及原始发送者
|
||||
59
src/langbot/templates/metadata/nodes/set_variable.yaml
Normal file
59
src/langbot/templates/metadata/nodes/set_variable.yaml
Normal 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: 要执行的操作
|
||||
65
src/langbot/templates/metadata/nodes/store_data.yaml
Normal file
65
src/langbot/templates/metadata/nodes/store_data.yaml
Normal 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: 键前缀
|
||||
78
src/langbot/templates/metadata/nodes/switch.yaml
Normal file
78
src/langbot/templates/metadata/nodes/switch.yaml
Normal 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: 字符串比较是否区分大小写
|
||||
@@ -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: 变量聚合模式
|
||||
65
src/langbot/templates/metadata/nodes/wait.yaml
Normal file
65
src/langbot/templates/metadata/nodes/wait.yaml
Normal 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: 等待直到此时间
|
||||
138
src/langbot/templates/metadata/nodes/webhook_trigger.yaml
Normal file
138
src/langbot/templates/metadata/nodes/webhook_trigger.yaml
Normal 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: 请求超时时间(秒)
|
||||
0
tests/unit_tests/workflow/__init__.py
Normal file
0
tests/unit_tests/workflow/__init__.py
Normal file
351
tests/unit_tests/workflow/test_executor.py
Normal file
351
tests/unit_tests/workflow/test_executor.py
Normal 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"
|
||||
133
tests/unit_tests/workflow/test_registry.py
Normal file
133
tests/unit_tests/workflow/test_registry.py
Normal 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
Reference in New Issue
Block a user