diff --git a/compare_nodes.py b/compare_nodes.py
new file mode 100644
index 000000000..1567415e4
--- /dev/null
+++ b/compare_nodes.py
@@ -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")
diff --git a/docs/development/workflow-system.md b/docs/development/workflow-system.md
new file mode 100644
index 000000000..d61c340c6
--- /dev/null
+++ b/docs/development/workflow-system.md
@@ -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 (
+
+ {nodeConfig?.fields.map(field => (
+
+ ))}
+
+ );
+}
+```
+
+#### 4. WorkflowDebugger
+
+调试器组件,支持实时调试:
+
+```tsx
+function WorkflowDebugger({ workflowUuid, workflow }) {
+ const [debugState, setDebugState] = useState('idle');
+ const [executionId, setExecutionId] = useState('');
+ const [logs, setLogs] = useState([]);
+
+ // 启动调试
+ 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) => void;
+ deleteNode: (nodeId: string) => void;
+ undo: () => void;
+ redo: () => void;
+}
+
+export const useWorkflowStore = create((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)
diff --git a/docs/user-guide/workflow-guide.md b/docs/user-guide/workflow-guide.md
new file mode 100644
index 000000000..c7dd4abfa
--- /dev/null
+++ b/docs/user-guide/workflow-guide.md
@@ -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)
diff --git a/node_comparison.json b/node_comparison.json
new file mode 100644
index 000000000..b67545a77
--- /dev/null
+++ b/node_comparison.json
@@ -0,0 +1,1468 @@
+{
+ "yaml_nodes": {
+ "call_pipeline": {
+ "category": "action",
+ "inputs": [
+ "query",
+ "context"
+ ],
+ "outputs": [
+ "response",
+ "result"
+ ],
+ "config": [
+ "pipeline_uuid",
+ "inherit_context",
+ "timeout"
+ ]
+ },
+ "code_executor": {
+ "category": "process",
+ "inputs": [
+ "input"
+ ],
+ "outputs": [
+ "output",
+ "logs"
+ ],
+ "config": [
+ "language",
+ "code",
+ "timeout"
+ ]
+ },
+ "condition": {
+ "category": "control",
+ "inputs": [
+ "input"
+ ],
+ "outputs": [
+ "true",
+ "false"
+ ],
+ "config": [
+ "condition_type",
+ "expression",
+ "left_value",
+ "operator",
+ "right_value",
+ "expected_type"
+ ]
+ },
+ "coze_bot": {
+ "category": "integration",
+ "inputs": [
+ "query",
+ "conversation_id"
+ ],
+ "outputs": [
+ "answer",
+ "conversation_id",
+ "success"
+ ],
+ "config": [
+ "api-key",
+ "bot-id",
+ "api-base",
+ "auto-save-history",
+ "timeout"
+ ]
+ },
+ "cron_trigger": {
+ "category": "trigger",
+ "inputs": [],
+ "outputs": [
+ "trigger_time",
+ "context"
+ ],
+ "config": [
+ "cron_expression",
+ "timezone",
+ "description",
+ "enabled"
+ ]
+ },
+ "data_transform": {
+ "category": "process",
+ "inputs": [
+ "data"
+ ],
+ "outputs": [
+ "result"
+ ],
+ "config": [
+ "transform_type",
+ "template",
+ "expression"
+ ]
+ },
+ "database_query": {
+ "category": "integration",
+ "inputs": [
+ "parameters"
+ ],
+ "outputs": [
+ "results",
+ "row_count",
+ "success"
+ ],
+ "config": [
+ "connection_type",
+ "connection_string",
+ "query",
+ "query_type",
+ "timeout"
+ ]
+ },
+ "dify_knowledge_query": {
+ "category": "integration",
+ "inputs": [
+ "query"
+ ],
+ "outputs": [
+ "results",
+ "success"
+ ],
+ "config": [
+ "base-url",
+ "api-key",
+ "dataset_id"
+ ]
+ },
+ "dify_workflow": {
+ "category": "integration",
+ "inputs": [
+ "query",
+ "conversation_id"
+ ],
+ "outputs": [
+ "answer",
+ "conversation_id",
+ "success"
+ ],
+ "config": [
+ "base-url",
+ "base-prompt",
+ "app-type",
+ "api-key"
+ ]
+ },
+ "end": {
+ "category": "control",
+ "inputs": [
+ "input"
+ ],
+ "outputs": [],
+ "config": [
+ "status",
+ "message"
+ ]
+ },
+ "event_trigger": {
+ "category": "trigger",
+ "inputs": [],
+ "outputs": [
+ "event",
+ "event_type",
+ "context"
+ ],
+ "config": [
+ "event_type",
+ "source_filter",
+ "platforms"
+ ]
+ },
+ "http_request": {
+ "category": "action",
+ "inputs": [
+ "body",
+ "variables"
+ ],
+ "outputs": [
+ "response",
+ "status_code",
+ "headers",
+ "success"
+ ],
+ "config": [
+ "method",
+ "url",
+ "headers",
+ "body_type",
+ "body_template",
+ "timeout",
+ "retry_count",
+ "ignore_ssl"
+ ]
+ },
+ "iterator": {
+ "category": "control",
+ "inputs": [
+ "array"
+ ],
+ "outputs": [
+ "item",
+ "index",
+ "is_first",
+ "is_last"
+ ],
+ "config": [
+ "parallel",
+ "max_concurrency"
+ ]
+ },
+ "knowledge_retrieval": {
+ "category": "process",
+ "inputs": [
+ "query"
+ ],
+ "outputs": [
+ "results",
+ "context",
+ "scores"
+ ],
+ "config": [
+ "knowledge_bases",
+ "top_k",
+ "similarity_threshold",
+ "retrieval_mode",
+ "rerank_enabled",
+ "rerank_model"
+ ]
+ },
+ "langflow_flow": {
+ "category": "integration",
+ "inputs": [
+ "input_value"
+ ],
+ "outputs": [
+ "result",
+ "success"
+ ],
+ "config": [
+ "base-url",
+ "api-key",
+ "flow-id",
+ "input-type",
+ "output-type",
+ "tweaks"
+ ]
+ },
+ "llm_call": {
+ "category": "process",
+ "inputs": [
+ "input",
+ "context"
+ ],
+ "outputs": [
+ "response",
+ "usage",
+ "parsed"
+ ],
+ "config": [
+ "model",
+ "system_prompt",
+ "user_prompt_template",
+ "temperature",
+ "max_tokens",
+ "output_format",
+ "json_schema",
+ "enable_tools",
+ "tools"
+ ]
+ },
+ "loop": {
+ "category": "control",
+ "inputs": [
+ "items"
+ ],
+ "outputs": [
+ "item",
+ "index",
+ "completed"
+ ],
+ "config": [
+ "loop_type",
+ "max_iterations",
+ "count",
+ "while_condition",
+ "parallel",
+ "parallel_limit"
+ ]
+ },
+ "mcp_tool": {
+ "category": "integration",
+ "inputs": [
+ "arguments"
+ ],
+ "outputs": [
+ "result",
+ "success",
+ "error"
+ ],
+ "config": [
+ "server_name",
+ "tool_name",
+ "arguments_template",
+ "timeout"
+ ]
+ },
+ "memory_store": {
+ "category": "integration",
+ "inputs": [
+ "value"
+ ],
+ "outputs": [
+ "result",
+ "success"
+ ],
+ "config": [
+ "operation",
+ "key",
+ "scope",
+ "ttl"
+ ]
+ },
+ "merge": {
+ "category": "control",
+ "inputs": [
+ "input_1",
+ "input_2",
+ "input_3",
+ "input_4"
+ ],
+ "outputs": [
+ "merged",
+ "array"
+ ],
+ "config": [
+ "merge_strategy"
+ ]
+ },
+ "message_trigger": {
+ "category": "trigger",
+ "inputs": [],
+ "outputs": [
+ "message",
+ "sender",
+ "context"
+ ],
+ "config": [
+ "match_type",
+ "match_pattern",
+ "message_source",
+ "platforms",
+ "ignore_bot_messages"
+ ]
+ },
+ "n8n_workflow": {
+ "category": "integration",
+ "inputs": [
+ "payload"
+ ],
+ "outputs": [
+ "result",
+ "success"
+ ],
+ "config": [
+ "webhook-url",
+ "auth-type",
+ "basic-username",
+ "basic-password",
+ "jwt-secret",
+ "jwt-algorithm",
+ "header-name",
+ "header-value",
+ "timeout",
+ "output-key"
+ ]
+ },
+ "opening_statement": {
+ "category": "action",
+ "inputs": [],
+ "outputs": [
+ "statement",
+ "suggested_questions"
+ ],
+ "config": [
+ "statement",
+ "suggested_questions",
+ "show_suggestions"
+ ]
+ },
+ "parallel": {
+ "category": "control",
+ "inputs": [
+ "input"
+ ],
+ "outputs": [
+ "branch_1",
+ "branch_2",
+ "results"
+ ],
+ "config": [
+ "branches",
+ "wait_for_all",
+ "fail_fast"
+ ]
+ },
+ "parameter_extractor": {
+ "category": "process",
+ "inputs": [
+ "text"
+ ],
+ "outputs": [
+ "parameters",
+ "missing",
+ "success"
+ ],
+ "config": [
+ "model",
+ "parameters",
+ "extraction_prompt",
+ "strict_mode"
+ ]
+ },
+ "question_classifier": {
+ "category": "process",
+ "inputs": [
+ "question"
+ ],
+ "outputs": [
+ "category",
+ "confidence",
+ "all_scores"
+ ],
+ "config": [
+ "model",
+ "categories",
+ "confidence_threshold",
+ "fallback_category"
+ ]
+ },
+ "redis_operation": {
+ "category": "integration",
+ "inputs": [
+ "key",
+ "value"
+ ],
+ "outputs": [
+ "result",
+ "success"
+ ],
+ "config": [
+ "connection_url",
+ "operation",
+ "key_template",
+ "hash_field",
+ "ttl"
+ ]
+ },
+ "reply_message": {
+ "category": "action",
+ "inputs": [
+ "message"
+ ],
+ "outputs": [
+ "status",
+ "message_id"
+ ],
+ "config": [
+ "reply_mode",
+ "message_template",
+ "long_text_processing",
+ "force_delay"
+ ]
+ },
+ "send_message": {
+ "category": "action",
+ "inputs": [
+ "content",
+ "context"
+ ],
+ "outputs": [
+ "message_id",
+ "success"
+ ],
+ "config": [
+ "message_type",
+ "content_template",
+ "reply_to_original",
+ "at_sender"
+ ]
+ },
+ "set_variable": {
+ "category": "action",
+ "inputs": [
+ "value"
+ ],
+ "outputs": [
+ "value"
+ ],
+ "config": [
+ "variable_name",
+ "variable_scope",
+ "operation"
+ ]
+ },
+ "store_data": {
+ "category": "action",
+ "inputs": [
+ "key",
+ "value"
+ ],
+ "outputs": [
+ "status"
+ ],
+ "config": [
+ "storage_type",
+ "ttl",
+ "key_prefix"
+ ]
+ },
+ "switch": {
+ "category": "control",
+ "inputs": [
+ "input"
+ ],
+ "outputs": [
+ "case_1",
+ "case_2",
+ "default"
+ ],
+ "config": [
+ "switch_expression",
+ "cases",
+ "case_sensitive"
+ ]
+ },
+ "variable_aggregator": {
+ "category": "control",
+ "inputs": [
+ "variables"
+ ],
+ "outputs": [
+ "aggregated"
+ ],
+ "config": [
+ "variable_mappings",
+ "aggregation_mode"
+ ]
+ },
+ "wait": {
+ "category": "control",
+ "inputs": [
+ "input"
+ ],
+ "outputs": [
+ "output"
+ ],
+ "config": [
+ "wait_type",
+ "duration",
+ "until_time"
+ ]
+ },
+ "webhook_trigger": {
+ "category": "trigger",
+ "inputs": [],
+ "outputs": [
+ "body",
+ "headers",
+ "query",
+ "method"
+ ],
+ "config": [
+ "webhook_path",
+ "auth_type",
+ "auth_token",
+ "allowed_ips",
+ "allowed_methods",
+ "content_type",
+ "validation",
+ "timeout"
+ ]
+ }
+ },
+ "frontend_nodes": {
+ "condition": {
+ "inputs": [
+ "input"
+ ],
+ "outputs": [
+ "true",
+ "false"
+ ],
+ "config": [
+ "condition_type",
+ "expression",
+ "comparison",
+ "exists",
+ "type_check",
+ "left_value",
+ "operator",
+ "eq",
+ "neq",
+ "gt",
+ "gte",
+ "lt",
+ "lte",
+ "contains",
+ "starts_with",
+ "ends_with",
+ "matches",
+ "right_value",
+ "expected_type",
+ "string",
+ "number",
+ "boolean",
+ "object",
+ "array",
+ "null"
+ ]
+ },
+ "switch_case": {
+ "inputs": [
+ "input"
+ ],
+ "outputs": [
+ "case_1",
+ "case_2",
+ "default"
+ ],
+ "config": [
+ "switch_expression",
+ "cases",
+ "case_sensitive"
+ ]
+ },
+ "loop": {
+ "inputs": [
+ "items"
+ ],
+ "outputs": [
+ "item",
+ "index",
+ "completed"
+ ],
+ "config": [
+ "loop_type",
+ "foreach",
+ "while",
+ "count",
+ "max_iterations",
+ "while_condition",
+ "parallel",
+ "parallel_limit"
+ ]
+ },
+ "parallel": {
+ "inputs": [
+ "input"
+ ],
+ "outputs": [
+ "branch_1",
+ "branch_2",
+ "results"
+ ],
+ "config": [
+ "branches",
+ "wait_for_all",
+ "fail_fast"
+ ]
+ },
+ "wait": {
+ "inputs": [
+ "input"
+ ],
+ "outputs": [
+ "output"
+ ],
+ "config": [
+ "wait_type",
+ "duration",
+ "until",
+ "until_time"
+ ]
+ },
+ "end": {
+ "inputs": [
+ "input"
+ ],
+ "outputs": [],
+ "config": [
+ "status",
+ "success",
+ "failed",
+ "cancelled",
+ "message"
+ ]
+ },
+ "text_template": {
+ "inputs": [
+ "variables"
+ ],
+ "outputs": [
+ "text"
+ ],
+ "config": [
+ "template",
+ "escape_html",
+ "trim_whitespace"
+ ]
+ },
+ "json_transform": {
+ "inputs": [
+ "input"
+ ],
+ "outputs": [
+ "output"
+ ],
+ "config": [
+ "transform_type",
+ "jmespath",
+ "jsonpath",
+ "mapping",
+ "expression"
+ ]
+ },
+ "code_executor": {
+ "inputs": [
+ "input"
+ ],
+ "outputs": [
+ "output",
+ "logs"
+ ],
+ "config": [
+ "language",
+ "javascript",
+ "python",
+ "code",
+ "timeout"
+ ]
+ },
+ "data_aggregator": {
+ "inputs": [
+ "items"
+ ],
+ "outputs": [
+ "result",
+ "count"
+ ],
+ "config": [
+ "aggregation_type",
+ "array",
+ "concat",
+ "sum",
+ "average",
+ "min",
+ "max",
+ "merge",
+ "first",
+ "last",
+ "separator",
+ "field_path"
+ ]
+ },
+ "text_splitter": {
+ "inputs": [
+ "text"
+ ],
+ "outputs": [
+ "chunks",
+ "count"
+ ],
+ "config": [
+ "split_type",
+ "separator",
+ "length",
+ "sentences",
+ "paragraphs",
+ "regex",
+ "chunk_size",
+ "chunk_overlap",
+ "regex_pattern",
+ "remove_empty"
+ ]
+ },
+ "variable_assignment": {
+ "inputs": [
+ "value"
+ ],
+ "outputs": [
+ "output"
+ ],
+ "config": [
+ "variable_name",
+ "value_type",
+ "input",
+ "static",
+ "expression",
+ "static_value"
+ ]
+ },
+ "data_transform": {
+ "inputs": [
+ "data"
+ ],
+ "outputs": [
+ "result"
+ ],
+ "config": [
+ "transform_type",
+ "template",
+ "jsonpath",
+ "jmespath",
+ "expression"
+ ]
+ },
+ "llm_call": {
+ "inputs": [
+ "input",
+ "context"
+ ],
+ "outputs": [
+ "response",
+ "usage",
+ "parsed"
+ ],
+ "config": [
+ "model",
+ "system_prompt",
+ "user_prompt_template",
+ "temperature",
+ "max_tokens",
+ "output_format",
+ "text",
+ "json",
+ "markdown",
+ "json_schema",
+ "enable_tools",
+ "tools"
+ ]
+ },
+ "question_classifier": {
+ "inputs": [
+ "question"
+ ],
+ "outputs": [
+ "category",
+ "confidence",
+ "all_scores"
+ ],
+ "config": [
+ "model",
+ "categories",
+ "confidence_threshold",
+ "fallback_category"
+ ]
+ },
+ "parameter_extractor": {
+ "inputs": [
+ "text"
+ ],
+ "outputs": [
+ "parameters",
+ "missing",
+ "success"
+ ],
+ "config": [
+ "model",
+ "parameters",
+ "extraction_prompt",
+ "strict_mode"
+ ]
+ },
+ "knowledge_retrieval": {
+ "inputs": [
+ "query"
+ ],
+ "outputs": [
+ "results",
+ "context",
+ "scores"
+ ],
+ "config": [
+ "knowledge_bases",
+ "top_k",
+ "similarity_threshold",
+ "retrieval_mode",
+ "vector",
+ "hybrid",
+ "keyword",
+ "rerank_enabled",
+ "rerank_model"
+ ]
+ },
+ "text_embedding": {
+ "inputs": [
+ "text"
+ ],
+ "outputs": [
+ "embedding",
+ "dimensions"
+ ],
+ "config": [
+ "model"
+ ]
+ },
+ "intent_recognition": {
+ "inputs": [
+ "text"
+ ],
+ "outputs": [
+ "intent",
+ "confidence",
+ "entities"
+ ],
+ "config": [
+ "model",
+ "intents_definition",
+ "extract_entities"
+ ]
+ },
+ "send_message": {
+ "inputs": [
+ "content",
+ "context"
+ ],
+ "outputs": [
+ "message_id",
+ "success"
+ ],
+ "config": [
+ "message_type",
+ "text",
+ "markdown",
+ "image",
+ "file",
+ "card",
+ "content_template",
+ "reply_to_original",
+ "at_sender"
+ ]
+ },
+ "http_request": {
+ "inputs": [
+ "body",
+ "variables"
+ ],
+ "outputs": [
+ "response",
+ "status_code",
+ "headers",
+ "success"
+ ],
+ "config": [
+ "method",
+ "GET",
+ "POST",
+ "PUT",
+ "PATCH",
+ "DELETE",
+ "url",
+ "headers",
+ "body_type",
+ "none",
+ "json",
+ "form",
+ "raw",
+ "body_template",
+ "timeout",
+ "retry_count",
+ "ignore_ssl"
+ ]
+ },
+ "bot_invoke": {
+ "inputs": [
+ "message",
+ "context"
+ ],
+ "outputs": [
+ "response",
+ "success"
+ ],
+ "config": [
+ "bot",
+ "wait_for_response",
+ "timeout"
+ ]
+ },
+ "workflow_invoke": {
+ "inputs": [
+ "input"
+ ],
+ "outputs": [
+ "output",
+ "status",
+ "execution_id"
+ ],
+ "config": [
+ "workflow_id",
+ "wait_for_completion",
+ "timeout",
+ "pass_context"
+ ]
+ },
+ "notification": {
+ "inputs": [
+ "content"
+ ],
+ "outputs": [
+ "success",
+ "notification_id"
+ ],
+ "config": [
+ "channel",
+ "webhook",
+ "email",
+ "dingtalk",
+ "feishu",
+ "wechat_work",
+ "title",
+ "content_template",
+ "webhook_url",
+ "recipients"
+ ]
+ },
+ "database_query": {
+ "inputs": [
+ "parameters"
+ ],
+ "outputs": [
+ "results",
+ "row_count",
+ "success"
+ ],
+ "config": [
+ "connection_type",
+ "postgresql",
+ "mysql",
+ "sqlite",
+ "connection_string",
+ "query",
+ "query_type",
+ "select",
+ "insert",
+ "update",
+ "delete",
+ "timeout"
+ ]
+ },
+ "redis_operation": {
+ "inputs": [
+ "key",
+ "value"
+ ],
+ "outputs": [
+ "result",
+ "success"
+ ],
+ "config": [
+ "connection_url",
+ "operation",
+ "get",
+ "set",
+ "delete",
+ "exists",
+ "incr",
+ "decr",
+ "hget",
+ "hset",
+ "lpush",
+ "rpush",
+ "lpop",
+ "rpop",
+ "key_template",
+ "hash_field",
+ "ttl"
+ ]
+ },
+ "mcp_tool": {
+ "inputs": [
+ "arguments"
+ ],
+ "outputs": [
+ "result",
+ "success",
+ "error"
+ ],
+ "config": [
+ "server_name",
+ "tool_name",
+ "arguments_template",
+ "timeout"
+ ]
+ },
+ "memory_store": {
+ "inputs": [
+ "value"
+ ],
+ "outputs": [
+ "result",
+ "success"
+ ],
+ "config": [
+ "operation",
+ "get",
+ "set",
+ "delete",
+ "append",
+ "list",
+ "key",
+ "scope",
+ "execution",
+ "workflow",
+ "session",
+ "user",
+ "global",
+ "ttl"
+ ]
+ },
+ "message_trigger": {
+ "inputs": [],
+ "outputs": [
+ "message",
+ "sender",
+ "context"
+ ],
+ "config": [
+ "match_type",
+ "all",
+ "prefix",
+ "regex",
+ "contains",
+ "exact",
+ "match_pattern",
+ "message_source",
+ "group",
+ "private",
+ "platforms",
+ "ignore_bot_messages"
+ ]
+ },
+ "cron_trigger": {
+ "inputs": [],
+ "outputs": [
+ "trigger_time",
+ "context"
+ ],
+ "config": [
+ "cron_expression",
+ "timezone",
+ "UTC",
+ "Asia/Shanghai",
+ "Asia/Tokyo",
+ "America/New_York",
+ "America/Los_Angeles",
+ "Europe/London",
+ "Europe/Berlin",
+ "description",
+ "enabled"
+ ]
+ },
+ "webhook_trigger": {
+ "inputs": [],
+ "outputs": [
+ "body",
+ "headers",
+ "query",
+ "method"
+ ],
+ "config": [
+ "webhook_path",
+ "auth_type",
+ "none",
+ "token",
+ "signature",
+ "basic",
+ "auth_token",
+ "allowed_ips",
+ "allowed_methods",
+ "content_type",
+ "application/json",
+ "application/x-www-form-urlencoded",
+ "multipart/form-data",
+ "text/plain",
+ "validation",
+ "timeout"
+ ]
+ },
+ "event_trigger": {
+ "inputs": [],
+ "outputs": [
+ "event",
+ "event_type",
+ "context"
+ ],
+ "config": [
+ "event_type",
+ "member_join",
+ "member_leave",
+ "message_recall",
+ "group_created",
+ "group_disbanded",
+ "bot_added",
+ "bot_removed",
+ "friend_request",
+ "group_request",
+ "source_filter",
+ "all",
+ "group",
+ "private",
+ "platforms"
+ ]
+ }
+ },
+ "discrepancies": {
+ "code_executor": [
+ {
+ "type": "config",
+ "only_yaml": [],
+ "only_frontend": [
+ "javascript",
+ "python"
+ ]
+ }
+ ],
+ "condition": [
+ {
+ "type": "config",
+ "only_yaml": [],
+ "only_frontend": [
+ "contains",
+ "starts_with",
+ "gt",
+ "matches",
+ "ends_with",
+ "lte",
+ "type_check",
+ "boolean",
+ "eq",
+ "number",
+ "comparison",
+ "array",
+ "exists",
+ "lt",
+ "string",
+ "object",
+ "neq",
+ "null",
+ "gte"
+ ]
+ }
+ ],
+ "cron_trigger": [
+ {
+ "type": "config",
+ "only_yaml": [],
+ "only_frontend": [
+ "America/New_York",
+ "America/Los_Angeles",
+ "Asia/Shanghai",
+ "UTC",
+ "Asia/Tokyo",
+ "Europe/Berlin",
+ "Europe/London"
+ ]
+ }
+ ],
+ "data_transform": [
+ {
+ "type": "config",
+ "only_yaml": [],
+ "only_frontend": [
+ "jsonpath",
+ "jmespath"
+ ]
+ }
+ ],
+ "database_query": [
+ {
+ "type": "config",
+ "only_yaml": [],
+ "only_frontend": [
+ "mysql",
+ "sqlite",
+ "update",
+ "insert",
+ "postgresql",
+ "delete",
+ "select"
+ ]
+ }
+ ],
+ "end": [
+ {
+ "type": "config",
+ "only_yaml": [],
+ "only_frontend": [
+ "cancelled",
+ "failed",
+ "success"
+ ]
+ }
+ ],
+ "event_trigger": [
+ {
+ "type": "config",
+ "only_yaml": [],
+ "only_frontend": [
+ "group_created",
+ "private",
+ "friend_request",
+ "all",
+ "bot_added",
+ "message_recall",
+ "member_leave",
+ "group_disbanded",
+ "group",
+ "group_request",
+ "bot_removed",
+ "member_join"
+ ]
+ }
+ ],
+ "http_request": [
+ {
+ "type": "config",
+ "only_yaml": [],
+ "only_frontend": [
+ "none",
+ "json",
+ "DELETE",
+ "PATCH",
+ "POST",
+ "PUT",
+ "raw",
+ "GET",
+ "form"
+ ]
+ }
+ ],
+ "knowledge_retrieval": [
+ {
+ "type": "config",
+ "only_yaml": [],
+ "only_frontend": [
+ "keyword",
+ "vector",
+ "hybrid"
+ ]
+ }
+ ],
+ "llm_call": [
+ {
+ "type": "config",
+ "only_yaml": [],
+ "only_frontend": [
+ "text",
+ "json",
+ "markdown"
+ ]
+ }
+ ],
+ "loop": [
+ {
+ "type": "config",
+ "only_yaml": [],
+ "only_frontend": [
+ "while",
+ "foreach"
+ ]
+ }
+ ],
+ "memory_store": [
+ {
+ "type": "config",
+ "only_yaml": [],
+ "only_frontend": [
+ "user",
+ "workflow",
+ "get",
+ "set",
+ "execution",
+ "delete",
+ "list",
+ "global",
+ "session",
+ "append"
+ ]
+ }
+ ],
+ "message_trigger": [
+ {
+ "type": "config",
+ "only_yaml": [],
+ "only_frontend": [
+ "private",
+ "prefix",
+ "all",
+ "group",
+ "regex",
+ "exact",
+ "contains"
+ ]
+ }
+ ],
+ "redis_operation": [
+ {
+ "type": "config",
+ "only_yaml": [],
+ "only_frontend": [
+ "incr",
+ "hset",
+ "decr",
+ "get",
+ "set",
+ "exists",
+ "hget",
+ "delete",
+ "lpush",
+ "lpop",
+ "rpop",
+ "rpush"
+ ]
+ }
+ ],
+ "send_message": [
+ {
+ "type": "config",
+ "only_yaml": [],
+ "only_frontend": [
+ "file",
+ "card",
+ "markdown",
+ "text",
+ "image"
+ ]
+ }
+ ],
+ "wait": [
+ {
+ "type": "config",
+ "only_yaml": [],
+ "only_frontend": [
+ "until"
+ ]
+ }
+ ],
+ "webhook_trigger": [
+ {
+ "type": "config",
+ "only_yaml": [],
+ "only_frontend": [
+ "token",
+ "none",
+ "application/x-www-form-urlencoded",
+ "signature",
+ "multipart/form-data",
+ "text/plain",
+ "application/json",
+ "basic"
+ ]
+ }
+ ]
+ }
+}
\ No newline at end of file
diff --git a/plans/translation-analysis-report.txt b/plans/translation-analysis-report.txt
new file mode 100644
index 000000000..417d8480a
--- /dev/null
+++ b/plans/translation-analysis-report.txt
@@ -0,0 +1,3791 @@
+翻译分析报告
+============================================================
+
+
+文件: ja-JP.ts
+总键数: 1406
+英文键数: 76
+缺失键数: 69
+
+英文键列表:
+ starOnGitHub: 'GitHubでStarする'
+ dataCollectionPolicyUrl: 'https://link.langbot.app/ja/docs/data-policy'
+ apiIntegration: 'API統合'
+ apiKeys: 'API キー'
+ webhooks: 'Webhooks'
+ createWebhook: 'Webhook を作成'
+ webhookName: 'Webhook 名'
+ webhookUrl: 'Webhook コールバック URL'
+ webhookDescription: 'Webhook の説明'
+ webhookNameRequired: 'Webhook 名は必須です'
+ webhookUrlRequired: 'Webhook URL は必須です'
+ apiKey: 'APIキー'
+ syncSuccess: '同期完了:{{created}} 件作成、{{updated}} 件更新'
+ langbotModels: 'LangBot モデル'
+ rerankUrlTooltip: '再順位付けエンドポイントの完全URL(例: https://dashscope.aliyuncs.com/compatible-api/v1/reranks)'
+ dateFormat: '{{month}}月{{day}}日'
+ ruleValuePrefixPlaceholder: '例: !draw'
+ ruleValueRegexpPlaceholder: '例: ^/help'
+ webhookUrlCopied: 'Webhook URL をコピーしました'
+ webhookSaasLink: 'LangBot Cloud の詳細はこちら'
+ connectionError: 'WebSocket接続エラー'
+ fromGithub: 'GitHubから'
+ new: 'New'
+ repoUrlPlaceholder: '例: https://github.com/owner/repo'
+ releaseTag: 'タグ: {{tag}}'
+ releaseName: '名前: {{name}}'
+ publishedAt: '公開日: {{date}}'
+ assetSize: 'サイズ: {{size}}'
+ depsProgress: '{{installed}}/{{total}} インストール済み · 残り {{remaining}} 個'
+ stdio: 'Stdioモード'
+ http: 'HTTPモード'
+ connectionFailed: 'WebSocket接続失敗'
+ starCount: 'スター:{{count}}'
+ extraParametersDescription: 'リクエストボディに追加されるパラメータ(max_tokens、temperature、top_p など)'
+ copySuffix: ' Copy'
+ connected: 'WebSocket接続済み'
+ disconnected: 'WebSocket未接続'
+ topK: 'Top K'
+ initWithSpace: 'Space で初期化'
+ recoveryKeyDescription: '設定ファイル `data/config.yaml` の `system.recovery_key` に保存されています'
+ embeddingCalls: 'Embedding コール'
+ llmStats: 'LLM統計'
+ mcp: 'MCP'
+ skipConfirmOk: 'OK'
+ platformConfig: '{{platform}} 設定'
+ aiConfig: '{{engine}} 設定'
+ uuid: 'UUID'
+ successfulCount: '成功{{count}}件'
+ conditionPlaceholder: '条件式を入力してください。例: output.success == true'
+ webhookTrigger: 'Webhookトリガー'
+ codeExecutorDescription: 'Python/JavaScriptコードを実行'
+ jsonTransform: 'JSON変換'
+ callPipeline: 'Pipeline呼び出し'
+ langflowFlow: 'Langflowフロー'
+ cozeBot: 'Coze Bot'
+ cozeBotDescription: 'Coze Botを呼び出し'
+ redisOperation: 'Redis操作'
+ conversationIdPlaceholder: 'Conversation unique identifier'
+ showingExecutions: '{{shown}} / {{total}}件の実行を表示中'
+ cron: 'Cron式'
+ path: 'Webhookパス'
+ content_type: 'Content-Type'
+ temperature: 'Temperature'
+ top_p: 'Top P'
+ pipeline_uuid: 'Pipeline'
+ connection_url: '接続URL'
+ webhook_body: 'Webhookボディ'
+ webhook_headers: 'Webhookヘッダー'
+ webhook_query: 'Webhookクエリ'
+ webhook_method: 'Webhookメソッド'
+ http_body: 'HTTPボディ'
+ http_response: 'HTTP応答'
+ pipeline_response: 'Pipeline応答'
+ redis_key: 'Redisキー'
+ redis_value: 'Redis値'
+ answer: 'Dify回答'
+
+缺失键列表:
+ undo
+ redo
+ bindTarget
+ bindTargetDescription
+ bindingType
+ selectBinding
+ noPipelinesFound
+ noWorkflowsFound
+ pipelineBindingHelp
+ workflowBindingHelp
+ edgeCondition
+ edgeConditionPlaceholder
+ noNodeSelected
+ selectNodeToEdit
+ dragNodeHint
+ mode
+ panel
+ start
+ pause
+ resume
+ step
+ stop
+ messageContentPlaceholder
+ platformPlaceholder
+ customVariablesDesc
+ variableKey
+ noWatchedVariables
+ nodeOutputs
+ noNodeOutputs
+ clearBreakpoints
+ logEntries
+ resetContext
+ starting
+ started
+ startError
+ pauseError
+ resumed
+ resumeError
+ steppedTo
+ stepError
+ stopped
+ stopError
+ escape_html
+ trim_whitespace
+ json_transform_type
+ json_expression
+ mapping
+ code_language
+ code_content
+ aggregation_type
+ separator
+ field_path
+ split_type
+ chunk_size
+ chunk_overlap
+ regex_pattern
+ remove_empty
+ assign_variable_name
+ value_type
+ static_value
+ n8n_webhook_url
+ n8n_auth_type
+ langflow_flow_id
+ coze_bot_id
+ coze_api_base
+ workflow
+ pipelineDescription
+ noPipelines
+ noWorkflows
+
+============================================================
+
+文件: zh-Hant.ts
+总键数: 1406
+英文键数: 553
+缺失键数: 69
+
+英文键列表:
+ integration: 'Integration'
+ welcome: '歡迎回到 LangBot 👋'
+ retry: 'Retry'
+ language: 'Programming Language'
+ starOnGitHub: '在 GitHub 上 Star'
+ cancel: 'Cancel Execution'
+ name: 'Name'
+ dataCollectionPolicyUrl: 'https://link.langbot.app/zh/docs/data-policy'
+ loading: 'Loading...'
+ loginWithSpace: '使用 Space 登入'
+ spaceLoginTitle: '透過 Space 登入'
+ spaceLoginFailed: 'Space 登入失敗'
+ spaceLoginSuccessDescription: '正在跳轉到 LangBot...'
+ apiIntegration: 'API 整合'
+ apiKeys: 'API 金鑰'
+ apiKeyHint: 'API 金鑰允許外部系統訪問 LangBot 的 Service API'
+ webhooks: 'Webhooks'
+ createWebhook: '建立 Webhook'
+ webhookName: 'Webhook 名稱'
+ webhookUrl: 'Webhook 回調位址'
+ webhookDescription: 'Webhook 描述'
+ webhookCreated: 'Webhook 建立成功'
+ webhookDeleted: 'Webhook 刪除成功'
+ webhookNameRequired: 'Webhook 名稱不能為空'
+ webhookUrlRequired: 'Webhook URL 不能為空'
+ noWebhooks: '暫無 Webhook'
+ more: '更多 ({{count}})'
+ modelBaseURL: '基礎 URL'
+ apiKeyRequired: 'API Key不能為空'
+ mustBeTrueOrFalse: '必須是 true 或 false'
+ requestURL: '請求URL'
+ scanURLPlaceholder: '留空則使用請求 URL + /models'
+ apiKey: 'API Key'
+ value: 'Value'
+ syncSuccess: '同步完成:建立 {{created}} 個,更新 {{updated}} 個'
+ langbotModelsDescription: '由 LangBot Space 提供的雲端模型'
+ langbotModels: 'LangBot 模型'
+ rerankUrlTooltip: '完整 URL 覆蓋重排序端點(例如:https://dashscope.aliyuncs.com/compatible-api/v1/reranks)'
+ modelsCount: '{{count}} 個模型'
+ dateFormat: '{{month}}月{{day}}日'
+ log: 'Log'
+ basicInfo: 'Basic Info'
+ basicInfoDescription: 'Set workflow name and description'
+ ruleValuePrefixPlaceholder: '如: !draw'
+ ruleValueRegexpPlaceholder: '如: ^/help'
+ dangerZone: 'Danger Zone'
+ dangerZoneDescription: 'Irreversible operations'
+ webhookUrlCopied: 'Webhook 位址已複製'
+ webhookSaasLink: '了解 LangBot Cloud'
+ viewDetails: 'View Details'
+ noLogs: 'No logs yet'
+ variables: 'Variables'
+ platform: 'Platform'
+ marketplace: 'Marketplace'
+ installFromGithub: '從Github安裝MCP伺服器'
+ connectionError: 'WebSocket連線錯誤'
+ confirmDeletePlugin: '您確定要刪除外掛({{author}}/{{name}})嗎?'
+ deleteConfirm: 'Confirm Delete'
+ fromGithub: '來自 GitHub'
+ confirmUpdatePlugin: '您確定要更新插件({{author}}/{{name}})嗎?'
+ failed: 'Failed'
+ repoUrlPlaceholder: '例如: https://github.com/owner/repo'
+ fetchingReleases: '正在獲取 Release 列表...'
+ selectRelease: '選擇 Release'
+ noReleasesFound: '未找到 Release'
+ fetchReleasesError: '獲取 Release 列表失敗:'
+ noAssetsFound: '該 Release 沒有可用的 .lbpkg 文件'
+ backToReleases: '返回 Release 列表'
+ releaseTag: 'Tag: {{tag}}'
+ releaseName: '名稱: {{name}}'
+ publishedAt: '發佈於: {{date}}'
+ assetSize: '大小: {{size}}'
+ installFromGithubDesc: '從 GitHub Release 安裝插件'
+ completed: 'Is Completed'
+ depsProgress: '已安裝 {{installed}}/{{total}} · 剩餘 {{remaining}} 個'
+ totalPlugins: '共 {{count}} 個插件'
+ stdio: 'Stdio模式'
+ sse: 'SSE模式'
+ http: 'HTTP模式'
+ url: 'Request URL'
+ headers: 'Request Headers'
+ timeout: 'Timeout'
+ connectionFailed: 'WebSocket連線失敗'
+ toolCount: '{{count}} 個工具'
+ starCount: '星標:{{count}}'
+ extraParametersDescription: '將在請求時附加到請求體中,如 max_tokens, temperature, top_p 等'
+ today: 'Today'
+ copySuffix: ' Copy'
+ unsavedChanges: 'Unsaved changes'
+ connected: 'WebSocket已連線'
+ disconnected: 'WebSocket未連線'
+ notConnected: 'WebSocket未連線,請稍後重試'
+ documents: 'Retrieved Documents'
+ status: 'Send Status'
+ supportedFormats: '支援 PDF、Word、TXT、Markdown 等文檔格式'
+ query: 'Query Parameters'
+ content: 'Content'
+ embeddingModelRequired: '此引擎需要Embedding模型'
+ running: 'Running'
+ recoveryKeyDescription: '儲存在設定檔案`data/config.yaml`的`system.recovery_key`中'
+ spaceStatus: 'Space 帳戶'
+ bindSpace: '綁定 Space 帳戶'
+ llmCallsCount: 'LLM調用'
+ successRate: 'Success Rate'
+ llmCalls: 'LLM 呼叫'
+ embeddingCalls: 'Embedding 呼叫'
+ errors: 'Error List'
+ message: 'Message Content'
+ sessionId: 'Session ID'
+ model: 'Model'
+ duration: 'Duration'
+ startTime: 'Start Time'
+ llmStats: 'LLM統計'
+ mcp: 'MCP'
+ action: 'Actions'
+ platformConfig: '{{platform}} 配置'
+ aiConfig: '{{engine}} 配置'
+ basicInfoDesc: 'Set workflow name, icon and description'
+ dangerZoneDesc: 'Irreversible operations'
+ deleteWorkflowAction: 'Delete this workflow'
+ deleteWorkflowHint: 'Once deleted, all associated configurations will be permanently removed and cannot be recovered.'
+ deleteWorkflow: 'Delete Workflow'
+ deleteConfirmDesc: 'Are you sure you want to delete workflow '
+ namePlaceholder: 'Enter workflow name'
+ descriptionPlaceholder: 'Enter workflow description (optional)'
+ enabled: 'Enabled'
+ enabledDesc: 'When enabled, the workflow can be triggered for execution'
+ info: 'Workflow Info'
+ uuid: 'UUID'
+ version: 'Version'
+ createdAt: 'Created At'
+ updatedAt: 'Updated At'
+ totalExecutions: '{{count}} execution(s) total'
+ statistics: 'Statistics'
+ successfulCount: '{{count}} successful'
+ averageDuration: 'Avg. Duration'
+ perExecution: 'per execution'
+ failedExecutions: 'Failed'
+ lastExecution: 'Last run'
+ filterByStatus: 'Filter by status'
+ allStatuses: 'All statuses'
+ manualTrigger: 'Manual Trigger'
+ triggerType: 'Trigger Type'
+ startedAt: 'Started At'
+ noExecutions: 'No executions yet'
+ executionDetails: 'Execution Details'
+ nodeExecutions: 'Node Executions'
+ result: 'Transform Result'
+ nodePalette: 'Node Palette'
+ properties: 'Properties'
+ zoomIn: 'Zoom In'
+ zoomOut: 'Zoom Out'
+ fitView: 'Fit View'
+ paste: 'Paste'
+ deleted: 'Deleted'
+ nothingToCopy: 'No nodes selected to copy'
+ nothingToPaste: 'Clipboard is empty'
+ copied: 'Copied {{count}} node(s)'
+ pasted: 'Pasted {{count}} node(s)'
+ nodesSelected: '{{count}} node(s) selected'
+ edgesSelected: '{{count}} edge(s) selected'
+ searchNodes: 'Search nodes...'
+ loadingNodeTypes: 'Loading node types...'
+ noNodesFound: 'No matching nodes found'
+ clearSearch: 'Clear search'
+ dragToAdd: 'Drag nodes to add to canvas'
+ selectNodeOrEdge: 'Select a node or edge'
+ selectNodeOrEdgeHint: 'Click on a node or edge in the canvas to view and edit its properties'
+ edgeProperties: 'Edge Properties'
+ nodeProperties: 'Node Properties'
+ condition: 'Condition Branch'
+ hasCondition: 'Set'
+ conditionPlaceholder: 'Enter condition expression, e.g. output.success == true'
+ conditionHelp: 'When condition is empty, this edge will always be executed. Use {{variable}} to reference context variables.'
+ deleteEdge: 'Delete Edge'
+ deleteEdgeConfirm: 'Confirm Delete Edge'
+ deleteEdgeConfirmDesc: 'This edge will be permanently removed.'
+ nodeLabel: 'Node Label'
+ nodeLabelPlaceholder: 'Enter node display name'
+ nodeId: 'Node ID'
+ inputOutputVariables: 'Input/Output Variables'
+ inputs: 'Inputs'
+ outputs: 'Outputs'
+ availableVariables: 'Available Variables'
+ globalVariables: 'Global Variables'
+ messageContent: 'Message Content'
+ nodeConfig: 'Node Configuration'
+ noConfigOptions: 'No configuration options for this node type'
+ deleteNode: 'Delete Node'
+ deleteNodeConfirm: 'Confirm Delete Node'
+ deleteNodeConfirmDesc: 'This node and all its connections will be permanently removed.'
+ input: 'Input'
+ text: 'Text'
+ context: 'Full Message Context'
+ body: 'Request Body'
+ items: 'Items'
+ arguments: 'Arguments'
+ question: 'User Question'
+ parameters: 'Extracted Parameters'
+ key: 'Key'
+ case_1: 'Case 1'
+ case_2: 'Case 2'
+ branch_1: 'Branch 1'
+ branch_2: 'Branch 2'
+ key_template: 'Key Template'
+ hash_field: 'Hash Field'
+ server_name: 'Server Name'
+ tool_name: 'Tool Name'
+ arguments_template: 'Arguments Template'
+ scope: 'Scope'
+ trigger: 'Triggers'
+ triggerDescription: 'Starting nodes for workflows'
+ messageTrigger: 'Message Trigger'
+ messageTriggerDescription: 'Triggered when a message is received'
+ scheduleTrigger: 'Schedule Trigger'
+ scheduleTriggerDescription: 'Triggered on a schedule'
+ cronTrigger: 'Cron Trigger'
+ cronTriggerDescription: 'Trigger workflow on a scheduled time'
+ webhookTrigger: 'Webhook Trigger'
+ webhookTriggerDescription: 'Triggered via HTTP request'
+ eventTrigger: 'Event Trigger'
+ eventTriggerDescription: 'Triggered on system events'
+ process: 'AI/Process'
+ processDescription: 'Data processing nodes'
+ aiProcess: 'AI Processing'
+ aiProcessDescription: 'Process messages using AI models'
+ llmCall: 'LLM Call'
+ llmCallDescription: 'Invoke large language model for conversation or generation'
+ codeProcess: 'Code Processing'
+ codeProcessDescription: 'Execute custom code'
+ codeExecutor: 'Code Executor'
+ codeExecutorDescription: 'Execute Python/JavaScript code'
+ templateProcess: 'Template Processing'
+ templateProcessDescription: 'Format output using templates'
+ httpRequest: 'HTTP Request'
+ httpRequestDescription: 'Send HTTP requests'
+ dataTransform: 'Data Transform'
+ dataTransformDescription: 'Transform data format'
+ questionClassifier: 'Question Classifier'
+ questionClassifierDescription: 'Classify user questions into predefined categories using LLM'
+ parameterExtractor: 'Parameter Extractor'
+ parameterExtractorDescription: 'Extract structured parameters from text using LLM'
+ knowledgeRetrieval: 'Knowledge Retrieval'
+ knowledgeRetrievalDescription: 'Retrieve relevant content from knowledge base'
+ textTemplate: 'Text Template'
+ textTemplateDescription: 'Generate text using templates with variable interpolation'
+ jsonTransform: 'JSON Transform'
+ jsonTransformDescription: 'Transform JSON data using expressions'
+ dataAggregator: 'Data Aggregator'
+ dataAggregatorDescription: 'Aggregate data from multiple sources'
+ textSplitter: 'Text Splitter'
+ textSplitterDescription: 'Split text into smaller chunks'
+ variableAssignment: 'Variable Assignment'
+ variableAssignmentDescription: 'Assign values to workflow variables'
+ control: 'Control Flow'
+ controlDescription: 'Flow control nodes'
+ conditionDescription: 'Branch based on conditions'
+ switch: 'Switch'
+ switchDescription: 'Multi-way branching'
+ loop: 'Loop'
+ loopDescription: 'Repeat execution'
+ iterator: 'Iterator'
+ iteratorDescription: 'Iterate over array elements'
+ parallel: 'Parallel Processing'
+ parallelDescription: 'Execute multiple branches in parallel'
+ wait: 'Wait'
+ waitDescription: 'Wait for specified time'
+ delay: 'Delay'
+ delayDescription: 'Wait for a specified time'
+ merge: 'Merge'
+ mergeDescription: 'Merge multiple branches'
+ variableAggregator: 'Variable Aggregator'
+ variableAggregatorDescription: 'Aggregate variable outputs from multiple branches'
+ actionDescription: 'Action execution nodes'
+ sendMessage: 'Send Message'
+ sendMessageDescription: 'Send message to platform'
+ replyMessage: 'Reply Message'
+ replyMessageDescription: 'Reply to the message that triggered the workflow'
+ storeData: 'Store Data'
+ storeDataDescription: 'Store data to database'
+ callPipeline: 'Call Pipeline'
+ callPipelineDescription: 'Call an existing Pipeline'
+ setVariable: 'Set Variable'
+ setVariableDescription: 'Set context variable'
+ openingStatement: 'Opening Statement'
+ openingStatementDescription: 'Provide conversation opener and suggested questions'
+ end: 'End'
+ endDescription: 'Mark the end of workflow execution'
+ logDescription: 'Record log information'
+ integrationDescription: 'Third-party platform integration nodes'
+ difyWorkflow: 'Dify Workflow'
+ difyWorkflowDescription: 'Call Dify platform workflow'
+ difyKnowledgeQuery: 'Dify Knowledge Query'
+ difyKnowledgeQueryDescription: 'Query Dify knowledge base'
+ n8nWorkflow: 'N8n Workflow'
+ n8nWorkflowDescription: 'Call n8n workflow'
+ langflowFlow: 'Langflow Flow'
+ langflowFlowDescription: 'Call Langflow flow'
+ cozeBot: 'Coze Bot'
+ cozeBotDescription: 'Call Coze Bot'
+ databaseQuery: 'Database Query'
+ databaseQueryDescription: 'Execute database queries'
+ redisOperation: 'Redis Operation'
+ redisOperationDescription: 'Perform Redis cache operations'
+ mcpTool: 'MCP Tool'
+ mcpToolDescription: 'Invoke an MCP tool'
+ memoryStore: 'Memory Store'
+ memoryStoreDescription: 'Store and retrieve data from workflow memory'
+ cancelled: 'Cancelled'
+ nodeResults: 'Node Execution Results'
+ current: 'Current Version'
+ rollback: 'Rollback to this version'
+ rollbackConfirm: 'Are you sure you want to rollback to this version? Current changes will be lost.'
+ rollbackSuccess: 'Rollback successful'
+ rollbackError: 'Failed to rollback: '
+ debug: 'Debug'
+ debugMode: 'Debug Mode'
+ debugPanel: 'Debug Panel'
+ startDebug: 'Start Debug'
+ pauseDebug: 'Pause'
+ resumeDebug: 'Resume'
+ stepDebug: 'Step'
+ stopDebug: 'Stop'
+ debugContext: 'Debug Context'
+ simulatedMessage: 'Simulated Message'
+ simulatedMessagePlaceholder: 'Enter the message content to simulate'
+ senderId: 'Sender ID'
+ senderIdPlaceholder: 'Sender unique identifier'
+ senderName: 'Sender Name'
+ senderNamePlaceholder: 'Sender display name'
+ conversationIdPlaceholder: 'Conversation unique identifier'
+ isGroup: 'Group Chat'
+ customVariables: 'Custom Variables'
+ addVariable: 'Add Variable'
+ variableName: 'Variable Name'
+ variableValue: 'Variable Value'
+ watchedVariables: 'Watched Variables'
+ addWatchVariable: 'Add Watch'
+ nodeStates: 'Node States'
+ breakpoints: 'Breakpoints'
+ toggleBreakpoint: 'Toggle Breakpoint'
+ breakpointSet: 'Breakpoint set'
+ breakpointRemoved: 'Breakpoint removed'
+ debugLogs: 'Debug Logs'
+ clearLogs: 'Clear Logs'
+ autoScroll: 'Auto Scroll'
+ idle: 'Idle'
+ paused: 'Paused'
+ pending: 'Pending'
+ skipped: 'Skipped'
+ filterByDate: 'Filter by Date'
+ allTime: 'All Time'
+ lastWeek: 'Last Week'
+ lastMonth: 'Last Month'
+ showingExecutions: 'Showing {{shown}} / {{total}} executions'
+ rerun: 'Rerun'
+ rerunExecution: 'Rerun Execution'
+ details: 'Details'
+ completedAt: 'Completed At'
+ noNodeExecutions: 'No node executions yet'
+ conditions: 'Trigger Conditions'
+ keyword_filter: 'Keyword Filter'
+ regex_filter: 'Regex Filter'
+ min_length: 'Min Length'
+ max_length: 'Max Length'
+ require_mention: 'Require Mention'
+ respond_rules: 'Group Respond Rules'
+ access_control: 'Access Control'
+ cron: 'Cron Expression'
+ timezone: 'Timezone'
+ path: 'Webhook Path'
+ allowed_methods: 'Allowed HTTP Methods'
+ content_type: 'Content-Type'
+ auth_type: 'Authentication Type'
+ auth_key: 'Auth Key'
+ validation: 'Request Validation'
+ event_types: 'Event Types'
+ filter: 'Event Filter'
+ debounce_ms: 'Debounce Time'
+ prompt_template: 'Prompt Template'
+ system_prompt: 'System Prompt'
+ temperature: 'Temperature'
+ top_p: 'Top P'
+ frequency_penalty: 'Frequency Penalty'
+ presence_penalty: 'Presence Penalty'
+ max_tokens: 'Max Tokens'
+ stop_sequences: 'Stop Sequences'
+ seed: 'Random Seed'
+ stream: 'Stream Output'
+ use_conversation_history: 'Use Conversation History'
+ code: 'Code'
+ method: 'Request Method'
+ auth_config: 'Auth Configuration'
+ transform_type: 'Transform Type'
+ template: 'Template'
+ expression: 'Expression'
+ output_type: 'Output Type'
+ categories: 'Categories'
+ instruction: 'Instruction'
+ knowledge_bases: 'Knowledge Bases'
+ top_k: 'Top K Results'
+ score_threshold: 'Score Threshold'
+ search_method: 'Search Method'
+ enable_citations: 'Enable Citations'
+ condition_type: 'Condition Type'
+ condition_expression: 'Condition Expression'
+ left_value: 'Left Value'
+ operator: 'Comparison Operator'
+ right_value: 'Right Value'
+ cases: 'Branch Cases'
+ max_iterations: 'Max Iterations'
+ break_condition: 'Break Condition'
+ max_concurrency: 'Max Concurrency'
+ branches: 'Branch Configuration'
+ wait_all: 'Wait All'
+ fail_fast: 'Fail Fast'
+ duration_type: 'Time Unit'
+ merge_strategy: 'Merge Strategy'
+ variable_mappings: 'Variable Mappings'
+ aggregation_mode: 'Aggregation Mode'
+ target_type: 'Target Type'
+ target_id: 'Target ID'
+ message_type: 'Message Type'
+ reply_mode: 'Reply Mode'
+ message_template: 'Message Template'
+ long_text_processing: 'Long Text Processing'
+ force_delay: 'Force Delay'
+ pipeline_uuid: 'Pipeline'
+ inherit_context: 'Inherit Context'
+ storage_type: 'Storage Type'
+ ttl: 'TTL'
+ key_prefix: 'Key Prefix'
+ variable_name: 'Variable Name'
+ variable_scope: 'Variable Scope'
+ set_variable_operation: 'Operation Type'
+ statement: 'Opening Statement'
+ suggested_questions: 'Suggested Questions'
+ show_suggestions: 'Show Suggestions'
+ output_format: 'Output Format'
+ success_message: 'Success Message'
+ connection_type: 'Database Type'
+ connection_string: 'Connection String'
+ query_type: 'Query Type'
+ connection_url: 'Connection URL'
+ operation: 'Operation Type'
+ dataset_id: 'Dataset ID'
+ sender_id: 'Sender ID'
+ sender_name: 'Sender Name'
+ is_group: 'Is Group Chat'
+ schedule: 'Trigger Schedule'
+ cron_timestamp: 'Cron Timestamp'
+ cron_schedule: 'Cron Schedule'
+ cron_context: 'Cron Context'
+ webhook_body: 'Webhook Body'
+ webhook_headers: 'Webhook Headers'
+ webhook_query: 'Webhook Query'
+ webhook_method: 'Webhook Method'
+ event_type: 'Event Type'
+ event_data: 'Event Data'
+ event_timestamp: 'Event Timestamp'
+ response: 'Model Response'
+ usage: 'Token Usage Statistics'
+ prompt: 'Prompt/Question'
+ context_info: 'Context Information'
+ output: 'Output Data'
+ console: 'Console Output'
+ code_input: 'Code Input'
+ code_output: 'Code Output'
+ status_code: 'Status Code'
+ http_body: 'HTTP Body'
+ http_headers: 'HTTP Headers'
+ http_response: 'HTTP Response'
+ response_headers: 'Response Headers'
+ transform_input: 'Transform Input'
+ transform_result: 'Transform Result'
+ category: 'Category Result'
+ confidence: 'Confidence'
+ all_scores: 'All Category Scores'
+ extraction_success: 'Extraction Success'
+ extract_text: 'Input Text'
+ citations: 'Citation Information'
+ knowledge_context: 'Merged Context'
+ knowledge_query: 'Retrieval Query'
+ true: 'True Branch Output'
+ false: 'False Branch Output'
+ condition_input: 'Condition Input'
+ matched_case: 'Matched Branch Output'
+ default: 'Default Branch Output'
+ switch_input: 'Switch Input'
+ item: 'Current Item'
+ index: 'Current Index'
+ results: 'All Iteration Results'
+ loop_items: 'Items to Iterate'
+ is_first: 'Is First'
+ is_last: 'Is Last'
+ iterator_array: 'Input Array'
+ iterator_item: 'Current Element'
+ iterator_index: 'Current Index'
+ parallel_input: 'Parallel Input'
+ parallel_results: 'All Branch Results'
+ wait_input: 'Passthrough Input'
+ wait_output: 'Passthrough Output'
+ merged: 'Merged Result'
+ merge_array: 'Array Result'
+ merge_input_1: 'Input 1'
+ merge_input_2: 'Input 2'
+ merge_input_3: 'Input 3'
+ merge_input_4: 'Input 4'
+ aggregated: 'Aggregated Variables'
+ aggregator_variables: 'Variable Input'
+ message_id: 'Message ID'
+ target: 'Target ID'
+ reply_message: 'Reply Content'
+ pipeline_response: 'Pipeline Response'
+ pipeline_result: 'Full Result'
+ pipeline_query: 'Query Content'
+ context_data: 'Context Data'
+ store_status: 'Store Status'
+ store_key: 'Store Key'
+ store_value: 'Store Value'
+ variable_value: 'Variable Value'
+ variable_result: 'Set Variable Result'
+ workflow_output: 'Workflow Output'
+ final_result: 'Final Result'
+ query_results: 'Query Results'
+ row_count: 'Affected/Returned Rows'
+ query_success: 'Query Success'
+ query_params: 'Query Parameters'
+ redis_result: 'Operation Result'
+ redis_success: 'Operation Success'
+ redis_key: 'Redis Key'
+ redis_value: 'Redis Value'
+ plugin_input: 'Plugin Input'
+ tool_result: 'Tool Execution Result'
+ tool_success: 'Tool Call Success'
+ mcp_arguments: 'Tool Arguments'
+ memory_result: 'Retrieved/Stored Value'
+ memory_success: 'Operation Success'
+ memory_value: 'Value to Store'
+ answer: 'Dify Answer'
+ dify_success: 'Call Success'
+ dify_query: 'User Input/Query'
+ search_results: 'Search Results'
+ knowledge_base_query: 'Query Content'
+ n8n_result: 'Workflow Execution Result'
+ n8n_success: 'Call Success'
+ n8n_payload: 'Workflow Input Data'
+ flow_result: 'Flow Execution Result'
+ flow_success: 'Call Success'
+ langflow_input: 'Input Content'
+ bot_answer: 'Bot Reply'
+ bot_success: 'Call Success'
+ coze_query: 'User Input/Query'
+
+缺失键列表:
+ undo
+ redo
+ bindTarget
+ bindTargetDescription
+ bindingType
+ selectBinding
+ noPipelinesFound
+ noWorkflowsFound
+ pipelineBindingHelp
+ workflowBindingHelp
+ edgeCondition
+ edgeConditionPlaceholder
+ noNodeSelected
+ selectNodeToEdit
+ dragNodeHint
+ mode
+ panel
+ start
+ pause
+ resume
+ step
+ stop
+ messageContentPlaceholder
+ platformPlaceholder
+ customVariablesDesc
+ variableKey
+ noWatchedVariables
+ nodeOutputs
+ noNodeOutputs
+ clearBreakpoints
+ logEntries
+ resetContext
+ starting
+ started
+ startError
+ pauseError
+ resumed
+ resumeError
+ steppedTo
+ stepError
+ stopped
+ stopError
+ escape_html
+ trim_whitespace
+ json_transform_type
+ json_expression
+ mapping
+ code_language
+ code_content
+ aggregation_type
+ separator
+ field_path
+ split_type
+ chunk_size
+ chunk_overlap
+ regex_pattern
+ remove_empty
+ assign_variable_name
+ value_type
+ static_value
+ n8n_webhook_url
+ n8n_auth_type
+ langflow_flow_id
+ coze_bot_id
+ coze_api_base
+ workflow
+ pipelineDescription
+ noPipelines
+ noWorkflows
+
+============================================================
+
+文件: es-ES.ts
+总键数: 1405
+英文键数: 1375
+缺失键数: 70
+
+英文键列表:
+ home: 'Inicio'
+ extensions: 'Extensiones'
+ installedPlugins: 'Plugins instalados'
+ pluginMarket: 'Tienda'
+ mcpServers: 'Servidores MCP'
+ pluginPages: 'Páginas de plugins'
+ pluginPagesTooltip: 'Páginas visuales proporcionadas por los plugins instalados'
+ quickStart: 'Inicio rápido'
+ login: 'Iniciar sesión'
+ logout: 'Cerrar sesión'
+ accountOptions: 'Configuración'
+ account: 'Cuenta'
+ integration: 'Integración'
+ email: 'Correo electrónico'
+ password: 'Contraseña'
+ welcome: 'Bienvenido de nuevo a LangBot 👋'
+ continueToLogin: 'Inicia sesión para continuar'
+ loginSuccess: 'Inicio de sesión exitoso'
+ loginFailed: 'Error de inicio de sesión, por favor verifica tu correo y contraseña'
+ loginLoadError: 'No se puede conectar al servidor'
+ loginLoadErrorDesc: 'No se puede conectar al backend de LangBot. Asegúrate de que el servicio esté en ejecución e inténtalo de nuevo.'
+ retry: 'Reintentar'
+ enterEmail: 'Introduce la dirección de correo electrónico'
+ enterPassword: 'Introduce la contraseña'
+ invalidEmail: 'Por favor, introduce una dirección de correo electrónico válida'
+ emptyPassword: 'Por favor, introduce tu contraseña'
+ language: 'Lenguaje de programación'
+ helpDocs: 'Obtener ayuda'
+ featureRequest: 'Solicitar función'
+ starOnGitHub: 'Dar estrella en GitHub'
+ create: 'Crear'
+ edit: 'Editar'
+ delete: 'Eliminar archivo'
+ add: 'Añadir'
+ select: 'Seleccionar'
+ cancel: 'CancelarEjecutar'
+ submit: 'Enviar'
+ error: 'Error'
+ success: 'Migración de base de conocimiento completada'
+ save: 'Guardar'
+ saving: 'Guardando...'
+ confirm: 'Confirmar'
+ confirmDelete: 'Confirmar eliminación'
+ deleteConfirmation: '¿Está seguro de que desea eliminar este flujo de trabajo?'
+ selectOption: 'Selecciona una opción'
+ selectPreset: 'Seleccionar preajuste'
+ required: 'Obligatorio'
+ enable: 'Activar'
+ name: 'Nombre'
+ description: 'Descripción'
+ icon: 'Icono'
+ close: 'Cerrar'
+ deleteSuccess: 'Eliminado exitosamente'
+ deleteError: 'Error al eliminar:'
+ addRound: 'Añadir ronda'
+ copy: 'Copiar'
+ copySuccess: '复制Éxito'
+ copyFailed: 'Error al copiar'
+ test: 'Probar'
+ forgotPassword: '¿Olvidaste tu contraseña?'
+ agreementNotice: 'Al continuar, aceptas nuestra'
+ privacyPolicy: 'Política de privacidad'
+ and: 'y'
+ dataCollectionPolicy: 'Política de recopilación de datos'
+ dataCollectionPolicyUrl: 'https://link.langbot.app/en/docs/data-policy'
+ loading: 'Cargar中...'
+ fieldRequired: 'Este campo es obligatorio'
+ or: 'o'
+ loginWithSpace: 'Iniciar sesión con Space'
+ spaceLoginRecommended: 'Recomendado: Usa API de modelos oficiales estables y servicios en la nube'
+ loginLocal: 'Iniciar sesión con cuenta local'
+ loginWithPassword: 'Iniciar sesión con contraseña'
+ spaceLoginTitle: 'Iniciar sesión con Space'
+ spaceLoginDescription: 'Escanea el código QR o visita el enlace para autorizar'
+ spaceLoginUserCode: 'Tu código'
+ spaceLoginExpires: 'El código expira en {{seconds}} segundos'
+ spaceLoginWaiting: 'Esperando autorización...'
+ spaceLoginSuccess: 'Autorización exitosa'
+ spaceLoginFailed: 'Error de inicio de sesión con Space'
+ spaceLoginExpired: 'El código de autorización ha expirado, por favor inténtalo de nuevo'
+ spaceLoginCancel: 'Cancelar'
+ spaceLoginVisitLink: 'Visitar enlace'
+ spaceLoginProcessing: 'Iniciando sesión con Space'
+ spaceLoginProcessingDescription: 'Por favor espera mientras completamos tu inicio de sesión...'
+ spaceLoginSuccessDescription: 'Redirigiendo a LangBot...'
+ spaceLoginError: 'Error de inicio de sesión'
+ spaceLoginNoCode: 'Falta el código de autorización'
+ backToLogin: 'Volver al inicio de sesión'
+ backToHome: 'Volver al inicio'
+ spaceAccountCannotChangePassword: 'Las cuentas de Space no pueden cambiar la contraseña aquí'
+ theme: 'Tema'
+ changePassword: 'Cambiar contraseña'
+ currentPassword: 'Contraseña actual'
+ newPassword: 'Nueva contraseña'
+ confirmNewPassword: 'Confirmar nueva contraseña'
+ enterCurrentPassword: 'Introduce la contraseña actual'
+ enterNewPassword: 'Introduce la nueva contraseña'
+ enterConfirmPassword: 'Confirma la nueva contraseña'
+ currentPasswordRequired: 'La contraseña actual es obligatoria'
+ newPasswordRequired: 'La nueva contraseña no puede estar vacía'
+ confirmPasswordRequired: 'La confirmación de contraseña es obligatoria'
+ passwordsDoNotMatch: 'Las contraseñas no coinciden'
+ changePasswordSuccess: 'Contraseña cambiada correctamente'
+ changePasswordFailed: 'Error al cambiar la contraseña, por favor verifica tu contraseña actual'
+ apiIntegration: 'Integración API'
+ apiKeys: 'Claves API'
+ manageApiIntegration: 'Gestionar integración API'
+ manageApiKeys: 'Gestionar claves API'
+ createApiKey: 'Crear clave API'
+ apiKeyName: 'Nombre de la clave API'
+ apiKeyDescription: 'Descripción de la clave API'
+ apiKeyValue: 'Valor de la clave API'
+ apiKeyCreated: 'Clave API creada correctamente'
+ apiKeyDeleted: 'Clave API eliminada correctamente'
+ apiKeyDeleteConfirm: '¿Estás seguro de que deseas eliminar esta clave API?'
+ apiKeyNameRequired: 'El nombre de la clave API es obligatorio'
+ copyApiKey: 'Copiar clave API'
+ apiKeyCopied: 'Clave API copiada al portapapeles'
+ noApiKeys: 'No hay claves API configuradas'
+ apiKeyHint: 'Las claves API permiten a sistemas externos acceder a las API del servicio LangBot'
+ webhooks: 'Webhooks'
+ createWebhook: 'Crear Webhook'
+ webhookName: 'Nombre del Webhook'
+ webhookUrl: 'URL de callback Webhook'
+ webhookDescription: 'Descripción del Webhook'
+ webhookEnabled: 'Activado'
+ webhookCreated: 'Webhook creado correctamente'
+ webhookDeleted: 'Webhook eliminado correctamente'
+ webhookDeleteConfirm: '¿Estás seguro de que deseas eliminar este Webhook?'
+ webhookNameRequired: 'El nombre del Webhook es obligatorio'
+ webhookUrlRequired: 'La URL del Webhook es obligatoria'
+ noWebhooks: 'No hay Webhooks configurados'
+ webhookHint: 'Los Webhooks permiten a LangBot enviar eventos de mensajes personales y grupales a sistemas externos'
+ actions: 'Acciones'
+ apiKeyCreatedMessage: 'Por favor copia esta clave API, si el botón no funciona, cópiala manualmente.'
+ none: 'Ninguno'
+ more: 'Más ({{count}})'
+ less: 'Menos'
+ noItems: 'Sin elementos'
+ title: 'Conversación de Flujo de Trabajo'
+ back: 'Volver'
+ help: 'Obtener ayuda'
+ createModel: 'Crear modelo Embedding'
+ editModel: 'Editar modelo Embedding'
+ getModelListError: 'Error al obtener la lista de modelos Embedding: '
+ modelName: 'Nombre del modelo'
+ modelProvider: 'Proveedor del modelo'
+ modelBaseURL: 'URL base'
+ modelAbilities: 'Capacidades del modelo'
+ saveSuccess: 'Guardado exitosamente'
+ saveError: 'Error al guardar:'
+ createSuccess: 'Flujo de trabajo creado exitosamente'
+ createError: 'Error al crear:'
+ modelNameRequired: 'El nombre del modelo no puede estar vacío'
+ modelProviderRequired: 'El proveedor del modelo no puede estar vacío'
+ requestURLRequired: 'La URL de solicitud no puede estar vacía'
+ apiKeyRequired: 'La clave API no puede estar vacía'
+ keyNameRequired: 'El nombre de la clave no puede estar vacío'
+ mustBeValidNumber: 'Debe ser un número válido'
+ mustBeTrueOrFalse: 'Debe ser verdadero o falso'
+ requestURL: 'URL de solicitud'
+ scanURL: 'URL de escaneo de modelos'
+ scanURLPlaceholder: 'Déjalo vacío para usar URL de solicitud + /models'
+ scanURLDescription: 'Ingresa el endpoint real de la lista de modelos cuando el escaneo de modelos no utiliza la misma dirección que la invocación del modelo.'
+ apiKey: 'Clave API'
+ abilities: 'Capacidades'
+ selectModelAbilities: 'Seleccionar capacidades del modelo'
+ visionAbility: 'Capacidad de visión'
+ functionCallAbility: 'Llamada a funciones'
+ extraParameters: 'Parámetros adicionales'
+ addParameter: 'Añadir parámetro'
+ keyName: 'Nombre de la clave'
+ type: 'Tipo'
+ value: 'Valor'
+ string: 'Cadena'
+ number: 'Número'
+ boolean: 'Booleano'
+ object: 'Objeto'
+ invalidJsonObject: 'El valor debe ser un objeto JSON válido'
+ selectModelProvider: 'Seleccionar proveedor del modelo'
+ modelProviderDescription: 'Por favor, introduce el nombre del modelo proporcionado por el proveedor'
+ modelManufacturer: 'Fabricante del modelo'
+ aggregationPlatform: 'Plataforma de agregación'
+ selfDeployed: 'Autoalojado'
+ builtin: 'Integrado'
+ selectModel: 'Seleccionar modelo'
+ testSuccess: 'Prueba exitosa'
+ testError: 'Error de prueba'
+ llmModels: 'LLM'
+ localProvider: 'Local'
+ localProviderDescription: 'Modelos configurados y gestionados localmente'
+ spaceProviderDescription: 'Modelos sincronizados desde tu cuenta de Space'
+ spaceDisabledForLocalAccount: 'Inicia sesión con Space para usar modelos en la nube'
+ syncModels: 'Sincronizar'
+ syncSuccess: 'Sincronización completa: {{created}} creados, {{updated}} actualizados'
+ syncError: 'Error de sincronización: '
+ spaceModelReadOnly: 'Los modelos de Space son de solo lectura'
+ noSpaceModels: 'No hay modelos de Space. Haz clic en Sincronizar para obtener modelos de Space.'
+ noLocalModels: 'No hay modelos locales. Haz clic en Crear para añadir un modelo.'
+ providerCount: '{{count}} proveedores'
+ addModel: 'Añadir modelo'
+ manualAdd: 'Manual'
+ scanAdd: 'Escanear'
+ scanModels: 'Escanear modelos'
+ scanModelsHint: 'Lee los modelos disponibles del proveedor actual y luego elige cuáles agregar.'
+ scannedModels: 'Modelos detectados'
+ scanDebug: 'Información de depuración'
+ searchScannedModels: 'Buscar modelos detectados'
+ noScannedModels: 'Todavía no hay resultados. Pulsa el botón superior para escanear.'
+ noScannedModelsMatch: 'No hay modelos coincidentes'
+ addSelectedModels: 'Agregar seleccionados'
+ addSelectedModelsSuccess: 'Se agregaron {{count}} modelo(s)'
+ selectAll: 'Seleccionar todo'
+ alreadyAdded: 'Ya agregado'
+ addLLMModel: 'Añadir modelo LLM'
+ addEmbeddingModel: 'Añadir modelo Embedding'
+ provider: 'Proveedor'
+ existingProvider: 'Proveedor existente'
+ newProvider: 'Nuevo proveedor'
+ selectProvider: 'Seleccionar proveedor'
+ requester: 'Tipo de proveedor'
+ selectRequester: 'Seleccionar tipo de proveedor'
+ langbotModelsDescription: 'Modelos en la nube impulsados por LangBot Space'
+ credits: 'Créditos'
+ loginToUseModels: 'Inicia sesión con Space para usar modelos en la nube'
+ noModels: 'No hay modelos configurados'
+ langbotModels: 'Modelos LangBot'
+ spaceTrialTooltip: '¡Créditos de prueba gratuitos disponibles! Inicia sesión con Space para acceder a modelos en la nube sin configuración.'
+ unlockModels: 'Inicia sesión para usar'
+ editProvider: 'Editar proveedor'
+ addProvider: 'Añadir proveedor'
+ addProviderHint: 'Añade proveedores para usar modelos de otras fuentes'
+ addProviderHintSimple: 'Añade proveedores para usar modelos'
+ noProviders: 'Aún no hay proveedores'
+ providerName: 'Nombre del proveedor'
+ providerNameRequired: 'El nombre del proveedor es obligatorio'
+ requesterRequired: 'El tipo de proveedor es obligatorio'
+ providerSaved: 'Proveedor guardado'
+ providerCreated: 'Proveedor creado'
+ providerSaveError: 'Error al guardar el proveedor: '
+ providerDeleted: 'Proveedor eliminado'
+ providerDeleteError: 'Error al eliminar el proveedor: '
+ deleteProviderConfirmation: '¿Estás seguro de que deseas eliminar este proveedor?'
+ loadError: 'Error al cargar el flujo de trabajo'
+ chat: 'Chat'
+ embedding: 'Vector'
+ rerank: 'Reordenar'
+ rerankUrlTooltip: 'URL completa para el endpoint de reordenación (ej: https://dashscope.aliyuncs.com/compatible-api/v1/reranks)'
+ rerankPathTooltip: 'Ruta añadida a la URL base (predeterminado: rerank, algunos servicios usan reranks)'
+ modelsCount: '{{count}} modelo(s)'
+ expandModels: 'Expandir'
+ collapseModels: 'Contraer'
+ primary: 'Modelo principal'
+ fallbackList: 'Modelos de respaldo'
+ addFallback: 'Añadir modelo de respaldo'
+ createBot: 'Crear Bot'
+ selectFromSidebar: 'Selecciona una página de plugin en la barra lateral'
+ editBot: 'Editar Bot'
+ getBotListError: 'Error al obtener la lista de Bots: '
+ botName: 'Nombre del Bot'
+ botDescription: 'Descripción del Bot'
+ botNameRequired: 'El nombre del Bot no puede estar vacío'
+ botDescriptionRequired: 'La descripción del Bot no puede estar vacía'
+ adapterRequired: 'El adaptador no puede estar vacío'
+ defaultDescription: 'Un flujo de trabajo'
+ getBotConfigError: 'Error al obtener la configuración del Bot: '
+ platformAdapter: 'Selección de plataforma/adaptador'
+ selectAdapter: 'Seleccionar adaptador'
+ adapterConfig: 'Configuración del adaptador'
+ viewAdapterDocs: 'Ver documentación'
+ bindPipeline: 'Vincular Pipeline'
+ selectPipeline: 'Seleccionar Pipeline'
+ selectBot: 'Seleccionar Bot'
+ botLogTitle: 'Registro del Bot'
+ enableAutoRefresh: 'Activar actualización automática'
+ session: 'Sesión'
+ yesterday: 'Ayer'
+ earlier: 'Anterior'
+ dateFormat: '{{day}}/{{month}}'
+ setBotEnableError: 'Error al establecer el estado de activación del Bot'
+ log: 'Registro'
+ configuration: 'Configuración'
+ logs: 'Registro'
+ basicInfo: '基础Información'
+ basicInfoDescription: '设置Flujo de TrabajoNombre和Descripción'
+ routingConnection: 'Enrutamiento y conexión'
+ routingConnectionDescription: 'Vincula el Pipeline que procesa los mensajes de este Bot'
+ routingRules: 'Reglas de enrutamiento condicional'
+ routingRulesDescription: 'Las reglas se evalúan en orden; la primera coincidencia enruta a su pipeline. Si ninguna coincide, se usa el pipeline predeterminado.'
+ addRoutingRule: 'Agregar regla'
+ ruleTypeLauncherType: 'Tipo de sesión'
+ ruleTypeLauncherId: 'ID de sesión'
+ ruleTypeMessageContent: 'Contenido del mensaje'
+ operatorEq: 'Igual a'
+ operatorNeq: 'No igual a'
+ operatorContains: 'Contiene'
+ operatorNotContains: 'No contiene'
+ operatorStartsWith: 'Comienza con'
+ operatorRegex: 'Regex'
+ operatorHas: 'Tiene'
+ operatorNotHas: 'No tiene'
+ ruleTypeMessageHasElement: 'Elemento del mensaje'
+ ruleValueElementPlaceholder: 'Seleccionar tipo de elemento'
+ elementImage: 'Imagen'
+ elementVoice: 'Voz'
+ elementFile: 'Archivo'
+ elementForward: 'Reenvío'
+ elementFace: 'Emoji'
+ elementAt: '@Mención'
+ elementAtAll: '@Todos'
+ elementQuote: 'Cita'
+ ruleValuePlaceholder: 'Valor de coincidencia'
+ ruleValueLauncherIdPlaceholder: 'ID de grupo o usuario'
+ ruleValueMessagePlaceholder: 'Texto del mensaje'
+ ruleValuePrefixPlaceholder: 'ej. !draw'
+ ruleValueRegexpPlaceholder: 'ej. ^/help'
+ pipelineDiscard: 'Descartar mensaje'
+ sessionTypePerson: 'Chat privado'
+ sessionTypeGroup: 'Chat grupal'
+ adapterConfigDescription: 'Configura el adaptador de plataforma seleccionado'
+ dangerZone: 'Zona peligrosa'
+ deleteBotAction: 'Eliminar este Bot'
+ deleteBotHint: 'Una vez eliminado, toda la configuración asociada se eliminará permanentemente.'
+ webhookUrlCopied: 'URL del Webhook copiada'
+ webhookUrlHint: 'Haz clic en el campo para seleccionar todo, luego presiona Ctrl+C (Mac: Cmd+C) para copiar, o haz clic en el botón'
+ webhookUrlHintEither: 'Usa cualquiera de las dos URL anteriores en la configuración de tu plataforma'
+ webhookSaasHint: 'Webhook requiere un dominio accesible públicamente. LangBot Cloud proporciona un punto de acceso público listo para usar para tu Bot.'
+ webhookSaasLink: 'Más información sobre LangBot Cloud'
+ popular: 'Popular'
+ china: 'China'
+ global: 'Global'
+ protocol: 'Protocolo'
+ logLevel: 'Nivel de registro'
+ allLevels: 'Todos los niveles'
+ selectLevel: 'Seleccionar nivel'
+ levelsSelected: 'niveles seleccionados'
+ viewDetailedLogs: 'Ver registros detallados'
+ viewDetails: 'Ver detalles'
+ collapse: 'Contraer'
+ imagesAttached: 'imagen(es) adjunta(s)'
+ noLogs: '暂无Registro'
+ sessions: 'Sesiones'
+ noSessions: 'No se encontraron sesiones'
+ selectSession: 'Selecciona una sesión para ver los mensajes'
+ noMessages: '暂无Mensaje'
+ messages: 'Mensajes'
+ messageCount: 'Mensajes'
+ loadingSessions: 'Cargando sesiones...'
+ loadingMessages: 'Cargando mensajes...'
+ user: 'Usuario'
+ variables: '监控Variable'
+ platform: 'Plataforma'
+ lastActive: 'Última actividad'
+ refresh: 'Actualizar'
+ active: 'Activo'
+ inactive: 'Inactivo'
+ discarded: 'Descartado'
+ userMessage: 'Usuario'
+ botMessage: 'Bot'
+ createPlugin: 'Crear plugin'
+ editPlugin: 'Editar plugin'
+ installed: 'Instalados'
+ marketplace: 'Tienda'
+ arrange: 'Ordenar plugins'
+ install: 'Instalar'
+ installPlugin: 'Instalar plugin'
+ onlySupportGithub: 'Actualmente solo se admite la instalación desde GitHub'
+ enterGithubLink: 'Introduce el enlace de GitHub del plugin'
+ installing: 'Instalando plugin...'
+ installSuccess: 'Plugin instalado correctamente'
+ installFailed: 'Error en la instalación, por favor inténtalo más tarde'
+ searchPlugin: 'Buscar plugins'
+ sortBy: 'Ordenar por'
+ mostStars: 'Más estrellas'
+ recentlyAdded: 'Añadidos recientemente'
+ recentlyUpdated: 'Actualizados recientemente'
+ noMatchingPlugins: 'No se encontraron plugins coincidentes'
+ getPluginListError: 'Error al obtener la lista de plugins:'
+ noPluginInstalled: 'No hay plugins instalados'
+ pluginConfig: 'Configuración del plugin'
+ pluginSort: 'Orden de plugins'
+ pluginSortDescription: 'El orden de los plugins afecta el orden de procesamiento dentro del mismo evento, arrastra la tarjeta del plugin para ordenar'
+ pluginSortSuccess: 'Orden de plugins aplicado correctamente'
+ pluginSortError: 'Error al ordenar plugins: '
+ pluginNoConfig: 'El plugin no tiene elementos de configuración.'
+ systemDisabled: 'Sistema de plugins desactivado'
+ systemDisabledDesc: 'El sistema de plugins no está activado, por favor modifica la configuración según la documentación'
+ connectionError: 'WebSocket连接Error'
+ connectionErrorDesc: 'Verifica la configuración del sistema de plugins o contacta al administrador.'
+ errorDetails: 'Detalles del error'
+ loadingStatus: 'Comprobando el estado del sistema de plugins...'
+ failedToGetStatus: 'Error al obtener el estado del sistema de plugins'
+ pluginSystemNotReady: 'El sistema de plugins no está listo, no se puede realizar esta operación'
+ debugInfo: 'Información de depuración'
+ debugInfoTitle: 'Información de depuración del plugin'
+ debugUrl: 'URL de depuración'
+ debugKey: 'Clave de depuración'
+ noDebugKey: '(No establecida)'
+ debugKeyDisabled: 'La clave de depuración no está configurada, la depuración del plugin no requiere autenticación'
+ failedToGetDebugInfo: 'Error al obtener la información de depuración'
+ copiedToClipboard: 'Copiado al portapapeles'
+ deleting: 'Eliminando...'
+ deletePlugin: 'Eliminar plugin'
+ saveConfig: 'Guardar configuración'
+ confirmDeletePlugin: '¿Estás seguro de que deseas eliminar el plugin ({{author}}/{{name}})?'
+ deleteDataCheckbox: 'También eliminar la configuración y el almacenamiento persistente del plugin'
+ deleteConfirm: 'ConfirmarEliminar'
+ modifyFailed: 'Error al modificar: '
+ Tool: 'Herramienta'
+ EventListener: 'Listener de eventos'
+ Command: 'Comando'
+ KnowledgeEngine: 'Motor de conocimiento'
+ Parser: 'Analizador'
+ Page: 'Página'
+ uploadLocal: 'Subir local'
+ debugging: 'Depuración'
+ uploadLocalPlugin: 'Subir plugin local'
+ dragToUpload: 'Arrastra el archivo del plugin aquí para subirlo'
+ unsupportedFileType: 'Tipo de archivo no soportado, solo se admiten archivos .lbpkg y .zip'
+ uploadingPlugin: 'Subiendo plugin...'
+ uploadSuccess: '¡Archivo subido correctamente!'
+ uploadFailed: 'Error en la subida'
+ selectFileToUpload: 'Selecciona el archivo del plugin para subir'
+ askConfirm: '¿Estás seguro de instalar el plugin '
+ fromGithub: 'Desde GitHub'
+ fromLocal: 'Desde local'
+ fromMarketplace: 'Desde la tienda'
+ componentsList: 'Componentes: '
+ noComponents: 'Sin componentes'
+ update: 'Actualizar plugin'
+ new: 'Nuevo'
+ updateConfirm: 'Confirmación de actualización'
+ confirmUpdatePlugin: '¿Estás seguro de que deseas actualizar el plugin ({{author}}/{{name}})?'
+ confirmUpdate: 'Confirmar actualización'
+ updating: 'Actualizando...'
+ updateSuccess: 'Actualizado correctamente'
+ updateError: 'Error al actualizar: '
+ saveConfigSuccessNormal: 'Configuración guardada correctamente'
+ saveConfigError: 'Error al guardar la configuración: '
+ config: 'Configuración'
+ readme: 'Documentación'
+ viewSource: 'Ver código fuente'
+ loadingReadme: 'Cargando documentación...'
+ noReadme: 'Este plugin no proporciona documentación README'
+ tooLarge: 'El tamaño del archivo supera el límite de 10MB'
+ failed: 'falló'
+ uploading: 'Subiendo...'
+ chooseFile: 'Elegir archivo'
+ addFile: 'Añadir archivo'
+ installFromGithub: 'Instalar servidor MCP desde GitHub'
+ enterRepoUrl: 'Introduce la URL del repositorio de GitHub'
+ repoUrlPlaceholder: 'p. ej., https://github.com/owner/repo'
+ fetchingReleases: 'Obteniendo versiones...'
+ selectRelease: 'Seleccionar versión'
+ noReleasesFound: 'No se encontraron versiones'
+ fetchReleasesError: 'Error al obtener versiones: '
+ selectAsset: 'Seleccionar archivo a instalar'
+ noAssetsFound: 'No hay archivos .lbpkg disponibles en esta versión'
+ fetchAssetsError: 'Error al obtener archivos: '
+ backToReleases: 'Volver a versiones'
+ backToRepoUrl: 'Volver a la URL del repositorio'
+ backToAssets: 'Volver a archivos'
+ releaseTag: 'Etiqueta: {{tag}}'
+ releaseName: 'Nombre: {{name}}'
+ publishedAt: 'Publicado el: {{date}}'
+ prerelease: 'Pre-lanzamiento'
+ assetSize: 'Tamaño: {{size}}'
+ confirmInstall: 'Confirmar instalación'
+ installFromGithubDesc: 'Instalar plugin desde GitHub Release'
+ goToMarketplace: 'Ir a la tienda'
+ titleGeneric: 'Instalación de Plugin'
+ overallProgress: 'Progreso general'
+ downloading: 'Descargando Plugin'
+ installingDeps: 'Instalando dependencias'
+ initializing: 'Inicializando configuración'
+ launching: 'Iniciando Plugin'
+ completed: '已Completado'
+ downloadSize: 'Tamaño del paquete: {{size}}'
+ depsInfo: '{{count}} dependencias por instalar'
+ depsProgress: '{{installed}}/{{total}} instaladas · {{remaining}} restantes'
+ installComplete: 'Plugin instalado correctamente'
+ dismiss: 'Descartar datos originales'
+ background: 'Ejecutar en segundo plano'
+ taskQueue: 'Tareas de instalación'
+ clearCompleted: 'Limpiar completados'
+ noTasks: 'No hay tareas de instalación'
+ searchPlaceholder: 'Buscar plugins...'
+ searchResults: 'Se encontraron {{count}} plugins'
+ totalPlugins: 'Total {{count}} plugins'
+ noPlugins: 'No hay plugins disponibles'
+ noResults: 'Sin resultados'
+ loadingMore: 'Cargando más...'
+ allLoaded: 'Todos los plugins mostrados'
+ installConfirm: '¿Estás seguro de que deseas instalar el plugin '
+ downloadComplete: 'Descarga del plugin '
+ loadFailed: 'Error al cargar'
+ noDescription: 'No hay descripción disponible'
+ notFound: 'No se encontró la información del plugin'
+ mostDownloads: 'Más descargas'
+ leastDownloads: 'Menos descargas'
+ downloads: 'descargas'
+ download: 'Descargar'
+ repository: 'Repositorio'
+ downloadFailed: 'Error en la descarga'
+ tagLabel: 'Etiquetas'
+ submissionTitle: 'Tienes un envío de plugin en revisión: {{name}}'
+ submissionPending: 'Tu envío de plugin está en revisión: {{name}}'
+ submissionApproved: 'Tu envío de plugin ha sido aprobado: {{name}}'
+ submissionRejected: 'Tu envío de plugin ha sido rechazado: {{name}}'
+ clickToRevoke: 'Revocar'
+ revokeSuccess: 'Revocación exitosa'
+ revokeFailed: 'Error en la revocación'
+ submissionDetails: 'Detalles del envío del plugin'
+ markAsRead: 'Marcar como leído'
+ markAsReadSuccess: 'Marcado como leído'
+ markAsReadFailed: 'Error al marcar como leído'
+ filterByComponent: 'Componente'
+ allComponents: 'Todos los componentes'
+ requestPlugin: 'Solicitar plugin'
+ deprecated: 'Obsoleto'
+ deprecatedTooltip: 'Por favor, instala el plugin de motor de conocimiento correspondiente.'
+ filterByTags: 'Filtrar por etiquetas'
+ selected: 'seleccionadas'
+ selectTags: 'Seleccionar etiquetas'
+ clearAll: 'Borrar todo'
+ noTags: 'No hay etiquetas disponibles'
+ createServer: 'Añadir servidor MCP'
+ editServer: 'Editar servidor MCP'
+ deleteServer: 'Eliminar servidor MCP'
+ confirmDeleteServer: '¿Estás seguro de que deseas eliminar este servidor MCP?'
+ confirmDeleteTitle: 'Eliminar servidor MCP'
+ getServerListError: 'Error al obtener la lista de servidores MCP: '
+ serverName: 'Nombre del servidor'
+ serverMode: 'Modo de conexión'
+ selectMode: 'Seleccionar modo'
+ stdio: 'Modo Stdio'
+ sse: 'Modo SSE'
+ http: 'Modo HTTP'
+ noServerInstalled: 'No hay servidores MCP configurados'
+ serverNameRequired: 'El nombre del servidor no puede estar vacío'
+ commandRequired: 'El comando no puede estar vacío'
+ urlRequired: 'La URL no puede estar vacía'
+ timeoutMustBePositive: 'El tiempo de espera debe ser un número positivo'
+ command: 'Comando'
+ args: 'Argumentos'
+ env: 'Variables de entorno'
+ url: 'Por favor求URL'
+ headers: 'Por favor求头'
+ timeout: 'Tiempo de esperaTiempo'
+ addArgument: 'Añadir argumento'
+ addEnvVar: 'Añadir variable de entorno'
+ addHeader: 'Añadir encabezado'
+ testing: 'Probando...'
+ connecting: 'Conectando...'
+ testFailed: 'Error en la prueba: '
+ refreshSuccess: 'Actualización exitosa'
+ refreshFailed: 'Error en la actualización: '
+ connectionSuccess: 'Conexión exitosa'
+ connectionFailed: 'WebSocket连接falló'
+ connectionFailedStatus: 'Conexión fallida'
+ toolsFound: 'herramientas'
+ unknownError: 'Error desconocido'
+ noToolsFound: 'No se encontraron herramientas'
+ parseResultFailed: 'Error al analizar el resultado de la prueba'
+ noResultReturned: 'La prueba no devolvió resultados'
+ getTaskFailed: 'Error al obtener el estado de la tarea'
+ noTaskId: 'No se obtuvo el ID de la tarea'
+ deleteFailed: 'Error al eliminar: '
+ createFailed: 'Error al crear: '
+ toolCount: '{{count}} herramientas'
+ statusConnected: 'Conectado'
+ statusDisconnected: 'Desconectado'
+ statusError: 'Error de conexión'
+ statusDisabled: 'Desactivado'
+ starCount: 'Estrellas: {{count}}'
+ nameRequired: 'El nombre no puede estar vacío'
+ sseTimeout: 'Tiempo de espera SSE'
+ sseTimeoutDescription: 'Tiempo de espera para establecer la conexión SSE'
+ extraParametersDescription: 'Se adjuntarán al cuerpo de la solicitud, como max_tokens, temperature, top_p, etc.'
+ timeoutMustBeNumber: 'El tiempo de espera debe ser un número'
+ timeoutNonNegative: 'El tiempo de espera no puede ser negativo'
+ sseTimeoutMustBeNumber: 'El tiempo de espera SSE debe ser un número'
+ sseTimeoutNonNegative: 'El tiempo de espera SSE no puede ser negativo'
+ updateFailed: 'Error al actualizar: '
+ deleteMCPAction: 'Eliminar este servidor MCP'
+ deleteMCPHint: 'Una vez eliminada, la configuración de este servidor MCP no se podrá recuperar.'
+ createPipeline: 'Crear Pipeline'
+ editPipeline: 'Editar Pipeline'
+ debugChat: 'ConversaciónDepurar'
+ getPipelineListError: 'Error al obtener la lista de Pipelines: '
+ daysAgo: 'días atrás'
+ today: 'Hoy'
+ updateTime: 'Actualizado '
+ defaultBadge: 'Predeterminado'
+ newestCreated: 'Más recientes'
+ earliestCreated: 'Más antiguos'
+ recentlyEdited: 'Editados recientemente'
+ earliestEdited: 'Editados más antiguos'
+ aiCapabilities: 'IA'
+ triggerConditions: 'Disparador'
+ safetyControls: 'Seguridad'
+ outputProcessing: 'Salida'
+ descriptionRequired: 'La descripción no puede estar vacía'
+ copySuffix: ' Copia'
+ defaultPipelineCannotDelete: 'El Pipeline predeterminado no se puede eliminar'
+ copyConfirmTitle: 'Confirmar copia'
+ copyConfirmation: '¿Estás seguro de que deseas copiar este Pipeline? Esto creará un nuevo Pipeline con todas las configuraciones.'
+ unsavedChanges: '有未Guardarde更改'
+ deletePipelineAction: 'Eliminar este Pipeline'
+ deletePipelineHint: 'Una vez eliminado, los Bots vinculados a este Pipeline dejarán de funcionar.'
+ copyPipelineAction: 'Copiar este Pipeline'
+ copyPipelineHint: 'Crear un nuevo Pipeline con todas las configuraciones duplicadas.'
+ noPluginsAvailable: 'No hay plugins disponibles'
+ disabled: 'Desactivado'
+ noPluginsSelected: 'No hay plugins seleccionados'
+ addPlugin: 'Añadir plugin'
+ selectPlugins: 'Seleccionar plugins'
+ pluginsTitle: 'Plugins'
+ mcpServersTitle: 'Servidores MCP'
+ noMCPServersSelected: 'No hay servidores MCP seleccionados'
+ addMCPServer: 'Añadir servidor MCP'
+ selectMCPServers: 'Seleccionar servidores MCP'
+ noPluginsInstalled: 'No hay plugins instalados'
+ noMCPServersConfigured: 'No hay servidores MCP configurados'
+ enableAllPlugins: 'Activar todos los plugins'
+ enableAllMCPServers: 'Activar todos los servidores MCP'
+ allPluginsEnabled: 'Todos los plugins activados'
+ allMCPServersEnabled: 'Todos los servidores MCP activados'
+ sessionType: '会话Tipo'
+ privateChat: 'Chat privado'
+ groupChat: 'Chat grupal'
+ send: 'Enviar'
+ reset: '重置Conversación'
+ inputPlaceholder: 'Enviar {{type}} Mensaje...'
+ sendFailed: 'Enviarfalló'
+ resetSuccess: 'Conversación已重置'
+ resetFailed: '重置falló'
+ loadMessagesFailed: 'CargarMensajefalló'
+ loadPipelinesFailed: 'Error al cargar Pipelines'
+ atTips: 'Mencionar bot'
+ streaming: 'Transmisión'
+ streamOutput: 'Streaming'
+ connected: 'WebSocket已Conectar'
+ disconnected: 'WebSocket未Conectar'
+ notConnected: 'WebSocket未连接,Por favor稍后Reintentar'
+ reply: 'Responder'
+ replyTo: 'Responder给'
+ showMarkdown: 'Renderizar'
+ showRaw: 'Texto sin formato'
+ allMembers: 'Todos los miembros'
+ file: 'Archivo'
+ voice: 'Voz'
+ uploadImage: 'Subir imagen'
+ detailedLogs: 'Registros detallados'
+ createKnowledgeBase: 'Crear base de conocimiento'
+ editKnowledgeBase: 'Editar base de conocimiento'
+ selectKnowledgeBase: 'Seleccionar base de conocimiento'
+ selectKnowledgeBases: 'Seleccionar bases de conocimiento'
+ addKnowledgeBase: 'Añadir base de conocimiento'
+ noKnowledgeBaseSelected: 'No hay bases de conocimiento seleccionadas'
+ empty: 'Vacío'
+ editDocument: 'Documentos'
+ metadata: 'Metadatos'
+ kbNameRequired: 'El nombre de la base de conocimiento no puede estar vacío'
+ kbDescriptionRequired: 'La descripción de la base de conocimiento no puede estar vacía'
+ embeddingModelUUIDRequired: 'El modelo Embedding no puede estar vacío'
+ kbName: 'Nombre de la base de conocimiento'
+ kbDescription: 'Descripción de la base de conocimiento'
+ topK: 'Top K'
+ topKRequired: 'Top K no puede estar vacío'
+ topKMax: 'El valor máximo de Top K es 30'
+ topKdescription: 'Se usa para especificar el número de documentos relevantes a recuperar, con un rango de 1 a 30.'
+ embeddingModelUUID: 'Modelo Embedding'
+ selectEmbeddingModel: 'Seleccionar modelo Embedding'
+ embeddingModelDescription: 'Se usa para vectorizar el texto, puedes configurarlo en la página de Modelos'
+ cannotChangeEmbeddingModel: 'El modelo Embedding no se puede modificar una vez creada la base de conocimiento'
+ updateKnowledgeBaseSuccess: 'Base de conocimiento actualizada correctamente'
+ updateKnowledgeBaseFailed: 'Error al actualizar la base de conocimiento: '
+ status: 'Estado'
+ dragAndDrop: 'Arrastra y suelta archivos aquí o haz clic para subir'
+ supportedFormats: 'Admite PDF, Word, TXT, Markdown, HTML, ZIP y otros formatos de documentos'
+ uploadError: 'Error al subir el archivo: '
+ uploadingFile: 'Subiendo archivo...'
+ fileSizeExceeded: 'El tamaño del archivo supera el límite de 10MB. Por favor, divídelo en archivos más pequeños.'
+ fileDeleteSuccess: 'Archivo eliminado correctamente'
+ fileDeleteFailed: 'Error al eliminar el archivo: '
+ processing: 'Procesando'
+ selectParser: 'Seleccionar analizador'
+ builtInParser: 'Proporcionado por el motor de conocimiento'
+ noParserAvailable: 'Ningún analizador admite este tipo de archivo. Por favor, instala un plugin de analizador que pueda manejar este formato.'
+ installParserHint: 'Buscar plugins de analizador en el Marketplace →'
+ confirmUpload: 'Subir'
+ cancelUpload: 'Cancelar'
+ deleteKnowledgeBaseConfirmation: '¿Estás seguro de que deseas eliminar esta base de conocimiento? Todos los documentos de esta base se eliminarán.'
+ retrieve: 'Prueba de recuperación'
+ retrieveTest: 'Prueba de recuperación'
+ query: 'SQLConsulta'
+ queryPlaceholder: 'Introduce el texto de consulta...'
+ distance: 'Distancia'
+ content: 'Contenido'
+ fileName: 'Nombre del archivo'
+ retrieveError: 'Error en la recuperación: '
+ unknownEngine: 'Motor desconocido'
+ knowledgeEngine: 'Motor de conocimiento'
+ knowledgeEngineRequired: 'El motor de conocimiento es obligatorio'
+ selectKnowledgeEngine: 'Seleccionar motor de conocimiento'
+ builtInEngine: 'Motor integrado'
+ cannotChangeKnowledgeEngine: 'El motor de conocimiento no se puede cambiar después de la creación'
+ engineSettings: 'Configuración del motor'
+ engineSettingsDescription: 'Configuración del motor de conocimiento seleccionado'
+ engineSettingsReadonly: 'solo lectura en modo de edición'
+ retrievalSettings: 'Configuración de recuperación'
+ retrievalSettingsDescription: 'Configura cómo se recuperan los documentos de esta base de conocimiento'
+ deleteKbAction: 'Eliminar esta base de conocimiento'
+ deleteKbHint: 'Una vez eliminada, todos los documentos y datos de esta base de conocimiento se eliminarán permanentemente.'
+ noEnginesAvailable: 'No hay motores de base de conocimiento disponibles'
+ installEngineHint: 'Por favor, instala primero un plugin de '
+ createKnowledgeBaseFailed: 'Error al crear la base de conocimiento: '
+ loadKnowledgeBaseFailed: 'Error al cargar la base de conocimiento: '
+ deleteKnowledgeBaseFailed: 'Error al eliminar la base de conocimiento: '
+ getKnowledgeBaseListError: 'Error al obtener la lista de bases de conocimiento: '
+ embeddingModel: 'Embedding'
+ embeddingModelRequired: 'El modelo Embedding es obligatorio para este motor'
+ addExternal: 'Añadir base de conocimiento externa'
+ createExternalSuccess: 'Base de conocimiento externa creada correctamente'
+ updateExternalSuccess: 'Base de conocimiento externa actualizada correctamente'
+ deleteExternalSuccess: 'Base de conocimiento externa eliminada correctamente'
+ retriever: 'Recuperador'
+ selectRetriever: 'Seleccionar un recuperador...'
+ retrieverConfiguration: 'Configuración del recuperador'
+ retrieverInstallInfo: 'Puedes instalar plugins de recuperador de conocimiento desde'
+ retrieverMarketLink: 'aquí'
+ detected: 'Se encontraron {{total}} base(s) de conocimiento para migrar ({{internal}} internas, {{external}} externas).'
+ startWithInstall: 'Auto-instalar plugin y migrar'
+ startDataOnly: 'Migrar solo datos'
+ running: 'Ejecutar中'
+ dismissError: 'Error en la operación'
+ adminAccountNote: 'La cuenta que uses aquí se establecerá como cuenta de administrador'
+ register: 'Registrarse'
+ initWithSpace: 'Inicializar con Space'
+ spaceRecommended: 'Recomendado: Usa API de modelos oficiales estables y servicios en la nube'
+ spaceInfoTip1: 'Space proporciona servicios de autenticación unificada de cuentas sin subir ninguna de tu información sensible.'
+ spaceInfoTip2: 'Iniciar sesión con una cuenta de Space te da acceso a los modelos de LangBot y otros servicios en la nube, incluyendo créditos gratuitos de llamadas a modelos para ayudarte a comenzar rápidamente.'
+ spaceInfoTip3: 'Tu método de inicio de sesión no afecta otras funciones. Puedes configurar y usar modelos de otras fuentes en cualquier momento.'
+ registerLocal: 'Registrar cuenta local'
+ registerWithPassword: 'Registrarse con correo electrónico y contraseña'
+ initSuccess: 'Inicialización exitosa, por favor inicia sesión'
+ initFailed: 'Error en la inicialización: '
+ recoveryKey: 'Clave de recuperación'
+ recoveryKeyDescription: 'Almacenada en `system.recovery_key` del archivo de configuración `data/config.yaml`'
+ enterRecoveryKey: 'Introduce la clave de recuperación'
+ recoveryKeyRequired: 'La clave de recuperación no puede estar vacía'
+ resetPassword: 'Restablecer contraseña'
+ resetting: 'Restableciendo...'
+ embeddingModels: 'Embedding'
+ newVersionAvailable: 'Nueva versión disponible'
+ viewUpdateGuide: 'Ver guía de actualización'
+ noReleaseNotes: 'No hay notas de la versión disponibles'
+ settings: 'Configuración de la cuenta'
+ setPassword: 'Establecer contraseña'
+ passwordSetSuccess: 'Contraseña establecida correctamente'
+ passwordStatus: 'Contraseña local'
+ passwordSet: 'Establecida'
+ passwordNotSet: 'No establecida'
+ passwordSetDescription: 'La contraseña está establecida, puedes iniciar sesión con correo y contraseña'
+ spaceStatus: 'Cuenta de Space'
+ spaceBound: 'Vinculada'
+ spaceNotBound: 'No vinculada'
+ spaceBoundDescription: 'Cuenta de Space vinculada, API de modelos oficiales y servicios en la nube disponibles'
+ bindSpace: 'Vincular cuenta de Space'
+ bindSpaceDescription: 'Vincular para usar API de modelos oficiales y servicios en la nube'
+ bindSpaceButton: 'Vincular'
+ bindSpaceConfirmTitle: 'Confirmar vinculación'
+ bindSpaceConfirmDescription: 'Estás a punto de vincular tu instancia local a una cuenta de Space'
+ bindSpaceWarning: 'Después de vincular, tu correo de inicio de sesión se cambiará de {{localEmail}} al correo de la cuenta de Space.'
+ bindSpaceSuccess: 'Cuenta de Space vinculada correctamente'
+ bindSpaceFailed: 'Error al vincular la cuenta de Space'
+ bindSpaceInvalidState: 'Solicitud de vinculación no válida. Por favor, inténtalo de nuevo desde la configuración de la cuenta.'
+ setPasswordHint: 'Establece una contraseña para iniciar sesión con correo y contraseña'
+ spaceEmailMismatch: 'El correo de inicio de sesión de Space no coincide con el correo de la cuenta local'
+ overview: 'Resumen'
+ totalMessages: 'Total de mensajes'
+ llmCallsCount: 'Llamadas LLM'
+ modelCallsCount: 'Llamadas a modelos'
+ successRate: 'Éxito率'
+ activeSessions: 'Sesiones activas'
+ last24Hours: 'Últimas 24 horas'
+ bot: 'Bot'
+ pipeline: 'Pipeline'
+ allBots: 'Todos los Bots'
+ allPipelines: 'Todos los Pipelines'
+ timeRange: 'Rango de tiempo'
+ customRange: 'Rango personalizado'
+ from: 'Desde'
+ to: 'Hasta'
+ apply: 'Aplicar'
+ lastHour: 'Última hora'
+ last6Hours: 'Últimas 6 horas'
+ last7Days: 'Últimos 7 días'
+ last30Days: 'Últimos 30 días'
+ llmCalls: 'Llamadas LLM'
+ embeddingCalls: 'Llamadas Embedding'
+ modelCalls: 'Llamadas a modelos'
+ feedback: 'Comentarios de usuarios'
+ errors: 'ErrorLista'
+ timestamp: 'Tiempo戳'
+ message: 'Mensaje'
+ sessionId: 'Sesión ID'
+ copyId: 'Copiar ID'
+ noMessagesDescription: 'Intenta ajustar los filtros o vuelve a comprobarlo más tarde'
+ loadMore: 'Cargar más'
+ autoRefresh: 'Actualización automática'
+ level: 'Nivel'
+ runner: 'Ejecutor'
+ viewConversation: 'Ver conversación'
+ model: 'Modelo'
+ tokens: 'Tokens'
+ duration: 'Esperar时间'
+ cost: 'Coste'
+ noData: 'No hay datos de tráfico disponibles'
+ inputTokens: 'Tokens de entrada'
+ outputTokens: 'Tokens de salida'
+ totalTokens: 'Tokens totales'
+ avgDuration: 'Duración promedio'
+ calls: 'Llamadas'
+ promptTokens: 'Tokens de prompt'
+ inputCount: 'Cantidad de entradas'
+ knowledgeBase: 'Base de conocimiento'
+ queryText: 'Consulta'
+ llmModel: 'LLM'
+ embeddingCall: 'Embedding'
+ retrieveCall: 'Recuperación'
+ lastActivity: 'Última actividad'
+ startTime: 'Inicio时间'
+ messageStats: 'Estadísticas de mensajes'
+ successMessages: 'Exitosos'
+ errorMessages: 'Fallidos'
+ llmStats: 'Estadísticas LLM'
+ errorType: 'Tipo de error'
+ errorMessage: 'Mensaje de error'
+ occurredAt: 'Ocurrido el'
+ noErrors: 'No se encontraron errores'
+ stackTrace: 'Traza de pila'
+ totalFeedback: 'Total de comentarios'
+ totalLikes: 'Me gusta'
+ totalDislikes: 'No me gusta'
+ satisfactionRate: 'Tasa de satisfacción'
+ like: 'Me gusta'
+ dislike: 'No me gusta'
+ noFeedback: 'Aún no hay comentarios'
+ noFeedbackDescription: 'Los comentarios de los usuarios aparecerán aquí'
+ feedbackList: 'Lista de comentarios'
+ feedbackContent: 'Contenido del comentario'
+ contextInfo: 'Información de contexto'
+ userId: 'ID de usuario'
+ messageId: 'ID de mensaje'
+ streamId: 'ID de consulta relacionada'
+ inaccurateReasons: 'Razones de inexactitud'
+ exportFeedback: 'Exportar comentarios'
+ viewMonitoring: 'Ver monitoreo'
+ refreshData: 'Actualizar datos'
+ exportData: 'Exportar datos'
+ exporting: 'Exportando...'
+ openDialog: 'Ver análisis'
+ dialogTitle: 'Análisis de almacenamiento'
+ generatedAt: 'Generado el {{time}}'
+ totalSize: 'Tamaño total'
+ binaryStorage: 'Almacenamiento binario de plugins'
+ uploadCleanup: 'Subidas caducadas'
+ logCleanup: 'Registros caducados'
+ sections: 'Secciones de almacenamiento'
+ monitoringTables: 'Tablas de monitoreo'
+ runtimeTasks: 'Tareas en ejecución'
+ cleanupPolicy: 'Política de limpieza'
+ uploadRetention: 'Retención de subidas'
+ logRetention: 'Retención de registros'
+ databaseType: 'Tipo de base de datos'
+ days: 'días'
+ missing: '缺失Parámetro'
+ expiredUploads: 'Subidas caducadas'
+ expiredLogs: 'Registros caducados'
+ noExpiredUploads: 'No hay archivos subidos caducados'
+ noExpiredLogs: 'No hay registros caducados'
+ database: 'Base de datos'
+ storage: 'Archivos subidos'
+ vector_store: 'Almacén vectorial'
+ plugins: 'Plugins'
+ mcp: 'MCP'
+ temp: 'Archivos temporales'
+ maxBotsReached: 'Se ha alcanzado el número máximo de Bots ({{max}}). Por favor, elimina un Bot existente antes de crear uno nuevo.'
+ maxPipelinesReached: 'Se ha alcanzado el número máximo de Pipelines ({{max}}). Por favor, elimina un Pipeline existente antes de crear uno nuevo.'
+ maxExtensionsReached: 'Se ha alcanzado el número máximo de extensiones ({{max}}). Por favor, elimina un servidor MCP o plugin existente antes de añadir uno nuevo.'
+ sidebarDescription: 'Crea un Bot con pasos guiados'
+ skip: 'Omitir'
+ skipConfirmMessage: 'Puedes volver a acceder al asistente de inicio rápido desde el menú de cuenta más tarde, o crear un Bot manualmente.'
+ skipConfirmOk: 'Aceptar'
+ prev: 'Anterior'
+ next: 'Siguiente'
+ finish: 'Crear y desplegar'
+ confirmCreateBot: 'Confirmar, crear Bot'
+ botCreateSuccess: '¡Bot creado correctamente!'
+ botSaveSuccess: '¡Configuración del Bot guardada y activada!'
+ spaceAuthError: 'Error al iniciar la autorización de Space'
+ skipSaveError: 'Error al guardar el estado de omisión. Por favor, inténtalo de nuevo.'
+ completeSaveError: 'Error al guardar el estado de finalización. Por favor, inténtalo de nuevo.'
+ botConfig: 'Configuración del Bot'
+ aiEngine: 'Motor de IA'
+ done: 'Listo'
+ saveBot: 'Guardar y activar Bot'
+ resaveBot: 'Volver a guardar configuración'
+ botSaved: 'Configuración del Bot guardada y activada. Consulta los registros para verificar la conexión.'
+ logsTitle: 'Registros del Bot'
+ logsDescription: 'Monitorea la actividad del Bot para verificar que la conexión con la plataforma funcione.'
+ action: 'Acción'
+ botInfo: 'Información del Bot'
+ botNamePlaceholder: 'Introduce el nombre del Bot'
+ botDescPlaceholder: 'Introduce la descripción del Bot (opcional)'
+ platformConfig: 'Configuración de {{platform}}'
+ aiConfig: 'Configuración de {{engine}}'
+ backToWorkbench: 'Volver al panel de trabajo'
+ createWorkflow: 'Crear Flujo de Trabajo'
+ editWorkflow: 'Editar Flujo de Trabajo'
+ newWorkflow: 'Nuevo Flujo de Trabajo'
+ getWorkflowListError: 'Error al obtener la lista de flujos de trabajo:'
+ workflowName: 'Nombre del Flujo de Trabajo'
+ workflowDescription: 'Descripción del Flujo de Trabajo'
+ workflowNameRequired: 'El nombre del flujo de trabajo no puede estar vacío'
+ getWorkflowError: 'Error al obtener el flujo de trabajo:'
+ copyError: '复制falló:'
+ export: 'Exportar'
+ import: 'Importar'
+ exportSuccess: 'Flujo de Trabajo已导出'
+ importSuccess: 'Flujo de Trabajo已导入'
+ publish: 'Publicar'
+ publishSuccess: '发布Éxito'
+ publishError: '发布falló'
+ executions: 'Ejecutar记录'
+ editor: 'Editar器'
+ basicInfoDesc: '设置Flujo de TrabajoNombre、图标和Descripción'
+ deleteWorkflowAction: 'Eliminar此Flujo de Trabajo'
+ deleteWorkflowHint: 'Eliminar后,所有关联Configuración将被永久移除,且无法恢复。'
+ deleteWorkflow: 'EliminarFlujo de Trabajo'
+ deleteConfirmDesc: '您确定要EliminarFlujo de Trabajo '
+ namePlaceholder: 'EntradaFlujo de TrabajoNombre'
+ descriptionPlaceholder: 'EntradaFlujo de TrabajoDescripción(可选)'
+ enabled: 'Habilitar'
+ enabledDesc: '启用后,Flujo de Trabajo将可以被触发Ejecutar'
+ info: 'Flujo de TrabajoInformación'
+ uuid: 'UUID'
+ version: 'Versión'
+ createdAt: 'Crear时间'
+ updatedAt: '更新Tiempo'
+ totalExecutions: '共 {{count}} 条Ejecutar记录'
+ statistics: 'Estadísticas分析'
+ successfulCount: 'Éxito {{count}} 次'
+ averageDuration: 'Duración promedio'
+ perExecution: '每次Ejecutar'
+ failedExecutions: 'falló次数'
+ lastExecution: '最后Ejecutar'
+ filterByStatus: '按Estado筛选'
+ allStatuses: '全部Estado'
+ manualTrigger: 'Activación manual'
+ executionId: 'Ejecutar ID'
+ triggerType: '触发Tipo'
+ startedAt: 'Inicio时间'
+ noExecutions: '暂无Ejecutar记录'
+ executionDetails: 'Ejecutar详情'
+ nodeExecutions: 'NodoEjecutar记录'
+ result: 'Resultado'
+ nodePalette: 'Nodo面板'
+ properties: 'Propiedades'
+ zoomIn: 'Acercar'
+ zoomOut: 'Alejar'
+ fitView: 'Ajustar vista'
+ paste: 'Pegar'
+ deleted: '已Eliminar'
+ nothingToPaste: '剪贴板estar vacío'
+ copied: '已复制 {{count}} 个Nodo'
+ pasted: '已粘贴 {{count}} 个Nodo'
+ nodesSelected: '已选中 {{count}} 个Nodo'
+ edgesSelected: 'Seleccionado {{count}} conexiones'
+ searchNodes: '搜索Nodo...'
+ loadingNodeTypes: '正在CargarNodoTipo...'
+ noNodesFound: '未找到匹配deNodo'
+ clearSearch: '清除Buscar'
+ dragToAdd: '拖拽Nodo到画布Agregar'
+ selectNodeOrEdge: 'SeleccionarunNodo或连线'
+ edgeProperties: 'Propiedades de conexión'
+ nodeProperties: 'Nodo属性'
+ condition: 'Condición分支'
+ hasCondition: '已Configuración'
+ conditionPlaceholder: 'EntradaCondición表达式,如: output.success == true'
+ conditionHelp: 'Condiciónestar vacío时,该连线将始终被Ejecutar。支持使用 {{Variable名}} 引用上下文Variable。'
+ deleteEdge: 'Eliminar连线'
+ deleteEdgeConfirm: '确定Eliminar此连线?'
+ nodeLabel: 'NodoNombre'
+ nodeLabelPlaceholder: 'EntradaNodo显示Nombre'
+ nodeId: 'Nodo ID'
+ inputOutputVariables: 'Entrada/SalidaVariable'
+ inputs: 'Entrada'
+ outputs: 'Salida'
+ availableVariables: '可用Variable'
+ globalVariables: '全局Variable'
+ messageContent: '模拟Mensaje'
+ messageSender: 'Enviar者'
+ nodeConfig: 'NodoConfiguración'
+ noConfigOptions: '该NodoTipo暂无Configuración选项'
+ deleteNode: 'EliminarNodo'
+ deleteNodeConfirm: '确定Eliminar此Nodo?'
+ input: 'Entrada'
+ text: 'Texto'
+ data: 'Datos'
+ context: 'Depurar上下文'
+ body: 'Por favor求体'
+ items: '项目Lista'
+ arguments: 'Parámetro'
+ question: 'UsuarioPregunta'
+ parameters: 'Parámetro定义'
+ key: 'Clave'
+ case_1: 'Rama 1'
+ case_2: 'Rama 2'
+ branch_1: '分支1Salida'
+ branch_2: '分支2Salida'
+ notification_id: 'NotificaciónID'
+ key_template: 'Plantilla de clave'
+ hash_field: '哈希Campo'
+ server_name: '服务器Nombre'
+ tool_name: '工具Nombre'
+ arguments_template: 'Parámetro模板'
+ scope: 'Ámbito'
+ payload: 'Carga útil'
+ input_value: 'EntradaValor'
+ trigger: 'Disparador'
+ triggerDescription: 'Flujo de Trabajode起始Nodo'
+ messageTrigger: 'Mensaje触发'
+ messageTriggerDescription: '当收到Mensaje时触发'
+ scheduleTrigger: 'Programado触发'
+ scheduleTriggerDescription: '按计划Programado触发'
+ cronTrigger: 'Programado触发'
+ cronTriggerDescription: '按定时计划触发Flujo de Trabajo'
+ webhookTrigger: 'Activador Webhook'
+ webhookTriggerDescription: '通过 HTTP Por favor求触发'
+ eventTrigger: 'Evento触发'
+ process: 'IA/Procesamiento'
+ aiProcess: 'Procesamiento IA'
+ aiProcessDescription: '使用 AI 模型处理Mensaje'
+ llmCall: 'LLM Llamar'
+ codeProcess: 'Código处理'
+ codeProcessDescription: 'Ejecutar自定义代码'
+ codeExecutor: '代码Ejecutar'
+ codeExecutorDescription: 'Ejecutar Python/JavaScript 代码'
+ templateProcess: 'Procesamiento de plantilla'
+ httpRequest: 'HTTP Por favor求'
+ httpRequestDescription: 'Enviar HTTP Por favor求'
+ dataTransform: 'DatosTransformar'
+ dataTransformDescription: 'TransformarDatosFormato'
+ questionClassifier: 'PreguntaClasificador'
+ questionClassifierDescription: '使用 LLM 将UsuarioPreguntaCategoría到预定义类别'
+ parameterExtractor: 'Parámetro提取器'
+ parameterExtractorDescription: '使用 LLM desde文本中提取结构化Parámetro'
+ knowledgeRetrieval: 'Conocimiento库Recuperación'
+ textTemplate: 'Texto模板'
+ jsonTransform: 'JSON Transformar'
+ jsonTransformDescription: 'Transformar JSON Datos'
+ dataAggregator: 'Datos聚合'
+ textSplitter: 'Texto分割'
+ variableAssignment: 'Variable赋Valor'
+ variableAssignmentDescription: '为Flujo de TrabajoVariable赋Valor'
+ control: 'Flujo de control'
+ conditionDescription: '根据Condición分流'
+ switch: '多路Rama'
+ switchDescription: '多Condición分支Seleccionar'
+ loop: 'Bucle'
+ loopDescription: '重复Ejecutar'
+ iterator: 'Iterador'
+ parallel: 'Paralelo处理'
+ parallelDescription: '并行Ejecutar多个分支'
+ wait: 'Esperar'
+ waitDescription: 'Esperar指定时间'
+ delay: 'Retraso'
+ delayDescription: 'Esperar指定时间'
+ merge: 'Fusionar'
+ mergeDescription: 'Fusionar多个Rama'
+ variableAggregator: 'Variable聚合器'
+ variableAggregatorDescription: '聚合多个分支deVariableSalida'
+ actionDescription: 'Ejecutar动作deNodo'
+ sendMessage: 'EnviarMensaje'
+ sendMessageDescription: 'EnviarMensaje到平台'
+ replyMessage: '回复Mensaje'
+ replyMessageDescription: '回复触发Flujo de TrabajodeMensaje'
+ storeData: 'AlmacenarDatos'
+ storeDataDescription: 'AlmacenarDatos到Base de Datos'
+ callPipeline: 'Llamar Pipeline'
+ callPipelineDescription: '调用现有de Pipeline'
+ setVariable: '设置Variable'
+ setVariableDescription: '设置上下文Variable'
+ openingStatement: 'Conversación开场白'
+ end: 'Fin'
+ endDescription: '标记Flujo de TrabajoEjecutarFin'
+ logDescription: '记录日志Información'
+ difyWorkflow: 'Dify Flujo de Trabajo'
+ difyWorkflowDescription: '调用 Dify 平台Flujo de Trabajo'
+ difyKnowledgeQuery: 'Dify Conocimiento库'
+ difyKnowledgeQueryDescription: 'Consulta Dify Conocimiento库'
+ n8nWorkflow: 'n8n Flujo de Trabajo'
+ n8nWorkflowDescription: '调用 n8n Flujo de Trabajo'
+ langflowFlow: 'Flujo Langflow'
+ langflowFlowDescription: 'Llamar Langflow 流程'
+ cozeBot: 'Bot Coze'
+ cozeBotDescription: 'Llamar扣子 Bot'
+ databaseQuery: 'Base de DatosConsulta'
+ databaseQueryDescription: 'Ejecutar数据库查询'
+ redisOperation: 'Redis Operación'
+ redisOperationDescription: 'Ejecutar Redis 缓存操作'
+ mcpTool: 'MCP Herramienta'
+ mcpToolDescription: 'Llamar MCP Herramienta'
+ memoryStore: '记忆Almacenar'
+ memoryStoreDescription: 'desdeFlujo de Trabajo记忆中存储和检索数据'
+ cancelled: 'Cancelado'
+ nodeResults: 'NodoEjecutar结果'
+ current: '当前Versión'
+ rollback: '回滚到此Versión'
+ rollbackSuccess: '回滚Éxito'
+ rollbackError: '回滚falló:'
+ debug: 'Debug'
+ debugMode: 'Depurar模式'
+ debugPanel: 'Depurar面板'
+ startDebug: 'InicioDepurar'
+ pauseDebug: 'Pausar'
+ resumeDebug: 'Continuar'
+ stepDebug: '单步Ejecutar'
+ stopDebug: 'Detener'
+ debugContext: 'Depurar上下文'
+ simulatedMessage: '模拟Mensaje'
+ simulatedMessagePlaceholder: 'Entrada要模拟deMensaje内容'
+ senderId: 'Enviar者 ID'
+ senderIdPlaceholder: 'Enviar者唯一标识'
+ senderName: 'Enviar者Nombre'
+ senderNamePlaceholder: 'Enviar者显示Nombre'
+ conversationId: 'Sesión ID'
+ isGroup: 'Chat grupal'
+ customVariables: '自定义Variable'
+ addVariable: 'AgregarVariable'
+ variableName: 'Variable名'
+ variableValue: 'VariableValor'
+ watchedVariables: '监控Variable'
+ addWatchVariable: 'Agregar监控'
+ nodeStates: 'Nodo状态'
+ breakpoints: 'Puntos de interrupción'
+ toggleBreakpoint: 'Cambiar断点'
+ breakpointSet: '断点已Configuración'
+ breakpointRemoved: 'Punto de interrupción eliminado'
+ debugLogs: 'Depurar日志'
+ clearLogs: '清空Registro'
+ autoScroll: 'Desplazamiento automático'
+ idle: 'Inactivo'
+ paused: '已Pausar'
+ pending: 'Esperar中'
+ skipped: '已Omitir'
+ selectWorkflow: 'SeleccionarFlujo de Trabajo'
+ loadWorkflowsFailed: 'CargarFlujo de Trabajofalló'
+ filterByDate: '按Fecha筛选'
+ allTime: '全部Tiempo'
+ lastWeek: 'Última semana'
+ showingExecutions: 'Mostrando {{shown}} / {{total}} registros'
+ rerun: '重新Ejecutar'
+ rerunExecution: '重新Ejecutar'
+ details: 'Detalles'
+ completedAt: 'Completado时间'
+ noNodeExecutions: '暂无NodoEjecutar记录'
+ conditions: '触发Condición'
+ keyword_filter: '关键词Filtrar'
+ regex_filter: 'Expresión RegularFiltrar'
+ min_length: 'MínimoLongitud'
+ max_length: 'MáximoLongitud'
+ require_mention: 'Requiere mencionar bot'
+ respond_rules: '群Respuesta规则'
+ access_control: 'Control de acceso'
+ cron: 'Expresión Cron'
+ timezone: 'Zona horaria'
+ path: 'Ruta Webhook'
+ allowed_methods: '允许deHTTP方法'
+ content_type: 'Content-Type'
+ auth_type: 'Tipo de autenticación'
+ auth_key: 'Clave de autenticación'
+ validation: 'Por favor求验证'
+ event_types: '事件Tipo'
+ filter: 'EventoFiltrar'
+ debounce_ms: '防抖Tiempo'
+ prompt_template: 'Aviso词模板'
+ system_prompt: '系统Aviso词'
+ temperature: 'Temperatura'
+ top_p: 'Top P'
+ frequency_penalty: 'Penalización de frecuencia'
+ presence_penalty: 'Penalización de presencia'
+ max_tokens: 'MáximoToken数'
+ stop_sequences: 'Detener序列'
+ seed: 'Semilla aleatoria'
+ stream: '流式Salida'
+ use_conversation_history: '使用Conversación历史'
+ code: 'Código'
+ method: 'Por favor求方法'
+ auth_config: '认证Configuración'
+ transform_type: '转换Tipo'
+ template: 'Plantilla'
+ expression: 'Expresión'
+ output_type: 'SalidaTipo'
+ categories: 'Categoría类别'
+ instruction: 'Instrucción'
+ knowledge_bases: 'Conocimiento库'
+ top_k: 'Cantidad a devolver'
+ score_threshold: '相似度阈Valor'
+ search_method: 'Buscar方法'
+ enable_citations: 'Habilitar引用'
+ condition_type: 'CondiciónTipo'
+ condition_expression: 'Condición表达式'
+ left_value: '左Valor'
+ operator: 'Operador de comparación'
+ right_value: '右Valor'
+ cases: '分支Condición'
+ break_condition: '中断Condición'
+ max_concurrency: 'Máximo并发数'
+ branches: '分支Configuración'
+ wait_all: 'Esperar所有'
+ fail_fast: '快速falló'
+ duration_type: 'Tiempo单位'
+ merge_strategy: 'Fusionar策略'
+ variable_mappings: 'Variable映射'
+ aggregation_mode: 'Modo de agregación'
+ target_type: '目标Tipo'
+ target_id: 'ID de destino'
+ message_type: 'MensajeTipo'
+ reply_mode: 'Responder模式'
+ message_template: 'Mensaje模板'
+ long_text_processing: '长Texto处理'
+ force_delay: '强制Retraso'
+ pipeline_uuid: 'Pipeline'
+ inherit_context: '继承Contexto'
+ storage_type: '存储Tipo'
+ ttl: '过期Tiempo'
+ key_prefix: 'Clave前缀'
+ variable_name: 'VariableNombre'
+ variable_scope: 'Variable作用域'
+ set_variable_operation: '操作Tipo'
+ statement: 'Declaración de Apertura'
+ suggested_questions: '建议Pregunta'
+ show_suggestions: 'Mostrar sugerencias'
+ output_format: 'Salida格式'
+ success_message: 'ÉxitoMensaje'
+ connection_type: '数据库Tipo'
+ connection_string: 'Conectar字符串'
+ query_type: '查询Tipo'
+ connection_url: 'ConectarURL'
+ operation: '操作Tipo'
+ dataset_id: 'Conocimiento库ID'
+ sender_id: 'Enviar者 ID'
+ sender_name: 'Enviar者Nombre'
+ conversation_id: 'Sesión ID'
+ is_group: 'Es chat grupal'
+ schedule: 'Programación'
+ cron_timestamp: 'Cron Tiempo戳'
+ cron_schedule: 'Cron Expresión de programación'
+ cron_context: 'Cron Contexto'
+ webhook_body: 'Webhook Por favor求体'
+ webhook_headers: 'Webhook Por favor求头'
+ webhook_query: 'Webhook 查询Parámetro'
+ webhook_method: 'Webhook Por favor求方法'
+ event_type: '事件Tipo'
+ event_data: 'EventoDatos'
+ event_timestamp: 'EventoTiempo戳'
+ response: 'Respuesta'
+ usage: 'Token使用Estadísticas'
+ prompt: 'Aviso/问题'
+ context_info: '上下文Información'
+ output: 'Salida'
+ console: '控制台Salida'
+ code_input: '代码Entrada'
+ code_output: '代码Salida'
+ status_code: 'Estado码'
+ http_body: 'HTTP Por favor求体'
+ http_headers: 'HTTP Por favor求头'
+ http_response: 'HTTP Respuesta'
+ response_headers: 'Respuesta头'
+ transform_input: '转换Entrada'
+ transform_result: 'Transformar结果'
+ category: 'Categoría结果'
+ confidence: 'Confianza'
+ all_scores: 'Todas las puntuaciones'
+ extraction_success: '提取Éxito'
+ extract_text: 'Entrada文本'
+ citations: '引用Información'
+ knowledge_context: 'Fusionar上下文'
+ knowledge_query: 'RecuperaciónConsulta'
+ true: 'Condición为真Salida'
+ false: 'Condición为假Salida'
+ condition_input: 'CondiciónEntrada'
+ matched_case: '匹配Rama'
+ default: 'PredeterminadoRama'
+ switch_input: '开关Entrada'
+ item: 'Elemento actual'
+ index: 'Índice actual'
+ results: 'Resultado'
+ loop_items: 'Elementos de iteración'
+ iterator_array: 'Entrada数组'
+ iterator_item: 'Elemento actual'
+ iterator_index: 'Índice actual'
+ parallel_input: '并行Entrada'
+ wait_input: '透传Entrada'
+ wait_output: '透传Salida'
+ merged: 'Fusionar结果'
+ merge_array: '数Grupo结果'
+ merge_input_1: 'Entrada 1'
+ merge_input_2: 'Entrada 2'
+ merge_input_3: 'Entrada 3'
+ merge_input_4: 'Entrada 4'
+ aggregated: '聚合Variable'
+ aggregator_variables: 'VariableEntrada'
+ message_id: 'MensajeID'
+ target: 'ID de destino'
+ reply_message: 'Responder内容'
+ pipeline_response: 'Pipeline Respuesta'
+ pipeline_result: '完整Resultado'
+ pipeline_query: 'Consulta内容'
+ context_data: '上下文Datos'
+ store_status: 'AlmacenarEstado'
+ store_key: 'Almacenar键'
+ store_value: '存储Valor'
+ variable_value: 'VariableValor'
+ variable_result: '设置Variable结果'
+ workflow_output: 'Flujo de TrabajoSalida'
+ final_result: '最终Resultado'
+ query_results: 'Consulta结果'
+ row_count: 'Número de filas'
+ query_success: '查询Éxito'
+ query_params: '查询Parámetro'
+ redis_result: 'RedisResultado'
+ redis_success: 'RedisÉxito'
+ redis_key: 'Redis Clave'
+ redis_value: 'Redis Valor'
+ plugin_input: '插件Entrada'
+ tool_result: 'Herramienta结果'
+ tool_success: '工具Éxito'
+ mcp_arguments: '工具Parámetro'
+ memory_result: '记忆Resultado'
+ memory_success: '记忆Éxito'
+ memory_value: '存储Valor'
+ answer: 'Respuesta'
+ dify_success: 'DifyÉxito'
+ dify_query: '用户Entrada/查询'
+ dify_conversation_id: 'Sesión ID'
+ search_results: 'Buscar结果'
+ knowledge_base_query: 'Consulta内容'
+ n8n_result: 'N8nResultado'
+ n8n_success: 'N8nÉxito'
+ n8n_payload: 'Flujo de TrabajoEntrada数据'
+ flow_result: '流程Resultado'
+ flow_success: '流程Éxito'
+ langflow_input: 'Entrada内容'
+ bot_answer: '机器人Responder'
+ coze_query: '用户Entrada/查询'
+ coze_conversation_id: 'Sesión ID'
+ bot_conversation_id: 'Sesión ID'
+ sender: 'Enviar者'
+ event: 'Evento'
+ trigger_time: '触发Tiempo'
+ scores: 'Puntuaciones'
+ parsed: 'Resultado analizado'
+ chunks: 'Texto块'
+ count: 'Cantidad'
+ execution_id: 'EjecutarID'
+ suggestions: '建议Pregunta'
+ dimensions: 'Dimensiones'
+ intent: 'Intención'
+ entities: 'Entidades'
+ invalidPage: 'Página de plugin no válida'
+
+缺失键列表:
+ undo
+ redo
+ bindTarget
+ bindTargetDescription
+ bindingType
+ selectBinding
+ noPipelinesFound
+ noWorkflowsFound
+ pipelineBindingHelp
+ workflowBindingHelp
+ dataOnlyHint
+ edgeCondition
+ edgeConditionPlaceholder
+ noNodeSelected
+ selectNodeToEdit
+ dragNodeHint
+ mode
+ panel
+ start
+ pause
+ resume
+ step
+ stop
+ messageContentPlaceholder
+ platformPlaceholder
+ customVariablesDesc
+ variableKey
+ noWatchedVariables
+ nodeOutputs
+ noNodeOutputs
+ clearBreakpoints
+ logEntries
+ resetContext
+ starting
+ started
+ startError
+ pauseError
+ resumed
+ resumeError
+ steppedTo
+ stepError
+ stopped
+ stopError
+ escape_html
+ trim_whitespace
+ json_transform_type
+ json_expression
+ mapping
+ code_language
+ code_content
+ aggregation_type
+ separator
+ field_path
+ split_type
+ chunk_size
+ chunk_overlap
+ regex_pattern
+ remove_empty
+ assign_variable_name
+ value_type
+ static_value
+ n8n_webhook_url
+ n8n_auth_type
+ langflow_flow_id
+ coze_bot_id
+ coze_api_base
+ workflow
+ pipelineDescription
+ noPipelines
+ noWorkflows
+
+============================================================
+
+文件: ru-RU.ts
+总键数: 1406
+英文键数: 34
+缺失键数: 69
+
+英文键列表:
+ dataCollectionPolicyUrl: 'https://link.langbot.app/en/docs/data-policy'
+ more: 'Ещё ({{count}})'
+ llmModels: 'LLM'
+ langbotModels: 'Модели LangBot'
+ rerankUrlTooltip: 'Полный URL для эндпоинта переранжирования (напр.: https://dashscope.aliyuncs.com/compatible-api/v1/reranks)'
+ dateFormat: '{{day}}.{{month}}'
+ webhookSaasLink: 'Подробнее о LangBot Cloud'
+ connectionError: 'WebSocket连接Ошибка'
+ fromGithub: 'С GitHub'
+ repoUrlPlaceholder: 'Напр., https://github.com/owner/repo'
+ depsProgress: '{{installed}}/{{total}} установлено · {{remaining}} осталось'
+ topK: 'Top K'
+ embeddingModelUUID: 'Модель Embedding'
+ embeddingModel: 'Embedding'
+ recoveryKeyDescription: 'Хранится в `system.recovery_key` файла конфигурации `data/config.yaml`'
+ embeddingModels: 'Embedding'
+ embeddingCalls: 'Вызовы Embedding'
+ llmModel: 'LLM'
+ embeddingCall: 'Embedding'
+ runtimeTasks: 'Задачи runtime'
+ mcp: 'MCP'
+ uuid: 'UUID'
+ conditionPlaceholder: 'ВходУсловие表达式,如: output.success == true'
+ codeExecutorDescription: 'Выполнить Python/JavaScript 代码'
+ callPipeline: 'Вызвать Pipeline'
+ callPipelineDescription: '调用现有 Pipeline'
+ langflowFlow: 'Поток Langflow'
+ cozeBot: 'Бот Coze'
+ debug: 'Debug'
+ path: 'Путь Webhook'
+ content_type: 'Content-Type'
+ top_p: 'Top P'
+ pipeline_response: 'Pipeline Ответ'
+ redis_key: 'Redis Ключ'
+
+缺失键列表:
+ undo
+ redo
+ bindTarget
+ bindTargetDescription
+ bindingType
+ selectBinding
+ noPipelinesFound
+ noWorkflowsFound
+ pipelineBindingHelp
+ workflowBindingHelp
+ edgeCondition
+ edgeConditionPlaceholder
+ noNodeSelected
+ selectNodeToEdit
+ dragNodeHint
+ mode
+ panel
+ start
+ pause
+ resume
+ step
+ stop
+ messageContentPlaceholder
+ platformPlaceholder
+ customVariablesDesc
+ variableKey
+ noWatchedVariables
+ nodeOutputs
+ noNodeOutputs
+ clearBreakpoints
+ logEntries
+ resetContext
+ starting
+ started
+ startError
+ pauseError
+ resumed
+ resumeError
+ steppedTo
+ stepError
+ stopped
+ stopError
+ escape_html
+ trim_whitespace
+ json_transform_type
+ json_expression
+ mapping
+ code_language
+ code_content
+ aggregation_type
+ separator
+ field_path
+ split_type
+ chunk_size
+ chunk_overlap
+ regex_pattern
+ remove_empty
+ assign_variable_name
+ value_type
+ static_value
+ n8n_webhook_url
+ n8n_auth_type
+ langflow_flow_id
+ coze_bot_id
+ coze_api_base
+ workflow
+ pipelineDescription
+ noPipelines
+ noWorkflows
+
+============================================================
+
+文件: th-TH.ts
+总键数: 1405
+英文键数: 67
+缺失键数: 70
+
+英文键列表:
+ dataCollectionPolicyUrl: 'https://link.langbot.app/en/docs/data-policy'
+ apiKeys: 'คีย์ API'
+ webhooks: 'Webhooks'
+ createWebhook: 'สร้าง Webhook'
+ webhookName: 'ชื่อ Webhook'
+ webhookUrl: 'URL เรียกกลับ Webhook'
+ apiKey: 'API Key'
+ llmModels: 'LLM'
+ addEmbeddingModel: 'เพิ่มโมเดล Embedding'
+ langbotModels: 'โมเดล LangBot'
+ rerankUrlTooltip: 'URL เต็มสำหรับ endpoint จัดลำดับใหม่ (เช่น: https://dashscope.aliyuncs.com/compatible-api/v1/reranks)'
+ botName: 'ชื่อ Bot'
+ bindPipeline: 'ผูก Pipeline'
+ selectPipeline: 'เลือก Pipeline'
+ dateFormat: '{{day}}/{{month}}'
+ ruleValuePrefixPlaceholder: 'เช่น !draw'
+ ruleValueRegexpPlaceholder: 'เช่น ^/help'
+ webhookUrlCopied: 'คัดลอก URL Webhook แล้ว'
+ fromGithub: 'จาก GitHub'
+ enterRepoUrl: 'กรอก URL ของ GitHub repository'
+ repoUrlPlaceholder: 'เช่น https://github.com/owner/repo'
+ backToRepoUrl: 'กลับไป URL ของ repository'
+ releaseName: 'ชื่อ: {{name}}'
+ installFromGithubDesc: 'ติดตั้งปลั๊กอินจาก GitHub Release'
+ depsProgress: 'ติดตั้งแล้ว {{installed}}/{{total}} · เหลือ {{remaining}}'
+ repository: 'Repository'
+ stdio: 'โหมด Stdio'
+ connectionFailed: 'WebSocket连接ล้มเหลว'
+ starCount: 'ดาว: {{count}}'
+ createPipeline: 'สร้าง Pipeline'
+ editPipeline: 'แก้ไข Pipeline'
+ aiCapabilities: 'AI'
+ deletePipelineAction: 'ลบ Pipeline นี้'
+ copyPipelineAction: 'คัดลอก Pipeline นี้'
+ connected: 'WebSocket已เชื่อมต่อ'
+ disconnected: 'WebSocket未เชื่อมต่อ'
+ topK: 'Top K'
+ embeddingModelUUID: 'โมเดล Embedding'
+ embeddingModel: 'Embedding'
+ recoveryKeyDescription: 'เก็บไว้ใน `system.recovery_key` ของไฟล์กำหนดค่า `data/config.yaml`'
+ embeddingModels: 'Embedding'
+ spaceStatus: 'บัญชี Space'
+ bot: 'Bot'
+ pipeline: 'Pipeline'
+ allPipelines: 'Pipeline ทั้งหมด'
+ embeddingCalls: 'การเรียก Embedding'
+ llmModel: 'LLM'
+ embeddingCall: 'Embedding'
+ stackTrace: 'Stack Trace'
+ runtimeTasks: 'งาน runtime'
+ mcp: 'MCP'
+ uuid: 'UUID'
+ conditionPlaceholder: 'อินพุตเงื่อนไข表达式,如: output.success == true'
+ codeExecutorDescription: 'ดำเนินการ Python/JavaScript 代码'
+ callPipeline: 'เรียกใช้ Pipeline'
+ callPipelineDescription: '调用现有 Pipeline'
+ langflowFlow: 'โฟลว์ Langflow'
+ cozeBot: 'บอท Coze'
+ debug: 'Debug'
+ path: 'เส้นทาง Webhook'
+ content_type: 'Content-Type'
+ top_p: 'Top P'
+ seed: 'ค่า Seed'
+ webhook_body: 'Webhook กรุณา求体'
+ webhook_headers: 'Webhook กรุณา求头'
+ redis_key: 'Redis คีย์'
+ redis_value: 'Redis ค่า'
+
+缺失键列表:
+ undo
+ redo
+ bindTarget
+ bindTargetDescription
+ bindingType
+ selectBinding
+ noPipelinesFound
+ noWorkflowsFound
+ pipelineBindingHelp
+ workflowBindingHelp
+ dataOnlyHint
+ edgeCondition
+ edgeConditionPlaceholder
+ noNodeSelected
+ selectNodeToEdit
+ dragNodeHint
+ mode
+ panel
+ start
+ pause
+ resume
+ step
+ stop
+ messageContentPlaceholder
+ platformPlaceholder
+ customVariablesDesc
+ variableKey
+ noWatchedVariables
+ nodeOutputs
+ noNodeOutputs
+ clearBreakpoints
+ logEntries
+ resetContext
+ starting
+ started
+ startError
+ pauseError
+ resumed
+ resumeError
+ steppedTo
+ stepError
+ stopped
+ stopError
+ escape_html
+ trim_whitespace
+ json_transform_type
+ json_expression
+ mapping
+ code_language
+ code_content
+ aggregation_type
+ separator
+ field_path
+ split_type
+ chunk_size
+ chunk_overlap
+ regex_pattern
+ remove_empty
+ assign_variable_name
+ value_type
+ static_value
+ n8n_webhook_url
+ n8n_auth_type
+ langflow_flow_id
+ coze_bot_id
+ coze_api_base
+ workflow
+ pipelineDescription
+ noPipelines
+ noWorkflows
+
+============================================================
+
+文件: vi-VN.ts
+总键数: 1405
+英文键数: 1200
+缺失键数: 70
+
+英文键列表:
+ home: 'Trang chủ'
+ extensions: 'Tiện ích mở rộng'
+ installedPlugins: 'Plugin đã cài đặt'
+ pluginMarket: 'Chợ ứng dụng'
+ mcpServers: 'Máy chủ MCP'
+ pluginPages: 'Trang plugin'
+ pluginPagesTooltip: 'Các trang trực quan được cung cấp bởi plugin đã cài đặt'
+ quickStart: 'Bắt đầu nhanh'
+ login: 'Đăng nhập'
+ logout: 'Đăng xuất'
+ account: 'Tài khoản'
+ integration: 'Tích hợp'
+ email: 'Email'
+ password: 'Mật khẩu'
+ welcome: 'Chào mừng trở lại LangBot 👋'
+ continueToLogin: 'Đăng nhập để tiếp tục'
+ loginSuccess: 'Đăng nhập thành công'
+ loginFailed: 'Đăng nhập thất bại, vui lòng kiểm tra email và mật khẩu'
+ loginLoadError: 'Không thể kết nối đến máy chủ'
+ loginLoadErrorDesc: 'Không thể kết nối đến máy chủ LangBot. Vui lòng đảm bảo dịch vụ đang chạy và thử lại.'
+ retry: 'Thử lại'
+ enterEmail: 'Nhập địa chỉ email'
+ enterPassword: 'Nhập mật khẩu'
+ invalidEmail: 'Vui lòng nhập địa chỉ email hợp lệ'
+ emptyPassword: 'Vui lòng nhập mật khẩu'
+ language: 'Ngôn ngữ lập trình'
+ helpDocs: 'Trợ giúp'
+ featureRequest: 'Yêu cầu tính năng'
+ starOnGitHub: 'Star trên GitHub'
+ create: 'Tạo'
+ edit: 'Chỉnh sửa'
+ delete: 'Xóa tệp'
+ add: 'Thêm'
+ select: 'Chọn'
+ cancel: 'HủyThực thi'
+ submit: 'Gửi'
+ error: 'Lỗi'
+ success: 'Di chuyển cơ sở tri thức hoàn tất'
+ save: 'Lưu'
+ saving: 'Đang lưu...'
+ confirm: 'Xác nhận'
+ confirmDelete: 'Xác nhận xóa'
+ deleteConfirmation: 'Bạn có chắc chắn muốn xóa quy trình này không?'
+ selectOption: 'Chọn một tùy chọn'
+ selectPreset: 'Chọn mẫu có sẵn'
+ required: 'Bắt buộc'
+ enable: 'Bật'
+ name: 'Tên'
+ icon: 'Biểu tượng'
+ deleteSuccess: 'Xóa thành công'
+ deleteError: 'Lỗi khi xóa:'
+ addRound: 'Thêm lượt'
+ copy: 'Sao chép'
+ copySuccess: '复制Thành công'
+ copyFailed: 'Sao chép thất bại'
+ test: 'Kiểm tra'
+ forgotPassword: 'Quên mật khẩu?'
+ agreementNotice: 'Bằng việc tiếp tục, bạn đồng ý với'
+ privacyPolicy: 'Chính sách bảo mật'
+ dataCollectionPolicy: 'Chính sách thu thập dữ liệu'
+ dataCollectionPolicyUrl: 'https://link.langbot.app/en/docs/data-policy'
+ fieldRequired: 'Trường này là bắt buộc'
+ or: 'hoặc'
+ loginWithSpace: 'Đăng nhập với Space'
+ spaceLoginRecommended: 'Khuyến nghị: Sử dụng API mô hình ổn định chính thức và dịch vụ đám mây'
+ loginLocal: 'Đăng nhập với tài khoản cục bộ'
+ loginWithPassword: 'Đăng nhập bằng mật khẩu'
+ spaceLoginTitle: 'Đăng nhập với Space'
+ spaceLoginDescription: 'Quét mã QR hoặc truy cập liên kết bên dưới để ủy quyền'
+ spaceLoginUserCode: 'Mã của bạn'
+ spaceLoginExpires: 'Mã hết hạn sau {{seconds}} giây'
+ spaceLoginWaiting: 'Đang chờ ủy quyền...'
+ spaceLoginSuccess: 'Ủy quyền thành công'
+ spaceLoginFailed: 'Đăng nhập Space thất bại'
+ spaceLoginExpired: 'Mã ủy quyền đã hết hạn, vui lòng thử lại'
+ spaceLoginCancel: 'Hủy'
+ spaceLoginVisitLink: 'Truy cập liên kết'
+ spaceLoginProcessing: 'Đang đăng nhập với Space'
+ spaceLoginProcessingDescription: 'Vui lòng chờ trong khi chúng tôi hoàn tất đăng nhập...'
+ spaceLoginSuccessDescription: 'Đang chuyển hướng đến LangBot...'
+ spaceLoginError: 'Đăng nhập thất bại'
+ spaceLoginNoCode: 'Thiếu mã ủy quyền'
+ backToLogin: 'Quay lại đăng nhập'
+ backToHome: 'Quay lại trang chủ'
+ spaceAccountCannotChangePassword: 'Tài khoản Space không thể đổi mật khẩu tại đây'
+ theme: 'Giao diện'
+ changePassword: 'Đổi mật khẩu'
+ currentPassword: 'Mật khẩu hiện tại'
+ newPassword: 'Mật khẩu mới'
+ confirmNewPassword: 'Xác nhận mật khẩu mới'
+ enterCurrentPassword: 'Nhập mật khẩu hiện tại'
+ enterNewPassword: 'Nhập mật khẩu mới'
+ enterConfirmPassword: 'Xác nhận mật khẩu mới'
+ currentPasswordRequired: 'Mật khẩu hiện tại là bắt buộc'
+ newPasswordRequired: 'Mật khẩu mới không được để trống'
+ confirmPasswordRequired: 'Xác nhận mật khẩu là bắt buộc'
+ passwordsDoNotMatch: 'Mật khẩu không khớp'
+ changePasswordSuccess: 'Đổi mật khẩu thành công'
+ changePasswordFailed: 'Đổi mật khẩu thất bại, vui lòng kiểm tra mật khẩu hiện tại'
+ apiIntegration: 'Tích hợp API'
+ apiKeys: 'Khóa API'
+ manageApiIntegration: 'Quản lý tích hợp API'
+ manageApiKeys: 'Quản lý khóa API'
+ createApiKey: 'Tạo khóa API'
+ apiKeyName: 'Tên khóa API'
+ apiKeyDescription: 'Mô tả khóa API'
+ apiKeyValue: 'Giá trị khóa API'
+ apiKeyCreated: 'Tạo khóa API thành công'
+ apiKeyDeleted: 'Xóa khóa API thành công'
+ apiKeyDeleteConfirm: 'Bạn có chắc chắn muốn xóa khóa API này không?'
+ apiKeyNameRequired: 'Tên khóa API là bắt buộc'
+ copyApiKey: 'Sao chép khóa API'
+ apiKeyCopied: 'Đã sao chép khóa API vào clipboard'
+ noApiKeys: 'Chưa cấu hình khóa API nào'
+ apiKeyHint: 'Khóa API cho phép các hệ thống bên ngoài truy cập API dịch vụ LangBot'
+ webhooks: 'Webhooks'
+ createWebhook: 'Tạo Webhook'
+ webhookName: 'Tên Webhook'
+ webhookUrl: 'URL Webhook Callback'
+ webhookDescription: 'Mô tả Webhook'
+ webhookCreated: 'Tạo Webhook thành công'
+ webhookDeleted: 'Xóa Webhook thành công'
+ webhookDeleteConfirm: 'Bạn có chắc chắn muốn xóa Webhook này không?'
+ webhookNameRequired: 'Tên Webhook là bắt buộc'
+ webhookUrlRequired: 'URL Webhook là bắt buộc'
+ noWebhooks: 'Chưa cấu hình Webhook nào'
+ webhookHint: 'Webhooks cho phép LangBot đẩy sự kiện tin nhắn cá nhân và nhóm đến các hệ thống bên ngoài'
+ actions: 'Hành động'
+ apiKeyCreatedMessage: 'Vui lòng sao chép khóa API này, nếu nút không hoạt động, vui lòng sao chép thủ công.'
+ none: 'Không có'
+ more: 'Thêm ({{count}})'
+ less: 'Thu gọn'
+ noItems: 'Không có mục nào'
+ title: 'Hội thoại Quy trình'
+ back: 'Quay lại'
+ help: 'Trợ giúp'
+ createModel: 'Tạo mô hình Embedding'
+ editModel: 'Chỉnh sửa mô hình Embedding'
+ getModelListError: 'Lấy danh sách mô hình Embedding thất bại: '
+ modelName: 'Tên mô hình'
+ modelProvider: 'Nhà cung cấp mô hình'
+ modelBaseURL: 'URL cơ sở'
+ modelAbilities: 'Khả năng mô hình'
+ saveSuccess: 'Lưu thành công'
+ saveError: 'Lỗi khi lưu:'
+ createSuccess: 'Tạo quy trình thành công'
+ createError: 'Lỗi khi tạo:'
+ modelNameRequired: 'Tên mô hình không được để trống'
+ modelProviderRequired: 'Nhà cung cấp mô hình không được để trống'
+ requestURLRequired: 'URL yêu cầu không được để trống'
+ apiKeyRequired: 'API Key không được để trống'
+ keyNameRequired: 'Tên khóa không được để trống'
+ mustBeValidNumber: 'Phải là một số hợp lệ'
+ mustBeTrueOrFalse: 'Phải là true hoặc false'
+ requestURL: 'URL yêu cầu'
+ scanURL: 'URL quét mô hình'
+ scanURLPlaceholder: 'Để trống để sử dụng URL yêu cầu + /models'
+ scanURLDescription: 'Điền điểm cuối danh sách mô hình thực tế khi quét mô hình không sử dụng cùng địa chỉ với việc gọi mô hình.'
+ apiKey: 'API Key'
+ abilities: 'Khả năng'
+ selectModelAbilities: 'Chọn khả năng mô hình'
+ visionAbility: 'Khả năng thị giác'
+ functionCallAbility: 'Gọi hàm'
+ extraParameters: 'Tham số bổ sung'
+ addParameter: 'Thêm tham số'
+ keyName: 'Tên khóa'
+ type: 'Loại'
+ value: 'Giá trị'
+ string: 'Chuỗi'
+ boolean: 'Boolean'
+ invalidJsonObject: 'Giá trị phải là một đối tượng JSON hợp lệ'
+ selectModelProvider: 'Chọn nhà cung cấp mô hình'
+ modelProviderDescription: 'Vui lòng điền tên mô hình do nhà cung cấp cung cấp'
+ modelManufacturer: 'Nhà sản xuất mô hình'
+ aggregationPlatform: 'Nền tảng tổng hợp'
+ selfDeployed: 'Tự triển khai'
+ builtin: 'Tích hợp sẵn'
+ selectModel: 'Chọn mô hình'
+ testSuccess: 'Kiểm tra thành công'
+ testError: 'Lỗi kiểm tra'
+ llmModels: 'LLM'
+ localProvider: 'Cục bộ'
+ localProviderDescription: 'Các mô hình được cấu hình và quản lý cục bộ'
+ spaceProviderDescription: 'Các mô hình được đồng bộ từ tài khoản Space của bạn'
+ spaceDisabledForLocalAccount: 'Đăng nhập với Space để sử dụng mô hình đám mây'
+ syncSuccess: 'Đồng bộ hoàn tất: {{created}} đã tạo, {{updated}} đã cập nhật'
+ syncError: 'Đồng bộ thất bại: '
+ spaceModelReadOnly: 'Mô hình Space chỉ đọc'
+ noSpaceModels: 'Không có mô hình Space. Nhấn Đồng bộ để lấy mô hình từ Space.'
+ noLocalModels: 'Không có mô hình cục bộ. Nhấn Tạo để thêm mô hình.'
+ providerCount: '{{count}} nhà cung cấp'
+ addModel: 'Thêm mô hình'
+ manualAdd: 'Thủ công'
+ scanAdd: 'Quét'
+ scanModels: 'Quét mô hình'
+ scanModelsHint: 'Đọc danh sách mô hình khả dụng từ nhà cung cấp hiện tại rồi chọn mô hình cần thêm.'
+ scannedModels: 'Kết quả quét'
+ scanDebug: 'Thông tin gỡ lỗi'
+ searchScannedModels: 'Tìm trong kết quả quét'
+ noScannedModels: 'Chưa có kết quả quét. Nhấn nút phía trên để bắt đầu.'
+ noScannedModelsMatch: 'Không có mô hình phù hợp'
+ addSelectedModels: 'Thêm mục đã chọn'
+ addSelectedModelsSuccess: 'Đã thêm {{count}} mô hình'
+ selectAll: 'Chọn tất cả'
+ addLLMModel: 'Thêm mô hình LLM'
+ addEmbeddingModel: 'Thêm mô hình Embedding'
+ provider: 'Nhà cung cấp'
+ existingProvider: 'Nhà cung cấp hiện có'
+ newProvider: 'Nhà cung cấp mới'
+ selectProvider: 'Chọn nhà cung cấp'
+ requester: 'Loại nhà cung cấp'
+ selectRequester: 'Chọn loại nhà cung cấp'
+ langbotModelsDescription: 'Mô hình đám mây được cung cấp bởi LangBot Space'
+ credits: 'Tín dụng'
+ loginToUseModels: 'Đăng nhập với Space để sử dụng mô hình đám mây'
+ noModels: 'Chưa cấu hình mô hình nào'
+ langbotModels: 'Mô hình LangBot'
+ spaceTrialTooltip: 'Có tín dụng dùng thử miễn phí! Đăng nhập với Space để truy cập mô hình đám mây không cần cấu hình.'
+ unlockModels: 'Đăng nhập để sử dụng'
+ editProvider: 'Chỉnh sửa nhà cung cấp'
+ addProvider: 'Thêm nhà cung cấp'
+ addProviderHint: 'Thêm nhà cung cấp để sử dụng mô hình từ các nguồn khác'
+ addProviderHintSimple: 'Thêm nhà cung cấp để sử dụng mô hình'
+ noProviders: 'Chưa có nhà cung cấp nào'
+ providerName: 'Tên nhà cung cấp'
+ providerNameRequired: 'Tên nhà cung cấp là bắt buộc'
+ requesterRequired: 'Loại nhà cung cấp là bắt buộc'
+ providerSaved: 'Đã lưu nhà cung cấp'
+ providerCreated: 'Đã tạo nhà cung cấp'
+ providerSaveError: 'Lưu nhà cung cấp thất bại: '
+ providerDeleted: 'Đã xóa nhà cung cấp'
+ providerDeleteError: 'Xóa nhà cung cấp thất bại: '
+ deleteProviderConfirmation: 'Bạn có chắc chắn muốn xóa nhà cung cấp này không?'
+ loadError: 'Lỗi khi tải quy trình'
+ chat: 'Trò chuyện'
+ embedding: 'Vector'
+ rerank: 'Sắp xếp lại'
+ rerankUrlTooltip: 'URL đầy đủ cho endpoint sắp xếp lại (vd: https://dashscope.aliyuncs.com/compatible-api/v1/reranks)'
+ rerankPathTooltip: 'Đường dẫn thêm vào URL cơ sở (mặc định: rerank, một số dịch vụ dùng reranks)'
+ modelsCount: '{{count}} mô hình'
+ expandModels: 'Mở rộng'
+ collapseModels: 'Thu gọn'
+ primary: 'Mô hình chính'
+ fallbackList: 'Mô hình dự phòng'
+ addFallback: 'Thêm mô hình dự phòng'
+ createBot: 'Tạo Bot'
+ selectFromSidebar: 'Chọn một trang plugin từ thanh bên'
+ editBot: 'Chỉnh sửa Bot'
+ getBotListError: 'Lấy danh sách Bot thất bại: '
+ botName: 'Tên Bot'
+ botDescription: 'Mô tả Bot'
+ botNameRequired: 'Tên Bot không được để trống'
+ botDescriptionRequired: 'Mô tả Bot không được để trống'
+ adapterRequired: 'Adapter không được để trống'
+ defaultDescription: 'Một quy trình'
+ getBotConfigError: 'Lấy cấu hình Bot thất bại: '
+ platformAdapter: 'Nền tảng/Lựa chọn Adapter'
+ selectAdapter: 'Chọn Adapter'
+ adapterConfig: 'Cấu hình Adapter'
+ viewAdapterDocs: 'Xem tài liệu'
+ bindPipeline: 'Liên kết Pipeline'
+ selectPipeline: 'Chọn Pipeline'
+ selectBot: 'Chọn Bot'
+ botLogTitle: 'Nhật ký Bot'
+ enableAutoRefresh: 'Bật tự động làm mới'
+ session: 'Phiên'
+ yesterday: 'Hôm qua'
+ dateFormat: '{{day}}/{{month}}'
+ setBotEnableError: 'Thiết lập trạng thái bật Bot thất bại'
+ log: 'Nhật ký'
+ configuration: 'Cấu hình'
+ logs: 'Nhật ký'
+ basicInfo: '基础Thông tin'
+ basicInfoDescription: '设置Quy trìnhTên和Mô tả'
+ routingConnection: 'Định tuyến & Kết nối'
+ routingConnectionDescription: 'Liên kết Pipeline xử lý tin nhắn cho Bot này'
+ routingRules: 'Quy tắc định tuyến có điều kiện'
+ routingRulesDescription: 'Các quy tắc được đánh giá theo thứ tự; kết quả khớp đầu tiên sẽ định tuyến đến pipeline tương ứng. Nếu không khớp, pipeline mặc định ở trên sẽ được sử dụng.'
+ addRoutingRule: 'Thêm quy tắc'
+ ruleTypeLauncherType: 'Loại phiên'
+ ruleTypeLauncherId: 'ID phiên'
+ ruleTypeMessageContent: 'Nội dung tin nhắn'
+ operatorEq: 'Bằng'
+ operatorNeq: 'Không bằng'
+ operatorContains: 'Chứa'
+ operatorNotContains: 'Không chứa'
+ operatorStartsWith: 'Bắt đầu bằng'
+ operatorRegex: 'Regex'
+ operatorNotHas: 'Không có'
+ ruleTypeMessageHasElement: 'Phần tử tin nhắn'
+ ruleValueElementPlaceholder: 'Chọn loại phần tử'
+ elementImage: 'Hình ảnh'
+ elementVoice: 'Giọng nói'
+ elementFile: 'Tập tin'
+ elementForward: 'Chuyển tiếp'
+ elementFace: 'Biểu tượng cảm xúc'
+ elementAtAll: '@Tất cả'
+ elementQuote: 'Trích dẫn'
+ ruleValuePlaceholder: 'Giá trị khớp'
+ ruleValueLauncherIdPlaceholder: 'ID nhóm hoặc người dùng'
+ ruleValueMessagePlaceholder: 'Nội dung tin nhắn'
+ ruleValuePrefixPlaceholder: 'vd. !draw'
+ ruleValueRegexpPlaceholder: 'vd. ^/help'
+ pipelineDiscard: 'Loại bỏ tin nhắn'
+ sessionTypePerson: 'Trò chuyện riêng'
+ sessionTypeGroup: 'Trò chuyện nhóm'
+ adapterConfigDescription: 'Cấu hình Adapter nền tảng đã chọn'
+ dangerZone: 'Vùng nguy hiểm'
+ deleteBotAction: 'Xóa Bot này'
+ deleteBotHint: 'Sau khi xóa, tất cả cấu hình liên quan sẽ bị xóa vĩnh viễn.'
+ webhookUrlCopied: 'Đã sao chép URL Webhook'
+ webhookUrlHint: 'Nhấp vào ô nhập để chọn tất cả, sau đó nhấn Ctrl+C (Mac: Cmd+C) để sao chép, hoặc nhấn nút'
+ webhookUrlHintEither: 'Sử dụng một trong hai URL trên trong cấu hình nền tảng của bạn'
+ webhookSaasHint: 'Webhook yêu cầu một tên miền có thể truy cập công khai. LangBot Cloud cung cấp điểm cuối công khai sẵn sàng sử dụng cho Bot của bạn.'
+ webhookSaasLink: 'Tìm hiểu thêm về LangBot Cloud'
+ popular: 'Phổ biến'
+ china: 'Trung Quốc'
+ global: 'Toàn cầu'
+ protocol: 'Giao thức'
+ logLevel: 'Mức nhật ký'
+ allLevels: 'Tất cả các mức'
+ selectLevel: 'Chọn mức'
+ levelsSelected: 'mức đã chọn'
+ viewDetailedLogs: 'Xem nhật ký chi tiết'
+ viewDetails: 'Xem chi tiết'
+ collapse: 'Thu gọn'
+ imagesAttached: 'hình ảnh đính kèm'
+ sessions: 'Phiên'
+ noSessions: 'Không tìm thấy phiên nào'
+ selectSession: 'Chọn một phiên để xem tin nhắn'
+ noMessages: '暂无Tin nhắn'
+ messages: 'Tin nhắn'
+ messageCount: 'Tin nhắn'
+ loadingSessions: 'Đang tải phiên...'
+ loadingMessages: 'Đang tải tin nhắn...'
+ user: 'Người dùng'
+ platform: 'Nền tảng'
+ lastActive: 'Hoạt động lần cuối'
+ refresh: 'Làm mới'
+ active: 'Đang hoạt động'
+ inactive: 'Không hoạt động'
+ userMessage: 'Người dùng'
+ botMessage: 'Bot'
+ createPlugin: 'Tạo Plugin'
+ editPlugin: 'Chỉnh sửa Plugin'
+ marketplace: 'Chợ ứng dụng'
+ arrange: 'Sắp xếp Plugin'
+ installPlugin: 'Cài đặt Plugin'
+ onlySupportGithub: 'Hiện chỉ hỗ trợ cài đặt từ GitHub'
+ enterGithubLink: 'Nhập liên kết GitHub của plugin'
+ installing: 'Đang cài đặt plugin...'
+ installSuccess: 'Cài đặt plugin thành công'
+ installFailed: 'Cài đặt thất bại, vui lòng thử lại sau'
+ searchPlugin: 'Tìm kiếm plugin'
+ sortBy: 'Sắp xếp theo'
+ mostStars: 'Nhiều star nhất'
+ recentlyAdded: 'Mới thêm gần đây'
+ recentlyUpdated: 'Mới cập nhật gần đây'
+ noMatchingPlugins: 'Không tìm thấy plugin phù hợp'
+ getPluginListError: 'Lấy danh sách plugin thất bại:'
+ noPluginInstalled: 'Chưa cài đặt plugin nào'
+ pluginConfig: 'Cấu hình Plugin'
+ pluginSort: 'Sắp xếp Plugin'
+ pluginSortDescription: 'Thứ tự plugin ảnh hưởng đến thứ tự xử lý trong cùng một sự kiện, vui lòng kéo thẻ plugin để sắp xếp'
+ pluginSortSuccess: 'Sắp xếp plugin thành công'
+ pluginSortError: 'Sắp xếp plugin thất bại: '
+ pluginNoConfig: 'Plugin không có mục cấu hình nào.'
+ systemDisabled: 'Hệ thống Plugin đã bị tắt'
+ systemDisabledDesc: 'Hệ thống plugin chưa được bật, vui lòng sửa đổi cấu hình theo tài liệu hướng dẫn'
+ connectionError: 'WebSocket连接Lỗi'
+ connectionErrorDesc: 'Vui lòng kiểm tra cấu hình hệ thống plugin hoặc liên hệ quản trị viên.'
+ errorDetails: 'Chi tiết lỗi'
+ loadingStatus: 'Đang kiểm tra trạng thái hệ thống plugin...'
+ failedToGetStatus: 'Lấy trạng thái hệ thống plugin thất bại'
+ pluginSystemNotReady: 'Hệ thống plugin chưa sẵn sàng, không thể thực hiện thao tác này'
+ debugInfo: 'Thông tin gỡ lỗi'
+ debugInfoTitle: 'Thông tin gỡ lỗi Plugin'
+ debugUrl: 'URL gỡ lỗi'
+ debugKey: 'Khóa gỡ lỗi'
+ noDebugKey: '(Chưa đặt)'
+ debugKeyDisabled: 'Khóa gỡ lỗi chưa được đặt, gỡ lỗi plugin không yêu cầu xác thực'
+ failedToGetDebugInfo: 'Lấy thông tin gỡ lỗi thất bại'
+ copiedToClipboard: 'Đã sao chép vào clipboard'
+ deleting: 'Đang xóa...'
+ deletePlugin: 'Xóa Plugin'
+ saveConfig: 'Lưu cấu hình'
+ confirmDeletePlugin: 'Bạn có chắc chắn muốn xóa plugin ({{author}}/{{name}}) không?'
+ deleteDataCheckbox: 'Đồng thời xóa cấu hình và dữ liệu lưu trữ của plugin'
+ deleteConfirm: 'Xác nhậnXóa'
+ modifyFailed: 'Sửa đổi thất bại: '
+ Tool: 'Công cụ'
+ EventListener: 'Trình lắng nghe sự kiện'
+ Command: 'Lệnh'
+ KnowledgeEngine: 'Công cụ tri thức'
+ Parser: 'Trình phân tích'
+ Page: 'Trang'
+ uploadLocal: 'Tải lên cục bộ'
+ debugging: 'Gỡ lỗi'
+ uploadLocalPlugin: 'Tải lên Plugin cục bộ'
+ dragToUpload: 'Kéo tệp plugin vào đây để tải lên'
+ unsupportedFileType: 'Loại tệp không được hỗ trợ, chỉ hỗ trợ tệp .lbpkg và .zip'
+ uploadingPlugin: 'Đang tải lên plugin...'
+ uploadSuccess: 'Tải tệp lên thành công!'
+ uploadFailed: 'Tải lên thất bại'
+ selectFileToUpload: 'Chọn tệp plugin để tải lên'
+ askConfirm: 'Bạn có chắc chắn muốn cài đặt plugin '
+ fromGithub: 'Từ GitHub'
+ fromLocal: 'Từ cục bộ'
+ fromMarketplace: 'Từ chợ ứng dụng'
+ componentsList: 'Thành phần: '
+ noComponents: 'Không có thành phần'
+ update: 'Cập nhật Plugin'
+ new: 'Mới'
+ updateConfirm: 'Xác nhận cập nhật'
+ confirmUpdatePlugin: 'Bạn có chắc chắn muốn cập nhật plugin ({{author}}/{{name}}) không?'
+ confirmUpdate: 'Xác nhận cập nhật'
+ updating: 'Đang cập nhật...'
+ updateSuccess: 'Cập nhật thành công'
+ updateError: 'Cập nhật thất bại: '
+ saveConfigSuccessNormal: 'Lưu cấu hình thành công'
+ saveConfigError: 'Lưu cấu hình thất bại: '
+ config: 'Cấu hình'
+ readme: 'Tài liệu'
+ viewSource: 'Xem mã nguồn'
+ loadingReadme: 'Đang tải tài liệu...'
+ noReadme: 'Plugin này không cung cấp tài liệu README'
+ tooLarge: 'Kích thước tệp vượt quá giới hạn 10MB'
+ failed: 'thất bại'
+ uploading: 'Đang tải lên...'
+ chooseFile: 'Chọn tệp'
+ addFile: 'Thêm tệp'
+ installFromGithub: 'Cài đặt máy chủ MCP từ GitHub'
+ enterRepoUrl: 'Nhập URL kho lưu trữ GitHub'
+ repoUrlPlaceholder: 'ví dụ: https://github.com/owner/repo'
+ fetchingReleases: 'Đang lấy danh sách phiên bản...'
+ selectRelease: 'Chọn phiên bản'
+ noReleasesFound: 'Không tìm thấy phiên bản nào'
+ fetchReleasesError: 'Lấy danh sách phiên bản thất bại: '
+ selectAsset: 'Chọn tệp để cài đặt'
+ noAssetsFound: 'Không có tệp .lbpkg nào trong phiên bản này'
+ fetchAssetsError: 'Lấy danh sách tệp thất bại: '
+ backToReleases: 'Quay lại danh sách phiên bản'
+ backToRepoUrl: 'Quay lại URL kho lưu trữ'
+ backToAssets: 'Quay lại danh sách tệp'
+ releaseTag: 'Tag: {{tag}}'
+ releaseName: 'Tên: {{name}}'
+ publishedAt: 'Ngày phát hành: {{date}}'
+ prerelease: 'Phiên bản trước phát hành'
+ assetSize: 'Kích thước: {{size}}'
+ confirmInstall: 'Xác nhận cài đặt'
+ installFromGithubDesc: 'Cài đặt plugin từ GitHub Release'
+ goToMarketplace: 'Đi đến chợ ứng dụng'
+ titleGeneric: 'Cài đặt Plugin'
+ overallProgress: 'Tiến độ tổng thể'
+ downloading: 'Đang tải Plugin'
+ installingDeps: 'Đang cài đặt phụ thuộc'
+ initializing: 'Đang khởi tạo cài đặt'
+ launching: 'Đang khởi chạy Plugin'
+ completed: '已Hoàn thành'
+ downloadSize: 'Kích thước gói: {{size}}'
+ depsInfo: '{{count}} phụ thuộc cần cài đặt'
+ depsProgress: 'Đã cài {{installed}}/{{total}} · Còn lại {{remaining}}'
+ installComplete: 'Cài đặt plugin thành công'
+ dismiss: 'Hủy bỏ dữ liệu gốc'
+ background: 'Chạy nền'
+ taskQueue: 'Tác vụ cài đặt'
+ clearCompleted: 'Xóa đã hoàn thành'
+ noTasks: 'Không có tác vụ cài đặt'
+ searchPlaceholder: 'Tìm kiếm plugin...'
+ searchResults: 'Tìm thấy {{count}} plugin'
+ totalPlugins: 'Tổng cộng {{count}} plugin'
+ noPlugins: 'Không có plugin nào'
+ noResults: 'Không có kết quả'
+ loadingMore: 'Đang tải thêm...'
+ allLoaded: 'Đã hiển thị tất cả plugin'
+ installConfirm: 'Bạn có chắc chắn muốn cài đặt plugin '
+ downloadComplete: 'Tải plugin '
+ loadFailed: 'Tải thất bại'
+ noDescription: 'Không có mô tả'
+ notFound: 'Không tìm thấy thông tin plugin'
+ mostDownloads: 'Tải nhiều nhất'
+ leastDownloads: 'Tải ít nhất'
+ downloads: 'lượt tải'
+ download: 'Tải xuống'
+ repository: 'Kho lưu trữ'
+ downloadFailed: 'Tải xuống thất bại'
+ tagLabel: 'Thẻ'
+ submissionTitle: 'Bạn có một plugin đang chờ duyệt: {{name}}'
+ submissionPending: 'Plugin của bạn đang được xem xét: {{name}}'
+ submissionApproved: 'Plugin của bạn đã được phê duyệt: {{name}}'
+ submissionRejected: 'Plugin của bạn đã bị từ chối: {{name}}'
+ clickToRevoke: 'Thu hồi'
+ revokeSuccess: 'Thu hồi thành công'
+ revokeFailed: 'Thu hồi thất bại'
+ submissionDetails: 'Chi tiết gửi Plugin'
+ markAsReadFailed: 'Đánh dấu đã đọc thất bại'
+ filterByComponent: 'Thành phần'
+ allComponents: 'Tất cả thành phần'
+ requestPlugin: 'Yêu cầu Plugin'
+ deprecated: 'Không còn hỗ trợ'
+ deprecatedTooltip: 'Vui lòng cài đặt plugin Công cụ tri thức tương ứng.'
+ filterByTags: 'Lọc theo thẻ'
+ selectTags: 'Chọn thẻ'
+ clearAll: 'Xóa tất cả'
+ noTags: 'Không có thẻ nào'
+ createServer: 'Thêm máy chủ MCP'
+ editServer: 'Chỉnh sửa máy chủ MCP'
+ deleteServer: 'Xóa máy chủ MCP'
+ confirmDeleteServer: 'Bạn có chắc chắn muốn xóa máy chủ MCP này không?'
+ confirmDeleteTitle: 'Xóa máy chủ MCP'
+ getServerListError: 'Lấy danh sách máy chủ MCP thất bại: '
+ serverName: 'Tên máy chủ'
+ serverMode: 'Chế độ kết nối'
+ selectMode: 'Chọn chế độ'
+ stdio: 'Chế độ Stdio'
+ sse: 'Chế độ SSE'
+ http: 'Chế độ HTTP'
+ noServerInstalled: 'Chưa cấu hình máy chủ MCP nào'
+ serverNameRequired: 'Tên máy chủ không được để trống'
+ commandRequired: 'Lệnh không được để trống'
+ urlRequired: 'URL không được để trống'
+ timeoutMustBePositive: 'Thời gian chờ phải là số dương'
+ command: 'Lệnh'
+ args: 'Tham số'
+ env: 'Biến môi trường'
+ url: 'Vui lòng求URL'
+ headers: 'Vui lòng求头'
+ timeout: 'Hết thời gianThời gian'
+ addArgument: 'Thêm tham số'
+ addEnvVar: 'Thêm biến môi trường'
+ addHeader: 'Thêm tiêu đề'
+ testing: 'Đang kiểm tra...'
+ connecting: 'Đang kết nối...'
+ testFailed: 'Kiểm tra thất bại: '
+ refreshSuccess: 'Làm mới thành công'
+ refreshFailed: 'Làm mới thất bại: '
+ connectionSuccess: 'Kết nối thành công'
+ connectionFailed: 'WebSocket连接thất bại'
+ connectionFailedStatus: 'Kết nối thất bại'
+ toolsFound: 'công cụ'
+ unknownError: 'Lỗi không xác định'
+ noToolsFound: 'Không tìm thấy công cụ nào'
+ parseResultFailed: 'Phân tích kết quả kiểm tra thất bại'
+ noResultReturned: 'Kiểm tra không trả về kết quả'
+ getTaskFailed: 'Lấy trạng thái tác vụ thất bại'
+ noTaskId: 'Không lấy được ID tác vụ'
+ deleteFailed: 'Xóa thất bại: '
+ createFailed: 'Tạo thất bại: '
+ toolCount: '{{count}} công cụ'
+ statusDisconnected: 'Đã ngắt kết nối'
+ statusError: 'Lỗi kết nối'
+ starCount: 'Star: {{count}}'
+ nameRequired: 'Tên không được để trống'
+ sseTimeout: 'Thời gian chờ SSE'
+ sseTimeoutDescription: 'Thời gian chờ để thiết lập kết nối SSE'
+ extraParametersDescription: 'Sẽ được đính kèm vào nội dung yêu cầu, như max_tokens, temperature, top_p, v.v.'
+ timeoutMustBeNumber: 'Thời gian chờ phải là một số'
+ timeoutNonNegative: 'Thời gian chờ không được âm'
+ sseTimeoutMustBeNumber: 'Thời gian chờ SSE phải là một số'
+ sseTimeoutNonNegative: 'Thời gian chờ SSE không được âm'
+ updateFailed: 'Cập nhật thất bại: '
+ deleteMCPAction: 'Xóa máy chủ MCP này'
+ deleteMCPHint: 'Sau khi xóa, cấu hình máy chủ MCP này không thể khôi phục.'
+ createPipeline: 'Tạo Pipeline'
+ editPipeline: 'Chỉnh sửa Pipeline'
+ debugChat: 'Hội thoạiGỡ lỗi'
+ getPipelineListError: 'Lấy danh sách Pipeline thất bại: '
+ daysAgo: 'ngày trước'
+ today: 'Hôm nay'
+ updateTime: 'Cập nhật '
+ defaultBadge: 'Mặc định'
+ newestCreated: 'Tạo mới nhất'
+ earliestCreated: 'Tạo sớm nhất'
+ recentlyEdited: 'Chỉnh sửa gần đây'
+ earliestEdited: 'Chỉnh sửa sớm nhất'
+ aiCapabilities: 'AI'
+ triggerConditions: 'Điều kiện kích hoạt'
+ safetyControls: 'An toàn'
+ outputProcessing: 'Đầu ra'
+ descriptionRequired: 'Mô tả không được để trống'
+ copySuffix: ' Bản sao'
+ defaultPipelineCannotDelete: 'Không thể xóa Pipeline mặc định'
+ copyConfirmTitle: 'Xác nhận sao chép'
+ copyConfirmation: 'Bạn có chắc chắn muốn sao chép Pipeline này không? Thao tác này sẽ tạo một Pipeline mới với tất cả cấu hình.'
+ deletePipelineAction: 'Xóa Pipeline này'
+ deletePipelineHint: 'Sau khi xóa, Bot liên kết với Pipeline này sẽ ngừng hoạt động.'
+ copyPipelineAction: 'Sao chép Pipeline này'
+ copyPipelineHint: 'Tạo một Pipeline mới với tất cả cấu hình được sao chép.'
+ noPluginsAvailable: 'Không có plugin nào'
+ noPluginsSelected: 'Chưa chọn plugin nào'
+ addPlugin: 'Thêm Plugin'
+ selectPlugins: 'Chọn Plugin'
+ pluginsTitle: 'Plugin'
+ mcpServersTitle: 'Máy chủ MCP'
+ noMCPServersSelected: 'Chưa chọn máy chủ MCP nào'
+ addMCPServer: 'Thêm máy chủ MCP'
+ selectMCPServers: 'Chọn máy chủ MCP'
+ noPluginsInstalled: 'Chưa cài đặt plugin nào'
+ noMCPServersConfigured: 'Chưa cấu hình máy chủ MCP nào'
+ enableAllPlugins: 'Bật tất cả Plugin'
+ enableAllMCPServers: 'Bật tất cả máy chủ MCP'
+ allPluginsEnabled: 'Đã bật tất cả plugin'
+ allMCPServersEnabled: 'Đã bật tất cả máy chủ MCP'
+ privateChat: 'Chat riêng'
+ groupChat: 'Chat nhóm'
+ send: 'Gửi'
+ reset: '重置Hội thoại'
+ inputPlaceholder: 'Gửi {{type}} Tin nhắn...'
+ sendFailed: 'Gửithất bại'
+ resetSuccess: 'Hội thoại已重置'
+ resetFailed: '重置thất bại'
+ loadMessagesFailed: 'TảiTin nhắnthất bại'
+ loadPipelinesFailed: 'Tải Pipeline thất bại'
+ atTips: 'Nhắc đến bot'
+ streaming: 'Truyền phát'
+ streamOutput: 'Streaming'
+ connected: 'WebSocket已Kết nối'
+ disconnected: 'WebSocket未Kết nối'
+ notConnected: 'WebSocket未连接,Vui lòng稍后Thử lại'
+ reply: 'Trả lời'
+ replyTo: 'Trả lời给'
+ showMarkdown: 'Hiển thị'
+ showRaw: 'Văn bản gốc'
+ allMembers: 'Tất cả thành viên'
+ file: 'Tệp'
+ voice: 'Giọng nói'
+ uploadImage: 'Tải lên hình ảnh'
+ detailedLogs: 'Nhật ký chi tiết'
+ createKnowledgeBase: 'Tạo cơ sở tri thức'
+ editKnowledgeBase: 'Chỉnh sửa cơ sở tri thức'
+ selectKnowledgeBase: 'Chọn cơ sở tri thức'
+ selectKnowledgeBases: 'Chọn cơ sở tri thức'
+ addKnowledgeBase: 'Thêm cơ sở tri thức'
+ noKnowledgeBaseSelected: 'Chưa chọn cơ sở tri thức nào'
+ empty: 'Trống'
+ editDocument: 'Tài liệu'
+ metadata: 'Siêu dữ liệu'
+ kbNameRequired: 'Tên cơ sở tri thức không được để trống'
+ kbDescriptionRequired: 'Mô tả cơ sở tri thức không được để trống'
+ embeddingModelUUIDRequired: 'Mô hình Embedding không được để trống'
+ kbName: 'Tên cơ sở tri thức'
+ kbDescription: 'Mô tả cơ sở tri thức'
+ topK: 'Top K'
+ topKRequired: 'Top K không được để trống'
+ topKMax: 'Giá trị tối đa của Top K là 30'
+ topKdescription: 'Dùng để chỉ định số lượng tài liệu liên quan cần truy xuất, phạm vi từ 1 đến 30.'
+ embeddingModelUUID: 'Mô hình Embedding'
+ selectEmbeddingModel: 'Chọn mô hình Embedding'
+ embeddingModelDescription: 'Dùng để véc tơ hóa văn bản, bạn có thể cấu hình trong trang Mô hình'
+ cannotChangeEmbeddingModel: 'Không thể thay đổi mô hình Embedding sau khi tạo cơ sở tri thức'
+ updateKnowledgeBaseSuccess: 'Cập nhật cơ sở tri thức thành công'
+ updateKnowledgeBaseFailed: 'Cập nhật cơ sở tri thức thất bại: '
+ status: 'Trạng thái'
+ dragAndDrop: 'Kéo và thả tệp vào đây hoặc nhấp để tải lên'
+ supportedFormats: 'Hỗ trợ các định dạng PDF, Word, TXT, Markdown, HTML, ZIP và các định dạng tài liệu khác'
+ uploadError: 'Tải tệp lên thất bại: '
+ uploadingFile: 'Đang tải tệp lên...'
+ fileSizeExceeded: 'Kích thước tệp vượt quá giới hạn 10MB. Vui lòng chia thành các tệp nhỏ hơn.'
+ fileDeleteSuccess: 'Xóa tệp thành công'
+ fileDeleteFailed: 'Xóa tệp thất bại: '
+ processing: 'Đang xử lý'
+ selectParser: 'Chọn trình phân tích'
+ builtInParser: 'Được cung cấp bởi Công cụ tri thức'
+ noParserAvailable: 'Không có trình phân tích hỗ trợ loại tệp này. Vui lòng cài đặt plugin trình phân tích có thể xử lý định dạng này.'
+ installParserHint: 'Duyệt plugin trình phân tích trong Marketplace →'
+ confirmUpload: 'Tải lên'
+ cancelUpload: 'Hủy'
+ deleteKnowledgeBaseConfirmation: 'Bạn có chắc chắn muốn xóa cơ sở tri thức này không? Tất cả tài liệu trong cơ sở tri thức này sẽ bị xóa.'
+ retrieve: 'Kiểm tra truy xuất'
+ retrieveTest: 'Kiểm tra truy xuất'
+ query: 'SQLTruy vấn'
+ queryPlaceholder: 'Nhập văn bản truy vấn...'
+ distance: 'Khoảng cách'
+ content: 'Nội dung'
+ fileName: 'Tên tệp'
+ retrieveError: 'Truy xuất thất bại: '
+ unknownEngine: 'Công cụ không xác định'
+ knowledgeEngine: 'Công cụ tri thức'
+ knowledgeEngineRequired: 'Công cụ tri thức là bắt buộc'
+ selectKnowledgeEngine: 'Chọn công cụ tri thức'
+ builtInEngine: 'Công cụ tích hợp'
+ cannotChangeKnowledgeEngine: 'Không thể thay đổi công cụ tri thức sau khi tạo'
+ engineSettings: 'Cài đặt công cụ'
+ engineSettingsDescription: 'Cấu hình cho công cụ tri thức đã chọn'
+ engineSettingsReadonly: 'chỉ đọc trong chế độ chỉnh sửa'
+ retrievalSettings: 'Cài đặt truy xuất'
+ retrievalSettingsDescription: 'Cấu hình cách truy xuất tài liệu từ cơ sở tri thức này'
+ deleteKbAction: 'Xóa cơ sở tri thức này'
+ deleteKbHint: 'Sau khi xóa, tất cả tài liệu và dữ liệu trong cơ sở tri thức này sẽ bị xóa vĩnh viễn.'
+ noEnginesAvailable: 'Không có công cụ cơ sở tri thức nào khả dụng'
+ installEngineHint: 'Vui lòng cài đặt plugin '
+ createKnowledgeBaseFailed: 'Tạo cơ sở tri thức thất bại: '
+ loadKnowledgeBaseFailed: 'Tải cơ sở tri thức thất bại: '
+ deleteKnowledgeBaseFailed: 'Xóa cơ sở tri thức thất bại: '
+ getKnowledgeBaseListError: 'Lấy danh sách cơ sở tri thức thất bại: '
+ embeddingModel: 'Embedding'
+ embeddingModelRequired: 'Mô hình Embedding là bắt buộc cho công cụ này'
+ addExternal: 'Thêm cơ sở tri thức bên ngoài'
+ createExternalSuccess: 'Tạo cơ sở tri thức bên ngoài thành công'
+ updateExternalSuccess: 'Cập nhật cơ sở tri thức bên ngoài thành công'
+ deleteExternalSuccess: 'Xóa cơ sở tri thức bên ngoài thành công'
+ retriever: 'Trình truy xuất'
+ selectRetriever: 'Chọn trình truy xuất...'
+ retrieverConfiguration: 'Cấu hình trình truy xuất'
+ retrieverInstallInfo: 'Bạn có thể cài đặt plugin trình truy xuất tri thức từ'
+ detected: 'Phát hiện {{total}} cơ sở tri thức cần di chuyển ({{internal}} nội bộ, {{external}} bên ngoài).'
+ startWithInstall: 'Tự động cài đặt Plugin & Di chuyển'
+ startDataOnly: 'Chỉ di chuyển dữ liệu'
+ running: 'Thực thi中'
+ dismissError: 'Thao tác thất bại'
+ adminAccountNote: 'Tài khoản bạn sử dụng ở đây sẽ được đặt làm tài khoản quản trị viên'
+ initWithSpace: 'Khởi tạo với Space'
+ spaceRecommended: 'Khuyến nghị: Sử dụng API mô hình ổn định chính thức và dịch vụ đám mây'
+ spaceInfoTip1: 'Space cung cấp dịch vụ xác thực tài khoản thống nhất mà không tải lên bất kỳ thông tin nhạy cảm nào của bạn.'
+ spaceInfoTip2: 'Đăng nhập bằng tài khoản Space cho phép bạn truy cập Mô hình LangBot và các dịch vụ đám mây khác, bao gồm tín dụng gọi mô hình miễn phí để giúp bạn bắt đầu nhanh chóng.'
+ spaceInfoTip3: 'Phương thức đăng nhập của bạn không ảnh hưởng đến các tính năng khác. Bạn có thể cấu hình và sử dụng mô hình từ các nguồn khác bất cứ lúc nào.'
+ registerLocal: 'Đăng ký tài khoản cục bộ'
+ registerWithPassword: 'Đăng ký bằng email và mật khẩu'
+ initSuccess: 'Khởi tạo thành công, vui lòng đăng nhập'
+ initFailed: 'Khởi tạo thất bại: '
+ recoveryKey: 'Khóa khôi phục'
+ recoveryKeyDescription: 'Được lưu trữ trong `system.recovery_key` của tệp cấu hình `data/config.yaml`'
+ enterRecoveryKey: 'Nhập khóa khôi phục'
+ recoveryKeyRequired: 'Khóa khôi phục không được để trống'
+ resetPassword: 'Đặt lại mật khẩu'
+ resetting: 'Đang đặt lại...'
+ embeddingModels: 'Embedding'
+ newVersionAvailable: 'Có phiên bản mới'
+ viewUpdateGuide: 'Xem hướng dẫn cập nhật'
+ noReleaseNotes: 'Không có ghi chú phát hành'
+ settings: 'Cài đặt tài khoản'
+ setPassword: 'Đặt mật khẩu'
+ passwordSetSuccess: 'Đặt mật khẩu thành công'
+ passwordStatus: 'Mật khẩu cục bộ'
+ passwordNotSet: 'Chưa đặt'
+ passwordSetDescription: 'Mật khẩu đã được đặt, bạn có thể đăng nhập bằng email và mật khẩu'
+ spaceStatus: 'Tài khoản Space'
+ spaceBound: 'Đã liên kết'
+ spaceNotBound: 'Chưa liên kết'
+ spaceBoundDescription: 'Tài khoản Space đã liên kết, có thể sử dụng API mô hình chính thức và dịch vụ đám mây'
+ bindSpace: 'Liên kết tài khoản Space'
+ bindSpaceDescription: 'Liên kết để sử dụng API mô hình chính thức và dịch vụ đám mây'
+ bindSpaceButton: 'Liên kết'
+ bindSpaceConfirmTitle: 'Xác nhận liên kết'
+ bindSpaceConfirmDescription: 'Bạn sắp liên kết phiên bản cục bộ với tài khoản Space'
+ bindSpaceWarning: 'Sau khi liên kết, email đăng nhập của bạn sẽ được đổi từ {{localEmail}} sang email tài khoản Space.'
+ bindSpaceSuccess: 'Liên kết tài khoản Space thành công'
+ bindSpaceFailed: 'Liên kết tài khoản Space thất bại'
+ bindSpaceInvalidState: 'Yêu cầu liên kết không hợp lệ. Vui lòng thử lại từ cài đặt tài khoản.'
+ setPasswordHint: 'Đặt mật khẩu để đăng nhập bằng email và mật khẩu'
+ spaceEmailMismatch: 'Email đăng nhập Space không khớp với email tài khoản cục bộ'
+ overview: 'Tổng quan'
+ totalMessages: 'Tổng tin nhắn'
+ llmCallsCount: 'Cuộc gọi LLM'
+ modelCallsCount: 'Cuộc gọi mô hình'
+ successRate: 'Thành công率'
+ activeSessions: 'Phiên đang hoạt động'
+ last24Hours: '24 giờ qua'
+ bot: 'Bot'
+ pipeline: 'Pipeline'
+ allBots: 'Tất cả Bot'
+ allPipelines: 'Tất cả Pipeline'
+ timeRange: 'Khoảng thời gian'
+ customRange: 'Tùy chỉnh'
+ apply: 'Áp dụng'
+ lastHour: '1 giờ qua'
+ last6Hours: '6 giờ qua'
+ last7Days: '7 ngày qua'
+ last30Days: '30 ngày qua'
+ llmCalls: 'Cuộc gọi LLM'
+ embeddingCalls: 'Cuộc gọi Embedding'
+ modelCalls: 'Cuộc gọi mô hình'
+ feedback: 'Phản hồi người dùng'
+ errors: 'LỗiDanh sách'
+ timestamp: 'Thời gian戳'
+ message: 'Tin nhắn'
+ sessionId: 'Phiên ID'
+ copyId: 'Sao chép ID'
+ noMessagesDescription: 'Hãy thử điều chỉnh bộ lọc hoặc kiểm tra lại sau'
+ loadMore: 'Tải thêm'
+ autoRefresh: 'Tự động làm mới'
+ level: 'Mức'
+ runner: 'Trình chạy'
+ viewConversation: 'Xem cuộc trò chuyện'
+ model: 'Mô hình'
+ tokens: 'Token'
+ cost: 'Chi phí'
+ noData: 'Không có dữ liệu lưu lượng'
+ inputTokens: 'Token đầu vào'
+ outputTokens: 'Token đầu ra'
+ totalTokens: 'Tổng Token'
+ avgDuration: 'Thời lượng trung bình'
+ calls: 'Cuộc gọi'
+ promptTokens: 'Token prompt'
+ knowledgeBase: 'Cơ sở tri thức'
+ queryText: 'Truy vấn'
+ llmModel: 'LLM'
+ embeddingCall: 'Embedding'
+ retrieveCall: 'Truy xuất'
+ lastActivity: 'Hoạt động cuối'
+ messageStats: 'Thống kê tin nhắn'
+ successMessages: 'Thành công'
+ errorMessages: 'Thất bại'
+ llmStats: 'Thống kê LLM'
+ errorType: 'Loại lỗi'
+ errorMessage: 'Thông báo lỗi'
+ occurredAt: 'Xảy ra lúc'
+ noErrors: 'Không tìm thấy lỗi'
+ stackTrace: 'Stack Trace'
+ totalFeedback: 'Tổng phản hồi'
+ totalLikes: 'Lượt thích'
+ totalDislikes: 'Lượt không thích'
+ satisfactionRate: 'Tỷ lệ hài lòng'
+ like: 'Thích'
+ dislike: 'Không thích'
+ noFeedback: 'Chưa có phản hồi'
+ noFeedbackDescription: 'Phản hồi của người dùng sẽ hiển thị tại đây'
+ feedbackList: 'Danh sách phản hồi'
+ feedbackContent: 'Nội dung phản hồi'
+ contextInfo: 'Thông tin ngữ cảnh'
+ userId: 'ID người dùng'
+ messageId: 'ID tin nhắn'
+ streamId: 'ID câu hỏi liên quan'
+ inaccurateReasons: 'Lý do không chính xác'
+ exportFeedback: 'Xuất phản hồi'
+ viewMonitoring: 'Xem giám sát'
+ refreshData: 'Làm mới dữ liệu'
+ exportData: 'Xuất dữ liệu'
+ exporting: 'Đang xuất...'
+ openDialog: 'Xem phân tích'
+ dialogTitle: 'Phân tích lưu trữ'
+ generatedAt: 'Tạo lúc {{time}}'
+ totalSize: 'Tổng dung lượng'
+ binaryStorage: 'Lưu trữ nhị phân plugin'
+ uploadCleanup: 'Tệp tải lên hết hạn'
+ logCleanup: 'Nhật ký hết hạn'
+ sections: 'Khu vực lưu trữ'
+ monitoringTables: 'Bảng giám sát'
+ runtimeTasks: 'Tác vụ runtime'
+ cleanupPolicy: 'Chính sách dọn dẹp'
+ uploadRetention: 'Thời gian giữ tệp tải lên'
+ logRetention: 'Thời gian giữ nhật ký'
+ databaseType: 'Loại cơ sở dữ liệu'
+ days: 'ngày'
+ missing: '缺失Tham số'
+ expiredUploads: 'Tệp tải lên hết hạn'
+ expiredLogs: 'Nhật ký hết hạn'
+ noExpiredUploads: 'Không có tệp tải lên hết hạn'
+ noExpiredLogs: 'Không có nhật ký hết hạn'
+ database: 'Cơ sở dữ liệu'
+ storage: 'Tệp tải lên'
+ vector_store: 'Kho vector'
+ plugins: 'Plugin'
+ mcp: 'MCP'
+ temp: 'Tệp tạm'
+ maxBotsReached: 'Đã đạt số lượng Bot tối đa ({{max}}). Vui lòng xóa một Bot hiện có trước khi tạo mới.'
+ maxPipelinesReached: 'Đã đạt số lượng Pipeline tối đa ({{max}}). Vui lòng xóa một Pipeline hiện có trước khi tạo mới.'
+ maxExtensionsReached: 'Đã đạt số lượng tiện ích mở rộng tối đa ({{max}}). Vui lòng xóa một máy chủ MCP hoặc plugin hiện có trước khi thêm mới.'
+ sidebarDescription: 'Tạo Bot với các bước hướng dẫn'
+ skip: 'Bỏ qua'
+ skipConfirmMessage: 'Bạn có thể vào lại trình hướng dẫn Bắt đầu nhanh từ menu tài khoản sau, hoặc tạo Bot thủ công.'
+ skipConfirmOk: 'OK'
+ prev: 'Trước'
+ next: 'Tiếp'
+ finish: 'Tạo & Triển khai'
+ confirmCreateBot: 'Xác nhận, Tạo Bot'
+ botCreateSuccess: 'Tạo Bot thành công!'
+ botSaveSuccess: 'Cấu hình Bot đã lưu và bật!'
+ spaceAuthError: 'Khởi tạo ủy quyền Space thất bại'
+ skipSaveError: 'Lưu trạng thái bỏ qua thất bại. Vui lòng thử lại.'
+ completeSaveError: 'Lưu trạng thái hoàn tất thất bại. Vui lòng thử lại.'
+ botConfig: 'Thiết lập Bot'
+ aiEngine: 'Công cụ AI'
+ done: 'Hoàn tất'
+ saveBot: 'Lưu & Bật Bot'
+ resaveBot: 'Lưu lại cấu hình'
+ botSaved: 'Cấu hình Bot đã lưu và bật. Kiểm tra nhật ký để xác minh kết nối.'
+ logsTitle: 'Nhật ký Bot'
+ logsDescription: 'Giám sát hoạt động Bot để xác minh kết nối nền tảng đang hoạt động.'
+ action: 'Hành động'
+ botInfo: 'Thông tin Bot'
+ botNamePlaceholder: 'Nhập tên Bot'
+ botDescPlaceholder: 'Nhập mô tả Bot (tùy chọn)'
+ platformConfig: 'Cấu hình {{platform}}'
+ aiConfig: 'Cấu hình {{engine}}'
+ backToWorkbench: 'Quay lại bàn làm việc'
+ createWorkflow: 'Tạo Quy trình'
+ editWorkflow: 'Chỉnh sửa Quy trình'
+ newWorkflow: 'Quy trình Mới'
+ getWorkflowListError: 'Lỗi khi lấy danh sách quy trình:'
+ workflowName: 'Tên Quy trình'
+ workflowDescription: 'Mô tả Quy trình'
+ workflowNameRequired: 'Tên quy trình không được để trống'
+ getWorkflowError: 'Lỗi khi lấy quy trình:'
+ copyError: '复制thất bại:'
+ export: 'Xuất'
+ import: 'Nhập'
+ exportSuccess: 'Quy trình已导出'
+ importSuccess: 'Quy trình已导入'
+ publish: 'Xuất bản'
+ publishSuccess: '发布Thành công'
+ publishError: '发布thất bại'
+ executions: 'Thực thi记录'
+ editor: 'Chỉnh sửa器'
+ basicInfoDesc: '设置Quy trìnhTên、图标和Mô tả'
+ deleteWorkflowAction: 'Xóa此Quy trình'
+ deleteWorkflow: 'XóaQuy trình'
+ deleteConfirmDesc: '您确定要XóaQuy trình '
+ namePlaceholder: 'Đầu vàoQuy trìnhTên'
+ descriptionPlaceholder: 'Đầu vàoQuy trìnhMô tả(可选)'
+ enabled: 'Bật'
+ enabledDesc: '启用后,Quy trình将可以被触发Thực thi'
+ info: 'Quy trìnhThông tin'
+ uuid: 'UUID'
+ version: 'Phiên bản'
+ updatedAt: '更新Thời gian'
+ totalExecutions: '共 {{count}} 条Thực thi记录'
+ statistics: 'Thống kê分析'
+ successfulCount: 'Thành công {{count}} 次'
+ averageDuration: 'Thời lượng trung bình'
+ perExecution: '每次Thực thi'
+ failedExecutions: 'thất bại次数'
+ lastExecution: '最后Thực thi'
+ filterByStatus: '按Trạng thái筛选'
+ allStatuses: '全部Trạng thái'
+ manualTrigger: 'Kích hoạt thủ công'
+ executionId: 'Thực thi ID'
+ noExecutions: '暂无Thực thi记录'
+ executionDetails: 'Thực thi详情'
+ nodeExecutions: 'NútThực thi记录'
+ result: 'Kết quả'
+ properties: 'Thuộc tính'
+ zoomIn: 'Phóng to'
+ zoomOut: 'Thu nhỏ'
+ fitView: 'Điều chỉnh khung nhìn'
+ paste: 'Dán'
+ copied: '已复制 {{count}} 个Nút'
+ pasted: '已粘贴 {{count}} 个Nút'
+ nodesSelected: '已选中 {{count}} 个Nút'
+ edgesSelected: 'Đã chọn {{count}} kết nối'
+ loadingNodeTypes: '正在TảiNútLoại...'
+ clearSearch: '清除Tìm kiếm'
+ selectNodeOrEdge: 'ChọnmộtNút或连线'
+ edgeProperties: 'Thuộc tính kết nối'
+ conditionPlaceholder: 'Đầu vàoĐiều kiện表达式,如: output.success == true'
+ nodeLabel: 'NútTên'
+ nodeId: 'Nút ID'
+ inputOutputVariables: 'Đầu vào/Đầu raBiến'
+ outputs: 'Đầu ra'
+ messageContent: '模拟Tin nhắn'
+ nodeConfig: 'NútCấu hình'
+ noConfigOptions: '该NútLoại暂无Cấu hình选项'
+ deleteNode: 'XóaNút'
+ text: 'Văn bản'
+ data: 'Dữ liệu'
+ body: 'Vui lòng求体'
+ items: '项目Danh sách'
+ arguments: 'Tham số'
+ question: 'Người dùngCâu hỏi'
+ parameters: 'Tham số定义'
+ key: 'Khóa'
+ case_1: 'Nhánh 1'
+ case_2: 'Nhánh 2'
+ notification_id: 'Thông báoID'
+ key_template: 'Mẫu khóa'
+ arguments_template: 'Tham số模板'
+ scope: 'Phạm vi'
+ payload: 'Tải trọng'
+ input_value: 'Đầu vàoGiá trị'
+ trigger: 'Trình kích hoạt'
+ triggerDescription: 'Quy trình起始Nút'
+ messageTrigger: 'Tin nhắn触发'
+ webhookTrigger: 'Kích hoạt Webhook'
+ webhookTriggerDescription: '通过 HTTP Vui lòng求触发'
+ process: 'AI/Xử lý'
+ aiProcess: 'Xử lý AI'
+ aiProcessDescription: '使用 AI 模型处理Tin nhắn'
+ llmCall: 'LLM Gọi'
+ codeExecutor: '代码Thực thi'
+ codeExecutorDescription: 'Thực thi Python/JavaScript 代码'
+ templateProcess: 'Xử lý mẫu'
+ httpRequest: 'HTTP Vui lòng求'
+ httpRequestDescription: 'Gửi HTTP Vui lòng求'
+ dataTransform: 'Dữ liệuChuyển đổi'
+ dataTransformDescription: 'Chuyển đổiDữ liệuĐịnh dạng'
+ questionClassifier: 'Câu hỏiBộ phân loại'
+ questionClassifierDescription: '使用 LLM 将Người dùngCâu hỏiDanh mục到预定义类别'
+ parameterExtractor: 'Tham số提取器'
+ knowledgeRetrieval: 'Kiến thức库Truy xuất'
+ jsonTransform: 'JSON Chuyển đổi'
+ jsonTransformDescription: 'Chuyển đổi JSON Dữ liệu'
+ variableAssignment: 'Biến赋Giá trị'
+ variableAssignmentDescription: '为Quy trìnhBiến赋Giá trị'
+ control: 'Luồng điều khiển'
+ switch: '多路Nhánh'
+ switchDescription: '多Điều kiện分支Chọn'
+ loop: 'Vòng lặp'
+ loopDescription: '重复Thực thi'
+ iterator: 'Bộ lặp'
+ parallel: 'Song song处理'
+ wait: 'Chờ'
+ delay: 'Trì hoãn'
+ merge: 'Hợp nhất'
+ mergeDescription: 'Hợp nhất多个Nhánh'
+ actionDescription: 'Thực thi动作Nút'
+ sendMessage: 'GửiTin nhắn'
+ sendMessageDescription: 'GửiTin nhắn到平台'
+ replyMessage: '回复Tin nhắn'
+ replyMessageDescription: '回复触发Quy trìnhTin nhắn'
+ storeData: 'Lưu trữDữ liệu'
+ storeDataDescription: 'Lưu trữDữ liệu到Cơ sở dữ liệu'
+ callPipeline: 'Gọi Pipeline'
+ callPipelineDescription: '调用现有 Pipeline'
+ openingStatement: 'Hội thoại开场白'
+ end: 'Kết thúc'
+ endDescription: '标记Quy trìnhThực thiKết thúc'
+ logDescription: '记录日志Thông tin'
+ difyWorkflow: 'Dify Quy trình'
+ difyWorkflowDescription: '调用 Dify 平台Quy trình'
+ difyKnowledgeQuery: 'Dify Kiến thức库'
+ difyKnowledgeQueryDescription: 'Truy vấn Dify Kiến thức库'
+ n8nWorkflow: 'n8n Quy trình'
+ n8nWorkflowDescription: '调用 n8n Quy trình'
+ langflowFlow: 'Luồng Langflow'
+ langflowFlowDescription: 'Gọi Langflow 流程'
+ cozeBot: 'Bot Coze'
+ cozeBotDescription: 'Gọi扣子 Bot'
+ databaseQuery: 'Cơ sở dữ liệuTruy vấn'
+ redisOperation: 'Redis Thao tác'
+ redisOperationDescription: 'Thực thi Redis 缓存操作'
+ mcpTool: 'MCP Công cụ'
+ mcpToolDescription: 'Gọi MCP Công cụ'
+ nodeResults: 'NútThực thi结果'
+ current: '当前Phiên bản'
+ rollbackSuccess: '回滚Thành công'
+ rollbackError: '回滚thất bại:'
+ debug: 'Debug'
+ startDebug: 'Bắt đầuGỡ lỗi'
+ pauseDebug: 'Tạm dừng'
+ resumeDebug: 'Tiếp tục'
+ stepDebug: '单步Thực thi'
+ stopDebug: 'Dừng'
+ simulatedMessage: '模拟Tin nhắn'
+ senderId: 'Gửi者 ID'
+ senderName: 'Gửi者Tên'
+ conversationId: 'Phiên ID'
+ isGroup: 'Chat nhóm'
+ addVariable: 'ThêmBiến'
+ variableName: 'Biến名'
+ variableValue: 'BiếnGiá trị'
+ breakpoints: 'Điểm dừng'
+ toggleBreakpoint: 'Chuyển đổi断点'
+ breakpointRemoved: 'Đã xóa điểm dừng'
+ autoScroll: 'Tự động cuộn'
+ idle: 'Nhàn rỗi'
+ paused: '已Tạm dừng'
+ skipped: '已Bỏ qua'
+ selectWorkflow: 'ChọnQuy trình'
+ loadWorkflowsFailed: 'TảiQuy trìnhthất bại'
+ allTime: '全部Thời gian'
+ lastWeek: 'Tuần trước'
+ showingExecutions: 'Hiển thị {{shown}} / {{total}} bản ghi'
+ rerunExecution: '重新Thực thi'
+ details: 'Chi tiết'
+ completedAt: 'Hoàn thành时间'
+ noNodeExecutions: '暂无NútThực thi记录'
+ regex_filter: 'Biểu thức Chính quyLọc'
+ min_length: 'Tối thiểuĐộ dài'
+ require_mention: 'Yêu cầu nhắc bot'
+ access_control: 'Kiểm soát truy cập'
+ cron: 'Biểu thức Cron'
+ timezone: 'Múi giờ'
+ path: 'Đường dẫn Webhook'
+ content_type: 'Content-Type'
+ auth_type: 'Loại xác thực'
+ auth_key: 'Khóa xác thực'
+ validation: 'Vui lòng求验证'
+ filter: 'Sự kiệnLọc'
+ debounce_ms: '防抖Thời gian'
+ temperature: 'Nhiệt độ'
+ top_p: 'Top P'
+ frequency_penalty: 'Phạt tần suất'
+ presence_penalty: 'Phạt hiện diện'
+ max_tokens: 'Tối đaToken数'
+ seed: 'Seed ngẫu nhiên'
+ method: 'Vui lòng求方法'
+ auth_config: '认证Cấu hình'
+ template: 'Mẫu'
+ expression: 'Biểu thức'
+ output_type: 'Đầu raLoại'
+ categories: 'Danh mục类别'
+ instruction: 'Chỉ thị'
+ knowledge_bases: 'Kiến thức库'
+ top_k: 'Số lượng trả về'
+ search_method: 'Tìm kiếm方法'
+ condition_type: 'Điều kiệnLoại'
+ left_value: '左Giá trị'
+ operator: 'Toán tử so sánh'
+ right_value: '右Giá trị'
+ branches: '分支Cấu hình'
+ fail_fast: '快速thất bại'
+ duration_type: 'Thời gian单位'
+ merge_strategy: 'Hợp nhất策略'
+ aggregation_mode: 'Chế độ tổng hợp'
+ target_id: 'ID đích'
+ message_type: 'Tin nhắnLoại'
+ message_template: 'Tin nhắn模板'
+ force_delay: '强制Trì hoãn'
+ pipeline_uuid: 'Pipeline'
+ inherit_context: '继承Ngữ cảnh'
+ ttl: '过期Thời gian'
+ variable_name: 'BiếnTên'
+ statement: 'Tuyên bố Mở đầu'
+ show_suggestions: 'Hiển thị gợi ý'
+ success_message: 'Thành côngTin nhắn'
+ connection_url: 'Kết nốiURL'
+ dataset_id: 'Kiến thức库ID'
+ sender_id: 'Gửi者 ID'
+ sender_name: 'Gửi者Tên'
+ conversation_id: 'Phiên ID'
+ is_group: 'Là chat nhóm'
+ schedule: 'Lịch trình'
+ cron_timestamp: 'Cron Thời gian戳'
+ cron_schedule: 'Cron Biểu thức lịch trình'
+ cron_context: 'Cron Ngữ cảnh'
+ webhook_body: 'Webhook Vui lòng求体'
+ webhook_headers: 'Webhook Vui lòng求头'
+ webhook_query: 'Webhook 查询Tham số'
+ webhook_method: 'Webhook Vui lòng求方法'
+ event_data: 'Sự kiệnDữ liệu'
+ event_timestamp: 'Sự kiệnThời gian戳'
+ response: 'Phản hồi'
+ usage: 'Token使用Thống kê'
+ context_info: '上下文Thông tin'
+ output: 'Đầu ra'
+ status_code: 'Trạng thái码'
+ http_body: 'HTTP Vui lòng求体'
+ http_headers: 'HTTP Vui lòng求头'
+ http_response: 'HTTP Phản hồi'
+ response_headers: 'Phản hồi头'
+ transform_result: 'Chuyển đổi结果'
+ category: 'Danh mục结果'
+ confidence: 'Độ tin cậy'
+ all_scores: 'Tất cả điểm số'
+ extraction_success: '提取Thành công'
+ citations: '引用Thông tin'
+ knowledge_query: 'Truy xuấtTruy vấn'
+ true: 'Điều kiện为真Đầu ra'
+ false: 'Điều kiện为假Đầu ra'
+ condition_input: 'Điều kiệnĐầu vào'
+ matched_case: '匹配Nhánh'
+ default: 'Mặc địnhNhánh'
+ item: 'Mục hiện tại'
+ index: 'Chỉ số hiện tại'
+ results: 'Kết quả'
+ loop_items: 'Các mục lặp'
+ iterator_item: 'Phần tử hiện tại'
+ iterator_index: 'Chỉ số hiện tại'
+ merged: 'Hợp nhất结果'
+ aggregator_variables: 'BiếnĐầu vào'
+ message_id: 'Tin nhắnID'
+ target: 'ID đích'
+ pipeline_response: 'Pipeline Phản hồi'
+ pipeline_query: 'Truy vấn内容'
+ store_status: 'Lưu trữTrạng thái'
+ store_key: 'Lưu trữ键'
+ variable_value: 'BiếnGiá trị'
+ workflow_output: 'Quy trìnhĐầu ra'
+ query_results: 'Truy vấn结果'
+ row_count: 'Số hàng'
+ query_success: '查询Thành công'
+ query_params: '查询Tham số'
+ redis_result: 'RedisKết quả'
+ redis_success: 'RedisThành công'
+ redis_key: 'Redis Khóa'
+ redis_value: 'Redis Giá trị'
+ tool_success: '工具Thành công'
+ mcp_arguments: '工具Tham số'
+ memory_success: '记忆Thành công'
+ answer: 'Câu trả lời'
+ dify_success: 'DifyThành công'
+ dify_conversation_id: 'Phiên ID'
+ search_results: 'Tìm kiếm结果'
+ knowledge_base_query: 'Truy vấn内容'
+ n8n_result: 'N8nKết quả'
+ n8n_success: 'N8nThành công'
+ n8n_payload: 'Quy trìnhĐầu vào数据'
+ flow_success: '流程Thành công'
+ bot_success: '机器人Thành công'
+ coze_conversation_id: 'Phiên ID'
+ bot_conversation_id: 'Phiên ID'
+ event: 'Sự kiện'
+ trigger_time: '触发Thời gian'
+ parsed: 'Kết quả phân tích'
+ chunks: 'Văn bản块'
+ count: 'Số lượng'
+ execution_id: 'Thực thiID'
+ dimensions: 'Chiều'
+ entities: 'Thực thể'
+ invalidPage: 'Trang plugin không hợp lệ'
+
+缺失键列表:
+ undo
+ redo
+ bindTarget
+ bindTargetDescription
+ bindingType
+ selectBinding
+ noPipelinesFound
+ noWorkflowsFound
+ pipelineBindingHelp
+ workflowBindingHelp
+ dataOnlyHint
+ edgeCondition
+ edgeConditionPlaceholder
+ noNodeSelected
+ selectNodeToEdit
+ dragNodeHint
+ mode
+ panel
+ start
+ pause
+ resume
+ step
+ stop
+ messageContentPlaceholder
+ platformPlaceholder
+ customVariablesDesc
+ variableKey
+ noWatchedVariables
+ nodeOutputs
+ noNodeOutputs
+ clearBreakpoints
+ logEntries
+ resetContext
+ starting
+ started
+ startError
+ pauseError
+ resumed
+ resumeError
+ steppedTo
+ stepError
+ stopped
+ stopError
+ escape_html
+ trim_whitespace
+ json_transform_type
+ json_expression
+ mapping
+ code_language
+ code_content
+ aggregation_type
+ separator
+ field_path
+ split_type
+ chunk_size
+ chunk_overlap
+ regex_pattern
+ remove_empty
+ assign_variable_name
+ value_type
+ static_value
+ n8n_webhook_url
+ n8n_auth_type
+ langflow_flow_id
+ coze_bot_id
+ coze_api_base
+ workflow
+ pipelineDescription
+ noPipelines
+ noWorkflows
+
+============================================================
diff --git a/src/langbot/pkg/api/http/controller/groups/workflows/__init__.py b/src/langbot/pkg/api/http/controller/groups/workflows/__init__.py
new file mode 100644
index 000000000..619aebb88
--- /dev/null
+++ b/src/langbot/pkg/api/http/controller/groups/workflows/__init__.py
@@ -0,0 +1,5 @@
+# Workflow router group
+from .workflows import WorkflowsRouterGroup, ExecutionsRouterGroup
+from .websocket_chat import WorkflowWebSocketChatRouterGroup
+
+__all__ = ['WorkflowsRouterGroup', 'ExecutionsRouterGroup', 'WorkflowWebSocketChatRouterGroup']
diff --git a/src/langbot/pkg/api/http/controller/groups/workflows/websocket_chat.py b/src/langbot/pkg/api/http/controller/groups/workflows/websocket_chat.py
new file mode 100644
index 000000000..607380ff6
--- /dev/null
+++ b/src/langbot/pkg/api/http/controller/groups/workflows/websocket_chat.py
@@ -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//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/', 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/', 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
diff --git a/src/langbot/pkg/api/http/controller/groups/workflows/workflows.py b/src/langbot/pkg/api/http/controller/groups/workflows/workflows.py
new file mode 100644
index 000000000..6f9ac036d
--- /dev/null
+++ b/src/langbot/pkg/api/http/controller/groups/workflows/workflows.py
@@ -0,0 +1,482 @@
+from __future__ import annotations
+
+import quart
+
+from ... import group
+from ....service.workflow import WorkflowExecutionFailedError
+
+
+@group.group_class('workflows', '/api/v1/workflows')
+class WorkflowsRouterGroup(group.RouterGroup):
+ """Workflow API router group"""
+
+ async def initialize(self) -> None:
+ # Workflow CRUD
+ @self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
+ async def _() -> str:
+ if quart.request.method == 'GET':
+ sort_by = quart.request.args.get('sort_by', 'created_at')
+ sort_order = quart.request.args.get('sort_order', 'DESC')
+ enabled_only = quart.request.args.get('enabled_only', 'false').lower() == 'true'
+ return self.success(
+ data={'workflows': await self.ap.workflow_service.get_workflows(sort_by, sort_order, enabled_only)}
+ )
+ elif quart.request.method == 'POST':
+ json_data = await quart.request.json
+ workflow_uuid = await self.ap.workflow_service.create_workflow(json_data)
+ return self.success(data={'uuid': workflow_uuid})
+
+ # Get node types (available nodes for the editor)
+ @self.route('/_/node-types', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
+ async def _() -> str:
+ return self.success(
+ data={
+ 'node_types': await self.ap.workflow_service.get_node_types(),
+ 'categories': await self.ap.workflow_service.get_node_types_by_category_meta(),
+ }
+ )
+
+ # Get node types by category
+ @self.route('/_/node-types/categories', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
+ async def _() -> str:
+ return self.success(data={'categories': await self.ap.workflow_service.get_node_types_by_category()})
+
+ # Single workflow operations
+ @self.route(
+ '/', 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('//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('//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('//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('//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('//executions', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
+ async def _(workflow_uuid: str) -> str:
+ limit = int(quart.request.args.get('limit', 50))
+ offset = int(quart.request.args.get('offset', 0))
+ executions = await self.ap.workflow_service.get_executions(
+ workflow_uuid=workflow_uuid, limit=limit, offset=offset
+ )
+ return self.success(data=executions)
+
+ @self.route(
+ '//executions/',
+ methods=['GET'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ )
+ async def _(workflow_uuid: str, execution_uuid: str) -> str:
+ execution = await self.ap.workflow_service.get_execution(execution_uuid)
+ if execution is None:
+ return self.http_status(404, -1, 'execution not found')
+ if execution.get('workflow_uuid') != workflow_uuid:
+ return self.http_status(404, -1, 'execution not found in workflow')
+ return self.success(data={'execution': execution})
+
+ # Get workflow versions
+ @self.route('//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(
+ '//rollback/', 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(
+ '//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('//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(
+ '//debug//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(
+ '//debug//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(
+ '//debug//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(
+ '//debug//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(
+ '//debug//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(
+ '//executions//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(
+ '//executions//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('//stats', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
+ async def _(workflow_uuid: str) -> str:
+ try:
+ stats = await self.ap.workflow_service.get_workflow_stats(workflow_uuid)
+ return self.success(data=stats)
+ except ValueError as e:
+ return self.http_status(404, -1, str(e))
+
+ # LLM Node Performance Test Endpoint
+ # Tests each step of LLM node execution with detailed timing
+ @self.route('/_/test/llm-node', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
+ async def _() -> str:
+ """Test LLM node performance with detailed step-by-step timing.
+
+ Request body:
+ {
+ "model_uuid": "uuid-of-model",
+ "system_prompt": "optional system prompt",
+ "user_prompt": "test message",
+ "temperature": 0.7,
+ "max_tokens": 100
+ }
+
+ Response includes timing for each step:
+ - model_fetch: Time to get model from model_mgr
+ - prompt_build: Time to build messages
+ - llm_call: Time for actual LLM invocation
+ - total: Total time
+ - usage: Token usage information
+ """
+ import time
+
+ json_data = await quart.request.json
+ if not json_data:
+ return self.http_status(400, -1, 'Request body is required')
+
+ model_uuid = json_data.get('model_uuid', '')
+ if not model_uuid:
+ return self.http_status(400, -1, 'model_uuid is required')
+
+ user_prompt = json_data.get('user_prompt', 'test')
+ system_prompt = json_data.get('system_prompt', '')
+ temperature = json_data.get('temperature')
+ max_tokens = json_data.get('max_tokens', 0)
+
+ timings = {}
+ errors = []
+
+ # Step 1: Model fetch
+ t_start = time.perf_counter()
+ try:
+ runtime_model = await self.ap.model_mgr.get_model_by_uuid(model_uuid)
+ timings['model_fetch_ms'] = round((time.perf_counter() - t_start) * 1000, 2)
+ timings['model_found'] = True
+ timings['model_name'] = runtime_model.model_entity.name if runtime_model else None
+ except Exception as e:
+ timings['model_fetch_ms'] = round((time.perf_counter() - t_start) * 1000, 2)
+ timings['model_found'] = False
+ errors.append(f'Model fetch failed: {str(e)}')
+ return self.http_status(400, -1, {
+ 'error': errors[0],
+ 'timings': timings,
+ })
+
+ # Step 2: Build messages
+ t_start = time.perf_counter()
+ import langbot_plugin.api.entities.builtin.provider.message as provider_message
+ messages = []
+ if system_prompt:
+ messages.append(provider_message.Message(role='system', content=system_prompt))
+ messages.append(provider_message.Message(role='user', content=user_prompt))
+ timings['prompt_build_ms'] = round((time.perf_counter() - t_start) * 1000, 2)
+
+ # Step 3: Build extra args
+ extra_args = {}
+ if temperature is not None:
+ extra_args['temperature'] = float(temperature)
+ if max_tokens and int(max_tokens) > 0:
+ extra_args['max_tokens'] = int(max_tokens)
+
+ # Step 4: LLM call
+ t_start = time.perf_counter()
+ try:
+ result_message = await runtime_model.provider.invoke_llm(
+ query=None,
+ model=runtime_model,
+ messages=messages,
+ funcs=None,
+ extra_args=extra_args,
+ )
+ timings['llm_call_ms'] = round((time.perf_counter() - t_start) * 1000, 2)
+ timings['llm_call_success'] = True
+
+ # Extract response text
+ response_text = ''
+ if isinstance(result_message.content, str):
+ response_text = result_message.content
+ elif isinstance(result_message.content, list):
+ for elem in result_message.content:
+ if hasattr(elem, 'text') and elem.text:
+ response_text += elem.text
+ elif isinstance(elem, str):
+ response_text += elem
+
+ timings['response_length'] = len(response_text)
+ timings['response_preview'] = response_text[:200]
+
+ # Extract usage
+ usage = {'prompt_tokens': 0, 'completion_tokens': 0, 'total_tokens': 0}
+ if hasattr(result_message, 'usage') and result_message.usage:
+ u = result_message.usage
+ usage = {
+ 'prompt_tokens': getattr(u, 'prompt_tokens', 0) or 0,
+ 'completion_tokens': getattr(u, 'completion_tokens', 0) or 0,
+ 'total_tokens': getattr(u, 'total_tokens', 0) or 0,
+ }
+ timings['usage'] = usage
+
+ except Exception as e:
+ timings['llm_call_ms'] = round((time.perf_counter() - t_start) * 1000, 2)
+ timings['llm_call_success'] = False
+ errors.append(f'LLM call failed: {str(e)}')
+
+ # Calculate total
+ timings['total_ms'] = round(sum([
+ timings.get('model_fetch_ms', 0),
+ timings.get('prompt_build_ms', 0),
+ timings.get('llm_call_ms', 0),
+ ]), 2)
+
+ # Add breakdown percentage
+ if timings['total_ms'] > 0:
+ timings['breakdown'] = {
+ 'model_fetch_pct': round(timings.get('model_fetch_ms', 0) / timings['total_ms'] * 100, 1),
+ 'prompt_build_pct': round(timings.get('prompt_build_ms', 0) / timings['total_ms'] * 100, 1),
+ 'llm_call_pct': round(timings.get('llm_call_ms', 0) / timings['total_ms'] * 100, 1),
+ }
+
+ if errors:
+ timings['errors'] = errors
+
+ return self.success(data={'test_result': timings})
+
+
+@group.group_class('executions', '/api/v1/executions')
+class ExecutionsRouterGroup(group.RouterGroup):
+ """Workflow execution API router group"""
+
+ async def initialize(self) -> None:
+ # Get all executions (across all workflows)
+ @self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
+ async def _() -> str:
+ limit = int(quart.request.args.get('limit', 50))
+ offset = int(quart.request.args.get('offset', 0))
+ status = quart.request.args.get('status')
+ executions = await self.ap.workflow_service.get_executions(limit=limit, offset=offset, status=status)
+ return self.success(data=executions)
+
+ # Get single execution
+ @self.route('/', 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('//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))
diff --git a/src/langbot/pkg/api/http/controller/main.py b/src/langbot/pkg/api/http/controller/main.py
index 2e366c3ef..62da86f48 100644
--- a/src/langbot/pkg/api/http/controller/main.py
+++ b/src/langbot/pkg/api/http/controller/main.py
@@ -17,6 +17,7 @@
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_pipelines)
importutil.import_modules_in_pkg(groups_knowledge)
importutil.import_modules_in_pkg(groups_resources)
+importutil.import_modules_in_pkg(groups_workflows)
class HTTPController:
diff --git a/src/langbot/pkg/api/http/service/bot.py b/src/langbot/pkg/api/http/service/bot.py
index b8af08613..06898dac6 100644
--- a/src/langbot/pkg/api/http/service/bot.py
+++ b/src/langbot/pkg/api/http/service/bot.py
@@ -99,16 +99,23 @@ async def create_bot(self, bot_data: dict) -> str:
# TODO: 检查配置信息格式
bot_data['uuid'] = str(uuid.uuid4())
- # bind the most recently updated pipeline if any exist
+ # Set default binding_type if not provided
+ if 'binding_type' not in bot_data:
+ bot_data['binding_type'] = 'pipeline'
+
+ # checkout the default pipeline (for backward compatibility)
result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.select(persistence_pipeline.LegacyPipeline)
- .order_by(persistence_pipeline.LegacyPipeline.updated_at.desc())
- .limit(1)
+ sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
+ persistence_pipeline.LegacyPipeline.is_default == True
+ )
)
pipeline = result.first()
if pipeline is not None:
bot_data['use_pipeline_uuid'] = pipeline.uuid
bot_data['use_pipeline_name'] = pipeline.name
+ # Also set binding_uuid for new unified binding model
+ if 'binding_uuid' not in bot_data:
+ bot_data['binding_uuid'] = pipeline.uuid
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_bot.Bot).values(bot_data))
@@ -120,26 +127,38 @@ async def create_bot(self, bot_data: dict) -> str:
async def update_bot(self, bot_uuid: str, bot_data: dict) -> None:
"""Update bot"""
- update_data = bot_data.copy()
+ if 'uuid' in bot_data:
+ del bot_data['uuid']
- if 'uuid' in update_data:
- del update_data['uuid']
+ # Handle binding_type and binding_uuid for the new unified binding model
+ # If binding_type is explicitly set to 'workflow', skip pipeline validation
+ binding_type = bot_data.get('binding_type')
- # set use_pipeline_name
- if 'use_pipeline_uuid' in update_data:
+ # set use_pipeline_name (for backward compatibility with 'pipeline' binding_type)
+ if 'use_pipeline_uuid' in bot_data:
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
- persistence_pipeline.LegacyPipeline.uuid == update_data['use_pipeline_uuid']
+ persistence_pipeline.LegacyPipeline.uuid == bot_data['use_pipeline_uuid']
)
)
pipeline = result.first()
if pipeline is not None:
- update_data['use_pipeline_name'] = pipeline.name
+ bot_data['use_pipeline_name'] = pipeline.name
+ # Also sync to binding_uuid if binding_type is 'pipeline' or not set
+ if binding_type is None or binding_type == 'pipeline':
+ bot_data['binding_uuid'] = bot_data['use_pipeline_uuid']
+ bot_data['binding_type'] = 'pipeline'
else:
raise Exception('Pipeline not found')
+ # If binding_uuid is set directly (for workflow), sync use_pipeline_uuid for backward compatibility
+ if 'binding_uuid' in bot_data and binding_type == 'workflow':
+ # For workflow binding, we don't sync to use_pipeline_uuid
+ # but we ensure binding_type is correctly set
+ bot_data['binding_type'] = 'workflow'
+
await self.ap.persistence_mgr.execute_async(
- sqlalchemy.update(persistence_bot.Bot).values(update_data).where(persistence_bot.Bot.uuid == bot_uuid)
+ sqlalchemy.update(persistence_bot.Bot).values(bot_data).where(persistence_bot.Bot.uuid == bot_uuid)
)
await self.ap.platform_mgr.remove_bot(bot_uuid)
diff --git a/src/langbot/pkg/api/http/service/pipeline.py b/src/langbot/pkg/api/http/service/pipeline.py
index 9175aba55..521b24325 100644
--- a/src/langbot/pkg/api/http/service/pipeline.py
+++ b/src/langbot/pkg/api/http/service/pipeline.py
@@ -73,6 +73,20 @@ async def get_pipeline(self, pipeline_uuid: str) -> dict | None:
return self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)
+ async def get_pipeline_by_name(self, pipeline_name: str) -> dict | None:
+ result = await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
+ persistence_pipeline.LegacyPipeline.name == pipeline_name
+ )
+ )
+
+ pipeline = result.first()
+
+ if pipeline is None:
+ return None
+
+ return self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)
+
async def create_pipeline(self, pipeline_data: dict, default: bool = False) -> str:
from ....utils import paths as path_utils
diff --git a/src/langbot/pkg/api/http/service/workflow.py b/src/langbot/pkg/api/http/service/workflow.py
new file mode 100644
index 000000000..77297fa62
--- /dev/null
+++ b/src/langbot/pkg/api/http/service/workflow.py
@@ -0,0 +1,1138 @@
+"""Workflow service for managing workflow CRUD and execution"""
+
+from __future__ import annotations
+
+import asyncio
+import uuid
+from datetime import datetime, timedelta
+from typing import Optional
+
+import sqlalchemy
+
+from ....core import app
+from ....entity.persistence import workflow as persistence_workflow
+from ....workflow.entities import (
+ WorkflowDefinition,
+ ExecutionContext,
+ ExecutionStatus,
+ NodeDefinition,
+ EdgeDefinition,
+ Position,
+ MessageContext,
+ NodeStatus,
+)
+from ....workflow.executor import WorkflowExecutor
+from ....workflow.registry import NodeTypeRegistry
+
+
+class WorkflowExecutionFailedError(Exception):
+ """Raised when a workflow execution finishes with failed status."""
+
+ def __init__(
+ self,
+ execution_id: str,
+ message: str,
+ *,
+ details: str | None = None,
+ ) -> None:
+ super().__init__(message)
+ self.execution_id = execution_id
+ self.message = message
+ self.details = details or message
+
+
+class WorkflowService:
+ """Workflow service for managing workflows"""
+
+ DEFAULT_MAX_EXECUTION_TIME = 300
+ STALE_EXECUTION_TIMEOUT_SECONDS = 300
+
+ ap: app.Application
+
+ def __init__(self, ap: app.Application) -> None:
+ self.ap = ap
+ self.executor = WorkflowExecutor(ap)
+ self.registry = NodeTypeRegistry.instance()
+
+ # Import workflow nodes to trigger registration
+ from ....workflow import nodes # noqa: F401
+
+ async def get_workflows(
+ self, sort_by: str = 'created_at', sort_order: str = 'DESC', enabled_only: bool = False
+ ) -> list[dict]:
+ """Get all workflows"""
+ query = sqlalchemy.select(persistence_workflow.Workflow)
+
+ if enabled_only:
+ query = query.where(persistence_workflow.Workflow.is_enabled == True)
+
+ if sort_by == 'created_at':
+ if sort_order == 'DESC':
+ query = query.order_by(persistence_workflow.Workflow.created_at.desc())
+ else:
+ query = query.order_by(persistence_workflow.Workflow.created_at.asc())
+ elif sort_by == 'updated_at':
+ if sort_order == 'DESC':
+ query = query.order_by(persistence_workflow.Workflow.updated_at.desc())
+ else:
+ query = query.order_by(persistence_workflow.Workflow.updated_at.asc())
+ elif sort_by == 'name':
+ if sort_order == 'DESC':
+ query = query.order_by(persistence_workflow.Workflow.name.desc())
+ else:
+ query = query.order_by(persistence_workflow.Workflow.name.asc())
+
+ result = await self.ap.persistence_mgr.execute_async(query)
+ workflows = result.all()
+
+ return [self._serialize_workflow(workflow) for workflow in workflows]
+
+ async def get_workflow(self, workflow_uuid: str) -> Optional[dict]:
+ """Get a single workflow by UUID"""
+ result = await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.select(persistence_workflow.Workflow).where(persistence_workflow.Workflow.uuid == workflow_uuid)
+ )
+
+ workflow = result.first()
+
+ if workflow is None:
+ return None
+
+ return self._serialize_workflow(workflow)
+
+ async def create_workflow(self, workflow_data: dict) -> str:
+ """Create a new workflow"""
+ # Check limitation
+ limitation = self.ap.instance_config.data.get('system', {}).get('limitation', {})
+ max_workflows = limitation.get('max_workflows', -1)
+ if max_workflows >= 0:
+ existing_workflows = await self.get_workflows()
+ if len(existing_workflows) >= max_workflows:
+ raise ValueError(f'Maximum number of workflows ({max_workflows}) reached')
+
+ workflow_uuid = str(uuid.uuid4())
+
+ # Prepare workflow data
+ new_workflow = {
+ 'uuid': workflow_uuid,
+ 'name': workflow_data.get('name', 'New Workflow'),
+ 'description': workflow_data.get('description', ''),
+ 'emoji': workflow_data.get('emoji', '💼'),
+ 'version': 1,
+ 'is_enabled': workflow_data.get('is_enabled', True),
+ 'definition': workflow_data.get(
+ 'definition',
+ {
+ 'nodes': [],
+ 'edges': [],
+ 'variables': {},
+ },
+ ),
+ 'global_config': workflow_data.get('global_config', {}),
+ 'extensions_preferences': workflow_data.get(
+ 'extensions_preferences',
+ {
+ 'enable_all_plugins': True,
+ 'enable_all_mcp_servers': True,
+ 'plugins': [],
+ 'mcp_servers': [],
+ },
+ ),
+ }
+
+ await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.insert(persistence_workflow.Workflow).values(**new_workflow)
+ )
+
+ return workflow_uuid
+
+ async def update_workflow(self, workflow_uuid: str, workflow_data: dict) -> None:
+ """Update an existing workflow"""
+ # Remove protected fields
+ protected_fields = ['uuid', 'created_at']
+ for field in protected_fields:
+ workflow_data.pop(field, None)
+
+ # Get current workflow to check version
+ current = await self.get_workflow(workflow_uuid)
+ if current is None:
+ raise ValueError(f'Workflow {workflow_uuid} not found')
+
+ # Handle nodes, edges and variables fields - merge them into definition
+ # This handles the case where frontend sends nodes/edges/variables at top level
+ nodes = workflow_data.pop('nodes', None)
+ edges = workflow_data.pop('edges', None)
+ variables = workflow_data.pop('variables', None)
+
+ # Remove fields that don't exist as database columns
+ workflow_data.pop('settings', None)
+ workflow_data.pop('triggers', None)
+
+ if nodes is not None or edges is not None or variables is not None:
+ # Get current definition or create new one
+ definition = workflow_data.get('definition', current.get('definition', {}))
+ if not isinstance(definition, dict):
+ definition = {}
+
+ # Update nodes, edges and variables in definition
+ if nodes is not None:
+ definition['nodes'] = nodes
+ if edges is not None:
+ definition['edges'] = edges
+ if variables is not None:
+ definition['variables'] = variables
+
+ workflow_data['definition'] = definition
+
+ # Increment version if definition changed
+ if 'definition' in workflow_data:
+ workflow_data['version'] = current.get('version', 0) + 1
+
+ # Save version history
+ await self._save_version_history(
+ workflow_uuid,
+ current.get('version', 1),
+ current.get('definition', {}),
+ current.get('global_config', {}),
+ )
+
+ await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.update(persistence_workflow.Workflow)
+ .where(persistence_workflow.Workflow.uuid == workflow_uuid)
+ .values(**workflow_data)
+ )
+
+ async def delete_workflow(self, workflow_uuid: str) -> None:
+ """Delete a workflow"""
+ # Delete related records first
+ await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.delete(persistence_workflow.WorkflowVersion).where(
+ persistence_workflow.WorkflowVersion.workflow_uuid == workflow_uuid
+ )
+ )
+ await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.delete(persistence_workflow.WorkflowTrigger).where(
+ persistence_workflow.WorkflowTrigger.workflow_uuid == workflow_uuid
+ )
+ )
+
+ # Delete the workflow
+ await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.delete(persistence_workflow.Workflow).where(persistence_workflow.Workflow.uuid == workflow_uuid)
+ )
+
+ async def copy_workflow(self, workflow_uuid: str) -> str:
+ """Copy a workflow"""
+ # Check limitation
+ limitation = self.ap.instance_config.data.get('system', {}).get('limitation', {})
+ max_workflows = limitation.get('max_workflows', -1)
+ if max_workflows >= 0:
+ existing_workflows = await self.get_workflows()
+ if len(existing_workflows) >= max_workflows:
+ raise ValueError(f'Maximum number of workflows ({max_workflows}) reached')
+
+ # Get original workflow
+ original = await self.get_workflow(workflow_uuid)
+ if original is None:
+ raise ValueError(f'Workflow {workflow_uuid} not found')
+
+ # Create copy
+ new_uuid = str(uuid.uuid4())
+ new_workflow = {
+ 'uuid': new_uuid,
+ 'name': f'{original["name"]} (Copy)',
+ 'description': original.get('description', ''),
+ 'emoji': original.get('emoji', '🔄'),
+ 'version': 1,
+ 'is_enabled': False, # Disabled by default
+ 'definition': original.get('definition', {}),
+ 'global_config': original.get('global_config', {}),
+ 'extensions_preferences': original.get(
+ 'extensions_preferences',
+ {
+ 'enable_all_plugins': True,
+ 'enable_all_mcp_servers': True,
+ 'plugins': [],
+ 'mcp_servers': [],
+ },
+ ),
+ }
+
+ await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.insert(persistence_workflow.Workflow).values(**new_workflow)
+ )
+
+ return new_uuid
+
+ async def execute_workflow(
+ self,
+ workflow_uuid: str,
+ trigger_type: str = 'manual',
+ trigger_data: Optional[dict] = None,
+ session_id: Optional[str] = None,
+ user_id: Optional[str] = None,
+ bot_id: Optional[str] = None,
+ ) -> str:
+ """Execute a workflow and return execution ID"""
+ workflow_dict = await self.get_workflow(workflow_uuid)
+ if workflow_dict is None:
+ raise ValueError(f'Workflow {workflow_uuid} not found')
+
+ # Create execution record
+ execution_uuid = str(uuid.uuid4())
+ execution_record = {
+ 'uuid': execution_uuid,
+ 'workflow_uuid': workflow_uuid,
+ 'workflow_version': workflow_dict.get('version', 1),
+ 'status': ExecutionStatus.RUNNING.value,
+ 'trigger_type': trigger_type,
+ 'trigger_data': trigger_data or {},
+ 'variables': {},
+ 'start_time': datetime.now(),
+ }
+
+ await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.insert(persistence_workflow.WorkflowExecution).values(**execution_record)
+ )
+
+ # Build WorkflowDefinition from dict
+ definition = workflow_dict.get('definition', {})
+ workflow = WorkflowDefinition(
+ uuid=workflow_uuid,
+ name=workflow_dict.get('name', ''),
+ description=workflow_dict.get('description', ''),
+ version=workflow_dict.get('version', 1),
+ nodes=[
+ NodeDefinition(
+ id=n.get('id', ''),
+ type=n.get('type', ''),
+ name=n.get('name', ''),
+ position=Position(**n.get('position', {'x': 0, 'y': 0})),
+ config=n.get('config', {}),
+ )
+ for n in definition.get('nodes', [])
+ ],
+ edges=[
+ EdgeDefinition(
+ id=e.get('id', ''),
+ source_node=e.get('source_node', '') or e.get('source', ''),
+ source_port=e.get('source_port', '') or e.get('sourceHandle', 'output'),
+ target_node=e.get('target_node', '') or e.get('target', ''),
+ target_port=e.get('target_port', '') or e.get('targetHandle', 'input'),
+ condition=e.get('condition') or (e.get('data', {}) or {}).get('condition'),
+ )
+ for e in definition.get('edges', [])
+ ],
+ variables=definition.get('variables', {}),
+ )
+
+ raw_trigger_data = trigger_data or {}
+
+ # Create execution context
+ context = ExecutionContext(
+ execution_id=execution_uuid,
+ workflow_id=workflow_uuid,
+ workflow_version=workflow_dict.get('version', 1),
+ trigger_type=trigger_type,
+ trigger_data=raw_trigger_data,
+ session_id=session_id,
+ user_id=user_id,
+ bot_id=bot_id,
+ )
+
+ if trigger_type == 'message':
+ message_context_data = raw_trigger_data.get('message_context') or {}
+ # Fallback: if message_context is missing but trigger_data has 'message',
+ # construct a minimal message_context so rerun and downstream nodes work.
+ if not message_context_data and raw_trigger_data.get('message'):
+ raw_msg = raw_trigger_data['message']
+ message_context_data = {
+ 'message_id': str(raw_trigger_data.get('message_id', execution_uuid)),
+ 'message_content': raw_msg if isinstance(raw_msg, str) else str(raw_msg),
+ 'sender_id': str(raw_trigger_data.get('sender_id', '')),
+ 'sender_name': str(raw_trigger_data.get('sender_name', 'User')),
+ 'platform': str(raw_trigger_data.get('platform', '')),
+ 'conversation_id': str(raw_trigger_data.get('connection_id', '')),
+ 'is_group': bool(raw_trigger_data.get('is_group', False)),
+ 'group_id': raw_trigger_data.get('group_id'),
+ 'mentions': raw_trigger_data.get('mentions', []),
+ 'reply_to': raw_trigger_data.get('reply_to'),
+ 'raw_message': raw_trigger_data.get('raw_message', {}),
+ }
+ if message_context_data:
+ context.message_context = MessageContext(
+ message_id=str(message_context_data.get('message_id', execution_uuid)),
+ message_content=str(message_context_data.get('message_content', '')),
+ sender_id=str(message_context_data.get('sender_id', '')),
+ sender_name=str(message_context_data.get('sender_name', '')),
+ platform=str(message_context_data.get('platform', '')),
+ conversation_id=str(message_context_data.get('conversation_id', '')),
+ is_group=bool(message_context_data.get('is_group', False)),
+ group_id=(
+ str(message_context_data.get('group_id'))
+ if message_context_data.get('group_id') is not None
+ else None
+ ),
+ mentions=[str(item) for item in message_context_data.get('mentions', [])],
+ reply_to=(
+ str(message_context_data.get('reply_to'))
+ if message_context_data.get('reply_to') is not None
+ else None
+ ),
+ raw_message=message_context_data.get('raw_message', {}),
+ )
+
+ max_execution_time = self.DEFAULT_MAX_EXECUTION_TIME
+ workflow_settings = definition.get('settings', {}) if isinstance(definition, dict) else {}
+ if isinstance(workflow_settings, dict):
+ raw_timeout = workflow_settings.get('max_execution_time', self.DEFAULT_MAX_EXECUTION_TIME)
+ try:
+ max_execution_time = int(raw_timeout)
+ except (TypeError, ValueError):
+ max_execution_time = self.DEFAULT_MAX_EXECUTION_TIME
+ if max_execution_time <= 0:
+ max_execution_time = self.DEFAULT_MAX_EXECUTION_TIME
+
+ # Execute asynchronously (in production, this should be done in a background task)
+ try:
+ context = await asyncio.wait_for(
+ self.executor.execute(workflow, context),
+ timeout=max_execution_time,
+ )
+
+ error_message = context.error
+ if context.status == ExecutionStatus.FAILED and not error_message:
+ failed_nodes = [
+ state
+ for state in context.node_states.values()
+ if getattr(state, 'status', None) == NodeStatus.FAILED
+ ]
+ error_message = next(
+ (state.error for state in failed_nodes if getattr(state, 'error', None)),
+ 'Workflow execution failed',
+ )
+
+ # Update execution record
+ await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.update(persistence_workflow.WorkflowExecution)
+ .where(persistence_workflow.WorkflowExecution.uuid == execution_uuid)
+ .values(
+ status=context.status.value,
+ end_time=context.end_time,
+ error=error_message,
+ variables=context.variables,
+ )
+ )
+
+ if context.status == ExecutionStatus.FAILED:
+ raise WorkflowExecutionFailedError(
+ execution_uuid,
+ error_message,
+ details=context.error or error_message,
+ )
+ except asyncio.TimeoutError as e:
+ timeout_message = f'Workflow execution timed out after {max_execution_time} seconds'
+ await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.update(persistence_workflow.WorkflowExecution)
+ .where(persistence_workflow.WorkflowExecution.uuid == execution_uuid)
+ .values(
+ status=ExecutionStatus.CANCELLED.value,
+ end_time=datetime.now(),
+ error=timeout_message,
+ )
+ )
+ raise WorkflowExecutionFailedError(
+ execution_uuid,
+ timeout_message,
+ details=str(e),
+ ) from e
+ except WorkflowExecutionFailedError:
+ raise
+ except Exception as e:
+ # Update execution record with error
+ await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.update(persistence_workflow.WorkflowExecution)
+ .where(persistence_workflow.WorkflowExecution.uuid == execution_uuid)
+ .values(
+ status=ExecutionStatus.FAILED.value,
+ end_time=datetime.now(),
+ error=str(e),
+ )
+ )
+ raise WorkflowExecutionFailedError(
+ execution_uuid,
+ str(e),
+ details=context.error or str(e),
+ ) from e
+
+ return execution_uuid
+
+ async def get_executions(
+ self, workflow_uuid: Optional[str] = None, limit: int = 50, offset: int = 0, status: Optional[str] = None
+ ) -> dict:
+ """Get workflow executions with total count"""
+ base_filter = []
+
+ if workflow_uuid:
+ base_filter.append(persistence_workflow.WorkflowExecution.workflow_uuid == workflow_uuid)
+
+ if status:
+ base_filter.append(persistence_workflow.WorkflowExecution.status == status)
+
+ # Get total count
+ count_query = sqlalchemy.select(sqlalchemy.func.count(persistence_workflow.WorkflowExecution.uuid))
+ for f in base_filter:
+ count_query = count_query.where(f)
+ count_result = await self.ap.persistence_mgr.execute_async(count_query)
+ total = count_result.scalar() or 0
+
+ # Get paginated results
+ query = sqlalchemy.select(persistence_workflow.WorkflowExecution)
+ for f in base_filter:
+ query = query.where(f)
+
+ query = query.order_by(persistence_workflow.WorkflowExecution.created_at.desc()).limit(limit).offset(offset)
+
+ result = await self.ap.persistence_mgr.execute_async(query)
+ executions = result.all()
+
+ return {
+ 'executions': [self._serialize_execution(execution) for execution in executions],
+ 'total': total,
+ }
+
+ async def get_execution(self, execution_uuid: str) -> Optional[dict]:
+ """Get a single execution by UUID"""
+ result = await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.select(persistence_workflow.WorkflowExecution).where(
+ persistence_workflow.WorkflowExecution.uuid == execution_uuid
+ )
+ )
+
+ execution = result.first()
+
+ if execution is None:
+ return None
+
+ data = self._serialize_execution(execution)
+
+ node_exec_query = (
+ sqlalchemy.select(persistence_workflow.WorkflowNodeExecution)
+ .where(persistence_workflow.WorkflowNodeExecution.execution_uuid == execution_uuid)
+ .order_by(persistence_workflow.WorkflowNodeExecution.id.asc())
+ )
+ node_exec_result = await self.ap.persistence_mgr.execute_async(node_exec_query)
+ node_executions = node_exec_result.all()
+ data['node_executions'] = [self._serialize_node_execution(node_exec) for node_exec in node_executions]
+ return data
+
+ async def get_node_types(self) -> list[dict]:
+ """Get all available node types"""
+ # Process pending registrations
+ self.registry.process_pending_registrations()
+ node_types = self.registry.list_all()
+
+ # Enrich node schemas with pipeline config metadata
+ return self._enrich_node_type_configs(node_types)
+
+ async def get_node_types_by_category(self) -> dict[str, list[dict]]:
+ """Get node types organized by category"""
+ self.registry.process_pending_registrations()
+ categories = self.registry.get_categories()
+
+ # Enrich node schemas with pipeline config metadata
+ for category, nodes in categories.items():
+ categories[category] = self._enrich_node_type_configs(nodes)
+
+ return categories
+
+ async def get_node_types_by_category_meta(self) -> list[dict]:
+ """Get workflow node category metadata for the editor UI."""
+ self.registry.process_pending_registrations()
+ categories = self.registry.get_categories()
+
+ ordered_categories = ['trigger', 'process', 'control', 'action', 'integration', 'misc']
+ label_map = {
+ 'trigger': {'en-US': 'Trigger', 'en': 'Trigger', 'zh-Hans': '触发器', 'zh-CN': '触发器'},
+ 'process': {'en-US': 'Process', 'en': 'Process', 'zh-Hans': '处理', 'zh-CN': '处理'},
+ 'control': {'en-US': 'Control', 'en': 'Control', 'zh-Hans': '控制', 'zh-CN': '控制'},
+ 'action': {'en-US': 'Action', 'en': 'Action', 'zh-Hans': '动作', 'zh-CN': '动作'},
+ 'integration': {'en-US': 'Integration', 'en': 'Integration', 'zh-Hans': '集成', 'zh-CN': '集成'},
+ 'misc': {'en-US': 'Misc', 'en': 'Misc', 'zh-Hans': '其他', 'zh-CN': '其他'},
+ }
+
+ result = []
+ for order, category_name in enumerate(ordered_categories):
+ if category_name not in categories:
+ continue
+ result.append(
+ {
+ 'name': category_name,
+ 'label': label_map.get(
+ category_name,
+ {
+ 'en-US': category_name,
+ 'en': category_name,
+ 'zh-Hans': category_name,
+ 'zh-CN': category_name,
+ },
+ ),
+ 'order': order,
+ }
+ )
+
+ return result
+
+ def _enrich_node_type_configs(self, node_types: list[dict]) -> list[dict]:
+ """Enrich node schemas with pipeline config metadata if available.
+
+ Dedicated workflow node YAML metadata is already applied by
+ NodeTypeRegistry before this method runs. This method only keeps the
+ older pipeline metadata reuse path for nodes that explicitly request it.
+ """
+ for node_schema in node_types:
+ node_schema.setdefault('config_schema', [])
+
+ config_schema_source = node_schema.get('config_schema_source')
+ config_stages = node_schema.get('config_stages', [])
+
+ if config_schema_source and config_stages:
+ pipeline_meta = self._get_pipeline_config_meta(config_schema_source)
+ if pipeline_meta:
+ enriched_configs = []
+ for stage_name in config_stages:
+ stage_data = self._find_stage_in_pipeline(pipeline_meta, stage_name)
+ if stage_data and 'config' in stage_data:
+ enriched_configs.extend(stage_data['config'])
+
+ existing_names = {cfg.get('name') for cfg in node_schema.get('config_schema', [])}
+ for cfg in enriched_configs:
+ if cfg.get('name') not in existing_names:
+ normalized_cfg = self._normalize_config_item(cfg)
+ node_schema['config_schema'].append(normalized_cfg)
+ existing_names.add(cfg.get('name'))
+
+ return node_types
+
+ def _normalize_port_item(self, port: dict) -> dict:
+ """Normalize a port (input/output) item from YAML format to frontend-compatible format"""
+ label = port.get('label')
+ if isinstance(label, dict):
+ normalized_label = {
+ 'en_US': label.get('en_US', label.get('en', '')),
+ 'en': label.get('en_US', label.get('en', '')),
+ 'zh_Hans': label.get('zh_Hans', label.get('zh', '')),
+ }
+ port = {**port, 'label': normalized_label}
+ else:
+ name = port.get('name', '')
+ port = {
+ **port,
+ 'label': {
+ 'en_US': name,
+ 'en': name,
+ 'zh_Hans': name,
+ },
+ }
+
+ description = port.get('description')
+ if isinstance(description, dict):
+ normalized_desc = {
+ 'en_US': description.get('en_US', description.get('en', '')),
+ 'en': description.get('en_US', description.get('en', '')),
+ 'zh_Hans': description.get('zh_Hans', description.get('zh', '')),
+ }
+ port = {**port, 'description': normalized_desc}
+ else:
+ port = {
+ **port,
+ 'description': {
+ 'en_US': '',
+ 'en': '',
+ 'zh_Hans': '',
+ },
+ }
+
+ return port
+
+ def _normalize_config_item(self, cfg: dict) -> dict:
+ """Normalize config item from YAML format to frontend-compatible format"""
+ # Ensure label is in proper i18n format (using underscore format like en_US, zh_Hans)
+ label = cfg.get('label')
+ if isinstance(label, dict):
+ # Convert YAML label to frontend i18n format
+ normalized_label = {
+ 'en_US': label.get('en_US', label.get('en', '')),
+ 'en': label.get('en_US', label.get('en', '')),
+ 'zh_Hans': label.get('zh_Hans', label.get('zh', '')),
+ }
+ cfg = {**cfg, 'label': normalized_label}
+ else:
+ # Create default label from name (handles both None and non-dict cases)
+ name = cfg.get('name', '')
+ cfg = {
+ **cfg,
+ 'label': {
+ 'en_US': name,
+ 'en': name,
+ 'zh_Hans': name,
+ },
+ }
+
+ # Ensure description is in proper i18n format
+ description = cfg.get('description')
+ if isinstance(description, dict):
+ normalized_desc = {
+ 'en_US': description.get('en_US', description.get('en', '')),
+ 'en': description.get('en_US', description.get('en', '')),
+ 'zh_Hans': description.get('zh_Hans', description.get('zh', '')),
+ }
+ cfg = {**cfg, 'description': normalized_desc}
+ else:
+ cfg = {
+ **cfg,
+ 'description': {
+ 'en_US': '',
+ 'en': '',
+ 'zh_Hans': '',
+ },
+ }
+
+ # Handle options - convert from list of {name, label} to list of {name, label}
+ options = cfg.get('options')
+ if isinstance(options, list) and len(options) > 0 and isinstance(options[0], dict):
+ normalized_options = []
+ for opt in options:
+ opt_label = opt.get('label')
+ if isinstance(opt_label, dict):
+ normalized_opt_label = {
+ 'en_US': opt_label.get('en_US', opt_label.get('en', '')),
+ 'en': opt_label.get('en_US', opt_label.get('en', '')),
+ 'zh_Hans': opt_label.get('zh_Hans', opt_label.get('zh', '')),
+ }
+ else:
+ normalized_opt_label = {
+ 'en_US': opt.get('name', ''),
+ 'en': opt.get('name', ''),
+ 'zh_Hans': opt.get('name', ''),
+ }
+ normalized_options.append({**opt, 'label': normalized_opt_label})
+ cfg = {**cfg, 'options': normalized_options}
+
+ return cfg
+
+ def _get_pipeline_config_meta(self, source: str) -> Optional[dict]:
+ """Get pipeline config metadata by source prefix"""
+ if source.startswith('pipeline:'):
+ parts = source.split(':')
+ if len(parts) >= 2:
+ pipeline_type = parts[1] # e.g., 'trigger', 'ai', 'output', 'safety'
+ meta_attr = f'pipeline_config_meta_{pipeline_type}'
+ return getattr(self.ap, meta_attr, None)
+ return None
+
+ def _find_stage_in_pipeline(self, pipeline_meta: dict, stage_name: str) -> Optional[dict]:
+ """Find a stage in pipeline config metadata"""
+ stages = pipeline_meta.get('stages', [])
+ for stage in stages:
+ if stage.get('name') == stage_name:
+ return stage
+ return None
+
+ async def get_versions(self, workflow_uuid: str) -> list[dict]:
+ """Get version history for a workflow"""
+ result = await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.select(persistence_workflow.WorkflowVersion)
+ .where(persistence_workflow.WorkflowVersion.workflow_uuid == workflow_uuid)
+ .order_by(persistence_workflow.WorkflowVersion.version.desc())
+ )
+
+ versions = result.all()
+
+ return [
+ self.ap.persistence_mgr.serialize_model(persistence_workflow.WorkflowVersion, version)
+ for version in versions
+ ]
+
+ async def rollback_to_version(self, workflow_uuid: str, version: int) -> None:
+ """Rollback workflow to a specific version"""
+ result = await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.select(persistence_workflow.WorkflowVersion).where(
+ persistence_workflow.WorkflowVersion.workflow_uuid == workflow_uuid,
+ persistence_workflow.WorkflowVersion.version == version,
+ )
+ )
+
+ version_record = result.first()
+
+ if version_record is None:
+ raise ValueError(f'Version {version} not found for workflow {workflow_uuid}')
+
+ # Update workflow with the old version's definition
+ await self.update_workflow(
+ workflow_uuid,
+ {
+ 'definition': version_record.definition,
+ 'global_config': version_record.global_config,
+ },
+ )
+
+ async def _save_version_history(
+ self, workflow_uuid: str, version: int, definition: dict, global_config: dict
+ ) -> None:
+ """Save workflow version to history"""
+ # Check if version already exists (database-agnostic)
+ existing = await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.select(persistence_workflow.WorkflowVersion).where(
+ persistence_workflow.WorkflowVersion.workflow_uuid == workflow_uuid,
+ persistence_workflow.WorkflowVersion.version == version,
+ )
+ )
+ if existing.first() is not None:
+ return
+
+ await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.insert(persistence_workflow.WorkflowVersion).values(
+ workflow_uuid=workflow_uuid,
+ version=version,
+ definition=definition,
+ global_config=global_config,
+ )
+ )
+
+ def _serialize_workflow(self, workflow) -> dict:
+ """Serialize workflow entity to dict"""
+ result = self.ap.persistence_mgr.serialize_model(persistence_workflow.Workflow, workflow)
+
+ # Extract nodes and edges from definition to top level for frontend compatibility
+ definition = result.get('definition', {})
+ if isinstance(definition, dict):
+ result['nodes'] = definition.get('nodes', [])
+ result['edges'] = definition.get('edges', [])
+ result['variables'] = definition.get('variables', {})
+ else:
+ result['nodes'] = []
+ result['edges'] = []
+ result['variables'] = {}
+
+ return result
+
+ def _serialize_execution(self, execution) -> dict:
+ data = self.ap.persistence_mgr.serialize_model(
+ persistence_workflow.WorkflowExecution,
+ execution,
+ )
+ data['started_at'] = data.get('start_time')
+ data['completed_at'] = data.get('end_time')
+ return data
+
+ def _serialize_node_execution(self, node_execution) -> dict:
+ data = self.ap.persistence_mgr.serialize_model(
+ persistence_workflow.WorkflowNodeExecution,
+ node_execution,
+ )
+ data['started_at'] = data.get('start_time')
+ data['completed_at'] = data.get('end_time')
+ return data
+
+ async def update_workflow_extensions(
+ self,
+ workflow_uuid: str,
+ bound_plugins: list[dict],
+ bound_mcp_servers: Optional[list[str]] = None,
+ enable_all_plugins: bool = True,
+ enable_all_mcp_servers: bool = True,
+ ) -> None:
+ """Update the bound plugins and MCP servers for a workflow"""
+ workflow = await self.get_workflow(workflow_uuid)
+ if workflow is None:
+ raise ValueError(f'Workflow {workflow_uuid} not found')
+
+ extensions_preferences = workflow.get('extensions_preferences', {})
+ extensions_preferences['enable_all_plugins'] = enable_all_plugins
+ extensions_preferences['enable_all_mcp_servers'] = enable_all_mcp_servers
+ extensions_preferences['plugins'] = bound_plugins
+ if bound_mcp_servers is not None:
+ extensions_preferences['mcp_servers'] = bound_mcp_servers
+
+ await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.update(persistence_workflow.Workflow)
+ .where(persistence_workflow.Workflow.uuid == workflow_uuid)
+ .values(extensions_preferences=extensions_preferences)
+ )
+
+ async def publish_workflow(self, workflow_uuid: str) -> None:
+ """Publish a workflow (set is_enabled to True)"""
+ workflow = await self.get_workflow(workflow_uuid)
+ if workflow is None:
+ raise ValueError(f'Workflow {workflow_uuid} not found')
+
+ await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.update(persistence_workflow.Workflow)
+ .where(persistence_workflow.Workflow.uuid == workflow_uuid)
+ .values(is_enabled=True)
+ )
+
+ async def unpublish_workflow(self, workflow_uuid: str) -> None:
+ """Unpublish a workflow (set is_enabled to False)"""
+ workflow = await self.get_workflow(workflow_uuid)
+ if workflow is None:
+ raise ValueError(f'Workflow {workflow_uuid} not found')
+
+ await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.update(persistence_workflow.Workflow)
+ .where(persistence_workflow.Workflow.uuid == workflow_uuid)
+ .values(is_enabled=False)
+ )
+
+ async def get_workflow_stats(self, workflow_uuid: str) -> dict:
+ """Get workflow statistics"""
+ # Verify workflow exists
+ workflow = await self.get_workflow(workflow_uuid)
+ if workflow is None:
+ raise ValueError(f'Workflow {workflow_uuid} not found')
+
+ # Get total execution count
+ total_result = await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.select(sqlalchemy.func.count(persistence_workflow.WorkflowExecution.uuid)).where(
+ persistence_workflow.WorkflowExecution.workflow_uuid == workflow_uuid
+ )
+ )
+ total_executions = total_result.scalar()
+
+ # Get executions by status
+ status_result = await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.select(
+ persistence_workflow.WorkflowExecution.status,
+ sqlalchemy.func.count(persistence_workflow.WorkflowExecution.uuid),
+ )
+ .where(persistence_workflow.WorkflowExecution.workflow_uuid == workflow_uuid)
+ .group_by(persistence_workflow.WorkflowExecution.status)
+ )
+ status_counts = {row[0]: row[1] for row in status_result.all()}
+
+ # Get success and failure counts
+ success_count = status_counts.get('completed', 0)
+ failed_count = status_counts.get('failed', 0)
+ pending_count = status_counts.get('pending', 0)
+ running_count = status_counts.get('running', 0)
+ cancelled_count = status_counts.get('cancelled', 0)
+
+ # Calculate success rate (as 0-1 ratio for frontend)
+ success_rate = 0.0
+ if total_executions > 0:
+ success_rate = success_count / total_executions
+
+ # Get average execution time (for completed executions)
+ avg_time_result = await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.select(
+ sqlalchemy.func.avg(
+ sqlalchemy.func.strftime('%s', persistence_workflow.WorkflowExecution.end_time)
+ - sqlalchemy.func.strftime('%s', persistence_workflow.WorkflowExecution.start_time)
+ )
+ ).where(
+ persistence_workflow.WorkflowExecution.workflow_uuid == workflow_uuid,
+ persistence_workflow.WorkflowExecution.status == 'completed',
+ persistence_workflow.WorkflowExecution.start_time.isnot(None),
+ persistence_workflow.WorkflowExecution.end_time.isnot(None),
+ )
+ )
+ avg_execution_time = avg_time_result.scalar()
+ # Ensure we always return a valid number, not None
+ avg_execution_time = float(avg_execution_time) if avg_execution_time is not None else 0.0
+
+ # Get last execution time
+ last_execution_result = await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.select(persistence_workflow.WorkflowExecution.created_at)
+ .where(persistence_workflow.WorkflowExecution.workflow_uuid == workflow_uuid)
+ .order_by(persistence_workflow.WorkflowExecution.created_at.desc())
+ .limit(1)
+ )
+ last_execution = last_execution_result.first()
+
+ return {
+ 'total_executions': int(total_executions),
+ 'successful_executions': int(success_count),
+ 'failed_executions': int(failed_count),
+ 'pending_executions': int(pending_count),
+ 'running_executions': int(running_count),
+ 'cancelled_executions': int(cancelled_count),
+ 'success_rate': round(success_rate, 4), # Already 0-1 ratio
+ 'average_duration_ms': round(avg_execution_time * 1000, 2)
+ if avg_execution_time > 0
+ else 0, # Convert to milliseconds
+ 'last_execution_time': last_execution[0] if last_execution else None,
+ }
+
+ async def start_debug_execution(
+ self,
+ workflow_uuid: str,
+ context: Optional[dict] = None,
+ variables: Optional[dict] = None,
+ breakpoints: Optional[list[str]] = None,
+ ) -> str:
+ """Start a debug execution and return execution ID"""
+ workflow = await self.get_workflow(workflow_uuid)
+ if workflow is None:
+ raise ValueError(f'Workflow {workflow_uuid} not found')
+
+ execution_uuid = str(uuid.uuid4())
+ execution_record = {
+ 'uuid': execution_uuid,
+ 'workflow_uuid': workflow_uuid,
+ 'workflow_version': workflow.get('version', 1),
+ 'status': ExecutionStatus.PENDING.value,
+ 'trigger_type': 'debug',
+ 'trigger_data': {'context': context or {}, 'breakpoints': breakpoints or []},
+ 'variables': variables or {},
+ 'start_time': datetime.now(),
+ }
+
+ await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.insert(persistence_workflow.WorkflowExecution).values(**execution_record)
+ )
+
+ return execution_uuid
+
+ async def pause_debug_execution(self, workflow_uuid: str, execution_uuid: str) -> None:
+ """Pause a debug execution"""
+ await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.update(persistence_workflow.WorkflowExecution)
+ .where(persistence_workflow.WorkflowExecution.uuid == execution_uuid)
+ .values(status='paused')
+ )
+
+ async def resume_debug_execution(self, workflow_uuid: str, execution_uuid: str) -> None:
+ """Resume a paused debug execution"""
+ await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.update(persistence_workflow.WorkflowExecution)
+ .where(persistence_workflow.WorkflowExecution.uuid == execution_uuid)
+ .values(status='running')
+ )
+
+ async def step_debug_execution(self, workflow_uuid: str, execution_uuid: str) -> dict:
+ """Step through a debug execution"""
+ return {'status': 'stepped', 'execution_uuid': execution_uuid}
+
+ async def stop_debug_execution(self, workflow_uuid: str, execution_uuid: str) -> None:
+ """Stop a debug execution"""
+ await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.update(persistence_workflow.WorkflowExecution)
+ .where(persistence_workflow.WorkflowExecution.uuid == execution_uuid)
+ .values(status='cancelled', end_time=datetime.now())
+ )
+
+ async def get_debug_state(self, workflow_uuid: str, execution_uuid: str) -> dict:
+ """Get the current debug state"""
+ execution = await self.get_execution(execution_uuid)
+ if execution is None:
+ raise ValueError(f'Execution {execution_uuid} not found')
+
+ return {
+ 'execution_uuid': execution_uuid,
+ 'status': execution.get('status'),
+ 'variables': execution.get('variables', {}),
+ }
+
+ async def cancel_execution(self, execution_uuid: str) -> None:
+ """Cancel a workflow execution"""
+ execution = await self.get_execution(execution_uuid)
+ if execution is None:
+ raise ValueError(f'Execution {execution_uuid} not found')
+
+ if execution.get('status') not in ['pending', 'running']:
+ raise RuntimeError(f'Cannot cancel execution with status {execution.get("status")}')
+
+ await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.update(persistence_workflow.WorkflowExecution)
+ .where(persistence_workflow.WorkflowExecution.uuid == execution_uuid)
+ .values(status='cancelled', end_time=datetime.now())
+ )
+
+ async def rerun_execution(self, workflow_uuid: str, execution_uuid: str) -> str:
+ """Rerun a workflow execution with the same trigger data"""
+ original_execution = await self.get_execution(execution_uuid)
+ if original_execution is None:
+ raise ValueError(f'Execution {execution_uuid} not found')
+
+ trigger_data = original_execution.get('trigger_data', {})
+ new_execution_uuid = await self.execute_workflow(
+ workflow_uuid, trigger_type=original_execution.get('trigger_type', 'manual'), trigger_data=trigger_data
+ )
+
+ return new_execution_uuid
+
+ async def cleanup_stale_executions(self, timeout_seconds: int | None = None) -> int:
+ """Cancel stale pending/running workflow executions."""
+ effective_timeout = timeout_seconds or self.STALE_EXECUTION_TIMEOUT_SECONDS
+ if effective_timeout <= 0:
+ effective_timeout = self.STALE_EXECUTION_TIMEOUT_SECONDS
+
+ stale_before = datetime.now() - timedelta(seconds=effective_timeout)
+ stale_statuses = [
+ ExecutionStatus.PENDING.value,
+ ExecutionStatus.RUNNING.value,
+ ExecutionStatus.WAITING.value,
+ ]
+
+ result = await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.update(persistence_workflow.WorkflowExecution)
+ .where(
+ persistence_workflow.WorkflowExecution.status.in_(stale_statuses),
+ persistence_workflow.WorkflowExecution.start_time.isnot(None),
+ persistence_workflow.WorkflowExecution.start_time < stale_before,
+ )
+ .values(
+ status=ExecutionStatus.CANCELLED.value,
+ end_time=datetime.now(),
+ error=f'Workflow execution auto-cancelled after exceeding {effective_timeout} seconds',
+ )
+ )
+
+ return int(getattr(result, 'rowcount', 0) or 0)
+
+ async def get_execution_logs(
+ self, workflow_uuid: str, execution_uuid: str, limit: int = 100, offset: int = 0
+ ) -> dict:
+ """Get execution logs for a workflow execution"""
+ execution = await self.get_execution(execution_uuid)
+ if execution is None:
+ raise ValueError(f'Execution {execution_uuid} not found')
+ if execution.get('workflow_uuid') != workflow_uuid:
+ raise ValueError(f'Execution {execution_uuid} not found in workflow {workflow_uuid}')
+
+ query = (
+ sqlalchemy.select(persistence_workflow.WorkflowNodeExecution)
+ .where(persistence_workflow.WorkflowNodeExecution.execution_uuid == execution_uuid)
+ .order_by(persistence_workflow.WorkflowNodeExecution.id.asc())
+ .limit(limit)
+ .offset(offset)
+ )
+
+ result = await self.ap.persistence_mgr.execute_async(query)
+ node_executions = result.all()
+
+ logs = []
+ for node_exec in node_executions:
+ serialized = self._serialize_node_execution(node_exec)
+ timestamp = serialized.get('completed_at') or serialized.get('started_at') or execution.get('started_at')
+ level = 'error' if serialized.get('status') == 'failed' else 'info'
+ message = f'{serialized.get("node_type")}::{serialized.get("node_id")} - {serialized.get("status")}'
+ if serialized.get('error'):
+ message = f'{message} - {serialized.get("error")}'
+ logs.append(
+ {
+ 'id': str(serialized.get('id', serialized.get('node_id'))),
+ 'timestamp': timestamp,
+ 'level': level,
+ 'node_id': serialized.get('node_id'),
+ 'message': message,
+ 'data': {
+ 'inputs': serialized.get('inputs'),
+ 'outputs': serialized.get('outputs'),
+ 'retry_count': serialized.get('retry_count'),
+ },
+ }
+ )
+
+ return {'logs': logs, 'total': len(logs)}
diff --git a/src/langbot/pkg/core/app.py b/src/langbot/pkg/core/app.py
index 7e5386cf5..df8f4ffef 100644
--- a/src/langbot/pkg/core/app.py
+++ b/src/langbot/pkg/core/app.py
@@ -31,6 +31,7 @@
from ..api.http.service import apikey as apikey_service
from ..api.http.service import webhook as webhook_service
from ..api.http.service import monitoring as monitoring_service
+from ..api.http.service import workflow as workflow_service
from ..api.http.service import maintenance as maintenance_service
from ..discover import engine as discover_engine
@@ -150,6 +151,8 @@ class Application:
webhook_service: webhook_service.WebhookService = None
+ workflow_service: workflow_service.WorkflowService = None
+
telemetry: telemetry_module.TelemetryManager = None
survey: survey_module.SurveyManager = None
@@ -237,6 +240,22 @@ async def monitoring_cleanup_loop():
scopes=[core_entities.LifecycleControlScope.APPLICATION],
)
+ async def workflow_execution_cleanup_loop():
+ check_interval_seconds = 60
+ while True:
+ try:
+ cancelled = await self.workflow_service.cleanup_stale_executions()
+ if cancelled > 0:
+ self.logger.info(f'Workflow execution auto-cleanup: cancelled {cancelled} stale executions')
+ except Exception as e:
+ self.logger.warning(f'Workflow execution auto-cleanup error: {e}')
+ await asyncio.sleep(check_interval_seconds)
+
+ self.task_mgr.create_task(
+ workflow_execution_cleanup_loop(),
+ name='workflow-execution-cleanup',
+ scopes=[core_entities.LifecycleControlScope.APPLICATION],
+ )
# Start storage/log maintenance task if enabled
storage_cleanup_cfg = self.instance_config.data.get('storage', {}).get('cleanup', {})
if storage_cleanup_cfg.get('enabled', True) and self.maintenance_service is not None:
diff --git a/src/langbot/pkg/core/stages/build_app.py b/src/langbot/pkg/core/stages/build_app.py
index 3bb5ffd7a..f0b594ebd 100644
--- a/src/langbot/pkg/core/stages/build_app.py
+++ b/src/langbot/pkg/core/stages/build_app.py
@@ -28,6 +28,7 @@
from ...api.http.service import apikey as apikey_service
from ...api.http.service import webhook as webhook_service
from ...api.http.service import monitoring as monitoring_service
+from ...api.http.service import workflow as workflow_service
from ...api.http.service import maintenance as maintenance_service
from ...discover import engine as discover_engine
from ...storage import mgr as storagemgr
@@ -86,6 +87,9 @@ async def run(self, ap: app.Application):
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
diff --git a/src/langbot/pkg/core/stages/load_config.py b/src/langbot/pkg/core/stages/load_config.py
index 26f4a9e19..2250f2944 100644
--- a/src/langbot/pkg/core/stages/load_config.py
+++ b/src/langbot/pkg/core/stages/load_config.py
@@ -221,3 +221,36 @@ async def load_resource_yaml_template_data(resource_name: str) -> dict:
ap.pipeline_config_meta_safety = await load_resource_yaml_template_data('metadata/pipeline/safety.yaml')
ap.pipeline_config_meta_ai = await load_resource_yaml_template_data('metadata/pipeline/ai.yaml')
ap.pipeline_config_meta_output = await load_resource_yaml_template_data('metadata/pipeline/output.yaml')
+
+ # Load workflow node metadata from YAML files. YAML is the source of
+ # truth for workflow editor metadata; Python classes provide execution
+ # logic and are bound through the registry.
+ from langbot.pkg.workflow.metadata import NodeMetadataLoader
+ from langbot.pkg.workflow.registry import NodeTypeRegistry
+
+ workflow_metadata_loader = NodeMetadataLoader()
+ workflow_node_count = await workflow_metadata_loader.load_core_metadata()
+ ap.workflow_node_configs = workflow_metadata_loader.get_all_metadata()
+ ap.workflow_node_metadata_loader = workflow_metadata_loader
+
+ workflow_registry = NodeTypeRegistry.instance()
+ for node_config in ap.workflow_node_configs.values():
+ workflow_registry.register_metadata(node_config, source=node_config.get('_source', 'core'))
+
+ # Import node modules after metadata registration so decorators can bind
+ # implementations to YAML-defined canonical node types.
+ from langbot.pkg.workflow import nodes as workflow_nodes # noqa: F401
+
+ workflow_registry.process_pending_registrations()
+
+ workflow_load_errors = workflow_metadata_loader.get_load_errors()
+ if workflow_load_errors:
+ print(f'Workflow node metadata load errors: {len(workflow_load_errors)}')
+ for error in workflow_load_errors:
+ print(f" - {error.get('file')}: {error.get('error')}")
+
+ print(
+ f'Loaded {workflow_node_count} workflow node metadata files; '
+ f'registered {workflow_registry.metadata_count()} metadata definitions, '
+ f'{workflow_registry.count()} node types'
+ )
diff --git a/src/langbot/pkg/entity/persistence/bot.py b/src/langbot/pkg/entity/persistence/bot.py
index c3fa295f7..b8ef3f157 100644
--- a/src/langbot/pkg/entity/persistence/bot.py
+++ b/src/langbot/pkg/entity/persistence/bot.py
@@ -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,
diff --git a/src/langbot/pkg/entity/persistence/workflow.py b/src/langbot/pkg/entity/persistence/workflow.py
new file mode 100644
index 000000000..f8c2d03f8
--- /dev/null
+++ b/src/langbot/pkg/entity/persistence/workflow.py
@@ -0,0 +1,126 @@
+"""Workflow persistence entities"""
+
+import sqlalchemy
+
+from .base import Base
+
+
+class Workflow(Base):
+ """Workflow definition"""
+
+ __tablename__ = 'workflows'
+
+ uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
+ name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
+ description = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
+ emoji = sqlalchemy.Column(sqlalchemy.String(10), nullable=True, default='🔄')
+ version = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, default=1)
+ is_enabled = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=True)
+
+ # Workflow definition stored as JSON
+ # Contains: nodes, edges, variables, settings
+ definition = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
+
+ # Global config (inherited from Pipeline capabilities)
+ # Contains: safety, output configs
+ global_config = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
+
+ # Extensions preferences (same as Pipeline)
+ extensions_preferences = sqlalchemy.Column(
+ sqlalchemy.JSON,
+ nullable=False,
+ default={'enable_all_plugins': True, 'enable_all_mcp_servers': True, 'plugins': [], 'mcp_servers': []},
+ )
+
+ created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
+ updated_at = sqlalchemy.Column(
+ sqlalchemy.DateTime,
+ nullable=False,
+ server_default=sqlalchemy.func.now(),
+ onupdate=sqlalchemy.func.now(),
+ )
+
+
+class WorkflowVersion(Base):
+ """Workflow version history"""
+
+ __tablename__ = 'workflow_versions'
+
+ id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True)
+ workflow_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
+ version = sqlalchemy.Column(sqlalchemy.Integer, nullable=False)
+ definition = sqlalchemy.Column(sqlalchemy.JSON, nullable=False)
+ global_config = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
+ created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
+ created_by = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
+
+ __table_args__ = (sqlalchemy.UniqueConstraint('workflow_uuid', 'version', name='uq_workflow_version'),)
+
+
+class WorkflowTrigger(Base):
+ """Workflow trigger configuration"""
+
+ __tablename__ = 'workflow_triggers'
+
+ uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
+ workflow_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
+ type = sqlalchemy.Column(sqlalchemy.String(50), nullable=False) # message, cron, event, webhook
+ config = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
+ is_enabled = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=True)
+ priority = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, default=0)
+ created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
+ updated_at = sqlalchemy.Column(
+ sqlalchemy.DateTime,
+ nullable=False,
+ server_default=sqlalchemy.func.now(),
+ onupdate=sqlalchemy.func.now(),
+ )
+
+
+class WorkflowExecution(Base):
+ """Workflow execution record"""
+
+ __tablename__ = 'workflow_executions'
+
+ uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
+ workflow_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
+ workflow_version = sqlalchemy.Column(sqlalchemy.Integer, nullable=False)
+ status = sqlalchemy.Column(sqlalchemy.String(20), nullable=False) # pending, running, completed, failed, cancelled
+ trigger_type = sqlalchemy.Column(sqlalchemy.String(50), nullable=True)
+ trigger_data = sqlalchemy.Column(sqlalchemy.JSON, nullable=True)
+ variables = sqlalchemy.Column(sqlalchemy.JSON, nullable=True)
+ start_time = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
+ end_time = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
+ error = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
+ created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
+
+
+class WorkflowNodeExecution(Base):
+ """Workflow node execution record"""
+
+ __tablename__ = 'workflow_node_executions'
+
+ id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True)
+ execution_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
+ node_id = sqlalchemy.Column(sqlalchemy.String(100), nullable=False)
+ node_type = sqlalchemy.Column(sqlalchemy.String(50), nullable=False)
+ status = sqlalchemy.Column(sqlalchemy.String(20), nullable=False) # pending, running, completed, failed, skipped
+ inputs = sqlalchemy.Column(sqlalchemy.JSON, nullable=True)
+ outputs = sqlalchemy.Column(sqlalchemy.JSON, nullable=True)
+ start_time = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
+ end_time = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
+ error = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
+ retry_count = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, default=0)
+
+
+class ScheduledJob(Base):
+ """Scheduled job for cron triggers"""
+
+ __tablename__ = 'workflow_scheduled_jobs'
+
+ uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
+ trigger_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
+ cron_expression = sqlalchemy.Column(sqlalchemy.String(100), nullable=True)
+ next_run_time = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
+ last_run_time = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
+ is_enabled = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=True)
diff --git a/src/langbot/pkg/persistence/migrations/dbm026_workflow_tables.py b/src/langbot/pkg/persistence/migrations/dbm026_workflow_tables.py
new file mode 100644
index 000000000..069d574f2
--- /dev/null
+++ b/src/langbot/pkg/persistence/migrations/dbm026_workflow_tables.py
@@ -0,0 +1,158 @@
+"""Add workflow tables and update bot binding fields"""
+
+import sqlalchemy
+from .. import migration
+
+
+@migration.migration_class(26)
+class DBMigrateWorkflowTables(migration.DBMigration):
+ """Add workflow tables and update bot binding fields"""
+
+ async def upgrade(self):
+ # Create workflows table
+ await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.text("""
+ CREATE TABLE IF NOT EXISTS workflows (
+ uuid VARCHAR(255) PRIMARY KEY,
+ name VARCHAR(255) NOT NULL,
+ description TEXT,
+ emoji VARCHAR(10) DEFAULT '🔄',
+ version INTEGER NOT NULL DEFAULT 1,
+ is_enabled BOOLEAN NOT NULL DEFAULT 1,
+ definition JSON NOT NULL DEFAULT '{}',
+ global_config JSON NOT NULL DEFAULT '{}',
+ extensions_preferences JSON NOT NULL DEFAULT '{"enable_all_plugins": true, "enable_all_mcp_servers": true, "plugins": [], "mcp_servers": []}',
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ )
+ """)
+ )
+
+ # Create workflow_versions table
+ await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.text("""
+ CREATE TABLE IF NOT EXISTS workflow_versions (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ workflow_uuid VARCHAR(255) NOT NULL,
+ version INTEGER NOT NULL,
+ definition JSON NOT NULL,
+ global_config JSON NOT NULL DEFAULT '{}',
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ created_by VARCHAR(255),
+ UNIQUE(workflow_uuid, version)
+ )
+ """)
+ )
+
+ # Create workflow_triggers table
+ await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.text("""
+ CREATE TABLE IF NOT EXISTS workflow_triggers (
+ uuid VARCHAR(255) PRIMARY KEY,
+ workflow_uuid VARCHAR(255) NOT NULL,
+ type VARCHAR(50) NOT NULL,
+ config JSON NOT NULL DEFAULT '{}',
+ is_enabled BOOLEAN NOT NULL DEFAULT 1,
+ priority INTEGER NOT NULL DEFAULT 0,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ )
+ """)
+ )
+
+ # Create workflow_executions table
+ await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.text("""
+ CREATE TABLE IF NOT EXISTS workflow_executions (
+ uuid VARCHAR(255) PRIMARY KEY,
+ workflow_uuid VARCHAR(255) NOT NULL,
+ workflow_version INTEGER NOT NULL,
+ status VARCHAR(20) NOT NULL,
+ trigger_type VARCHAR(50),
+ trigger_data JSON,
+ variables JSON,
+ start_time TIMESTAMP,
+ end_time TIMESTAMP,
+ error TEXT,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ )
+ """)
+ )
+
+ # Create workflow_node_executions table
+ await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.text("""
+ CREATE TABLE IF NOT EXISTS workflow_node_executions (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ execution_uuid VARCHAR(255) NOT NULL,
+ node_id VARCHAR(100) NOT NULL,
+ node_type VARCHAR(50) NOT NULL,
+ status VARCHAR(20) NOT NULL,
+ inputs JSON,
+ outputs JSON,
+ start_time TIMESTAMP,
+ end_time TIMESTAMP,
+ error TEXT,
+ retry_count INTEGER NOT NULL DEFAULT 0
+ )
+ """)
+ )
+
+ # Create workflow_scheduled_jobs table
+ await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.text("""
+ CREATE TABLE IF NOT EXISTS workflow_scheduled_jobs (
+ uuid VARCHAR(255) PRIMARY KEY,
+ trigger_uuid VARCHAR(255) NOT NULL,
+ cron_expression VARCHAR(100),
+ next_run_time TIMESTAMP,
+ last_run_time TIMESTAMP,
+ is_enabled BOOLEAN NOT NULL DEFAULT 1
+ )
+ """)
+ )
+
+ # Create indexes
+ await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.text('CREATE INDEX IF NOT EXISTS idx_workflow_versions_uuid ON workflow_versions(workflow_uuid)')
+ )
+ await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.text('CREATE INDEX IF NOT EXISTS idx_workflow_triggers_uuid ON workflow_triggers(workflow_uuid)')
+ )
+ await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.text(
+ 'CREATE INDEX IF NOT EXISTS idx_workflow_executions_uuid ON workflow_executions(workflow_uuid)'
+ )
+ )
+ await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.text(
+ 'CREATE INDEX IF NOT EXISTS idx_workflow_node_executions_uuid ON workflow_node_executions(execution_uuid)'
+ )
+ )
+ await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.text(
+ 'CREATE INDEX IF NOT EXISTS idx_workflow_scheduled_jobs_trigger ON workflow_scheduled_jobs(trigger_uuid)'
+ )
+ )
+
+ # Update bots table: add binding_type column (default to 'pipeline' for backward compatibility)
+ # Check if column exists first (SQLite doesn't support IF NOT EXISTS for columns)
+ try:
+ await self.ap.persistence_mgr.execute_async(sqlalchemy.text('SELECT binding_type FROM bots LIMIT 1'))
+ except Exception:
+ # Column doesn't exist, add it
+ await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.text("ALTER TABLE bots ADD COLUMN binding_type VARCHAR(20) NOT NULL DEFAULT 'pipeline'")
+ )
+
+ async def downgrade(self):
+ # Drop tables in reverse order
+ await self.ap.persistence_mgr.execute_async(sqlalchemy.text('DROP TABLE IF EXISTS workflow_scheduled_jobs'))
+ await self.ap.persistence_mgr.execute_async(sqlalchemy.text('DROP TABLE IF EXISTS workflow_node_executions'))
+ await self.ap.persistence_mgr.execute_async(sqlalchemy.text('DROP TABLE IF EXISTS workflow_executions'))
+ await self.ap.persistence_mgr.execute_async(sqlalchemy.text('DROP TABLE IF EXISTS workflow_triggers'))
+ await self.ap.persistence_mgr.execute_async(sqlalchemy.text('DROP TABLE IF EXISTS workflow_versions'))
+ await self.ap.persistence_mgr.execute_async(sqlalchemy.text('DROP TABLE IF EXISTS workflows'))
+
+ # Remove binding_type column from bots (SQLite doesn't support DROP COLUMN directly)
+ # This would need a table recreation in SQLite, so we'll skip it in downgrade
diff --git a/src/langbot/pkg/persistence/migrations/dbm027_bot_binding_fields.py b/src/langbot/pkg/persistence/migrations/dbm027_bot_binding_fields.py
new file mode 100644
index 000000000..5de8aae39
--- /dev/null
+++ b/src/langbot/pkg/persistence/migrations/dbm027_bot_binding_fields.py
@@ -0,0 +1,49 @@
+"""Add binding_uuid field to bots table and migrate data"""
+
+import sqlalchemy
+from .. import migration
+
+
+@migration.migration_class(27)
+class DBMigrateBotBindingFields(migration.DBMigration):
+ """Add binding_uuid field to bots table and migrate existing data"""
+
+ async def upgrade(self):
+ # Add binding_uuid column to bots table
+ # Check if column exists first (SQLite doesn't support IF NOT EXISTS for columns)
+ try:
+ await self.ap.persistence_mgr.execute_async(sqlalchemy.text('SELECT binding_uuid FROM bots LIMIT 1'))
+ except Exception:
+ # Column doesn't exist, add it
+ await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.text('ALTER TABLE bots ADD COLUMN binding_uuid VARCHAR(64)')
+ )
+
+ # Migrate existing data: copy use_pipeline_uuid to binding_uuid for records
+ # that have a pipeline bound and binding_uuid is not set yet
+ await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.text("""
+ UPDATE bots
+ SET binding_uuid = use_pipeline_uuid
+ WHERE use_pipeline_uuid IS NOT NULL
+ AND use_pipeline_uuid != ''
+ AND (binding_uuid IS NULL OR binding_uuid = '')
+ """)
+ )
+
+ # Ensure binding_type is 'pipeline' for records that were migrated
+ await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.text("""
+ UPDATE bots
+ SET binding_type = 'pipeline'
+ WHERE binding_uuid IS NOT NULL
+ AND binding_uuid != ''
+ AND (binding_type IS NULL OR binding_type = '')
+ """)
+ )
+
+ async def downgrade(self):
+ # SQLite doesn't support DROP COLUMN directly
+ # This would need a table recreation in SQLite, so we'll skip it in downgrade
+ # The column will remain but won't be used
+ pass
diff --git a/src/langbot/pkg/pipeline/pipelinemgr.py b/src/langbot/pkg/pipeline/pipelinemgr.py
index 1426fe3de..394f35e25 100644
--- a/src/langbot/pkg/pipeline/pipelinemgr.py
+++ b/src/langbot/pkg/pipeline/pipelinemgr.py
@@ -384,7 +384,8 @@ async def process_query(self, query: pipeline_query.Query):
finally:
self.ap.logger.debug(f'Query {query.query_id} processed')
- del self.ap.query_pool.cached_queries[query.query_id]
+ # Use pop with default to avoid KeyError if query was never cached
+ self.ap.query_pool.cached_queries.pop(query.query_id, None)
class PipelineManager:
diff --git a/src/langbot/pkg/platform/botmgr.py b/src/langbot/pkg/platform/botmgr.py
index 8e99618c3..d2ec38262 100644
--- a/src/langbot/pkg/platform/botmgr.py
+++ b/src/langbot/pkg/platform/botmgr.py
@@ -2,7 +2,6 @@
import asyncio
import json
-import re
import traceback
import sqlalchemy
@@ -54,29 +53,24 @@ def __init__(
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 @@ def resolve_pipeline_uuid(
message_text: str,
message_element_types: list[str] | None = None,
) -> tuple[str | None, bool]:
- """Resolve pipeline UUID based on routing rules.
-
- 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)
+ """Resolve pipeline UUID for message processing.
- 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
+ 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
- if rule_type == 'launcher_type':
- if self._match_operator(launcher_type, operator, rule_value):
- return target_uuid, True
- elif rule_type == 'launcher_id':
- if self._match_operator(str(launcher_id), operator, str(rule_value)):
- return target_uuid, True
- elif rule_type == 'message_content':
- if self._match_operator(message_text, operator, rule_value):
- return target_uuid, True
- elif rule_type == 'message_has_element':
- has_element = rule_value in element_type_set
- if operator == 'eq' and has_element:
- return target_uuid, True
- elif operator == 'neq' and not has_element:
- return target_uuid, True
-
- return self.bot_entity.use_pipeline_uuid, False
+ return binding_uuid, False
async def _record_discarded_message(
self,
diff --git a/src/langbot/pkg/platform/sources/websocket_adapter.py b/src/langbot/pkg/platform/sources/websocket_adapter.py
index 9ffcf04ac..0c5d80a31 100644
--- a/src/langbot/pkg/platform/sources/websocket_adapter.py
+++ b/src/langbot/pkg/platform/sources/websocket_adapter.py
@@ -373,6 +373,7 @@ async def handle_websocket_message(
"""
pipeline_uuid = connection.pipeline_uuid
session_type = connection.session_type
+ is_workflow = bool(connection.metadata.get('is_workflow'))
# 获取stream参数,默认为True
self.stream_enabled = message_data.get('stream', True)
@@ -414,6 +415,61 @@ async def handle_websocket_message(
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()))
diff --git a/src/langbot/pkg/workflow/__init__.py b/src/langbot/pkg/workflow/__init__.py
new file mode 100644
index 000000000..02e644d44
--- /dev/null
+++ b/src/langbot/pkg/workflow/__init__.py
@@ -0,0 +1,81 @@
+"""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 importlib import import_module
+from typing import Any
+
+from .node import WorkflowNode, workflow_node
+
+
+def __getattr__(name: str) -> Any:
+ """Lazily expose heavier workflow modules.
+
+ Loading workflow metadata should not import the executor or node modules as a
+ side effect. Node implementations are imported explicitly during boot after
+ YAML metadata has been registered.
+ """
+ if name == 'NodeTypeRegistry':
+ from .registry import NodeTypeRegistry
+
+ return NodeTypeRegistry
+
+ if name == 'WorkflowExecutor':
+ from .executor import WorkflowExecutor
+
+ return WorkflowExecutor
+
+ if name in ('DebugWorkflowExecutor', 'DebugExecutionState', 'ExecutionLog'):
+ from . import debug
+
+ return getattr(debug, name)
+
+ if name == 'nodes':
+ return import_module('.nodes', __name__)
+
+ raise AttributeError(f'module {__name__!r} has no attribute {name!r}')
+
+__all__ = [
+ # Entities
+ 'WorkflowDefinition',
+ 'NodeDefinition',
+ 'EdgeDefinition',
+ 'Position',
+ 'PortDefinition',
+ 'TriggerDefinition',
+ 'WorkflowSettings',
+ 'ExecutionContext',
+ 'NodeState',
+ 'ExecutionStatus',
+ 'NodeStatus',
+ # Node
+ 'WorkflowNode',
+ 'workflow_node',
+ # Registry
+ 'NodeTypeRegistry',
+ # Executor
+ 'WorkflowExecutor',
+ # Debug
+ 'DebugWorkflowExecutor',
+ 'DebugExecutionState',
+ 'ExecutionLog',
+]
diff --git a/src/langbot/pkg/workflow/adapters.py b/src/langbot/pkg/workflow/adapters.py
new file mode 100644
index 000000000..17530a4fd
--- /dev/null
+++ b/src/langbot/pkg/workflow/adapters.py
@@ -0,0 +1,204 @@
+"""Workflow-Pipeline通信适配器
+
+这个模块提供了Workflow和Pipeline之间的通信适配,使用SDK标准的MessageEnvelope格式。
+"""
+
+from __future__ import annotations
+
+import logging
+from typing import Any, Optional
+
+logger = logging.getLogger(__name__)
+
+
+class _WorkflowPipelineCaptureAdapter:
+ """Workflow-Pipeline通信适配器
+
+ 用于在Workflow节点和Pipeline之间进行标准化的消息传递。
+ 支持MessageEnvelope格式的双向转换。
+ """
+
+ def __init__(self, context: Any):
+ """初始化适配器
+
+ Args:
+ context: ExecutionContext - Workflow执行上下文
+ """
+ self.context = context
+ self.responses: list[dict[str, Any]] = []
+ self.bot_account_id: Optional[str] = None
+ self._logger = logging.getLogger(__name__)
+
+ async def call_pipeline_with_envelope(
+ self,
+ envelope: Any,
+ pipeline_executor: Any
+ ) -> Any:
+ """使用MessageEnvelope调用Pipeline
+
+ Args:
+ envelope: MessageEnvelope - 标准消息信封
+ pipeline_executor: Pipeline执行器实例
+
+ Returns:
+ MessageEnvelope - 执行结果信封
+ """
+ try:
+ # 动态导入以避免循环依赖
+ from langbot_plugin_sdk.workflow import envelope_to_query, query_to_envelope
+
+ # 1. 转换为Query
+ query = envelope_to_query(envelope)
+
+ # 2. 调用Pipeline
+ result_query = await pipeline_executor.execute(query)
+
+ # 3. 转换回Envelope
+ result_envelope = query_to_envelope(result_query, envelope)
+
+ self._logger.debug(
+ f'Pipeline execution completed for workflow {envelope.workflow_id}',
+ extra={
+ 'workflow_id': envelope.workflow_id,
+ 'execution_id': envelope.execution_id,
+ 'node_id': envelope.node_id,
+ }
+ )
+
+ return result_envelope
+
+ except Exception as e:
+ self._logger.error(
+ f'Pipeline execution failed: {e}',
+ exc_info=True,
+ extra={
+ 'workflow_id': envelope.workflow_id,
+ 'execution_id': envelope.execution_id,
+ 'node_id': envelope.node_id,
+ }
+ )
+ raise
+
+ def validate_envelope(self, envelope: Any) -> bool:
+ """验证MessageEnvelope的有效性
+
+ Args:
+ envelope: MessageEnvelope - 要验证的消息信封
+
+ Returns:
+ bool - 验证是否通过
+ """
+ required_fields = [
+ 'message_id',
+ 'workflow_id',
+ 'node_id',
+ 'execution_id',
+ 'payload',
+ 'launcher_type',
+ ]
+
+ for field in required_fields:
+ if not hasattr(envelope, field):
+ self._logger.warning(
+ f'MessageEnvelope missing required field: {field}'
+ )
+ return False
+
+ return True
+
+ def get_responses(self) -> list[dict[str, Any]]:
+ """获取所有响应
+
+ Returns:
+ list - 响应列表
+ """
+ return self.responses.copy()
+
+ def add_response(self, response: dict[str, Any]) -> None:
+ """添加响应
+
+ Args:
+ response: dict - 响应数据
+ """
+ self.responses.append(response)
+
+ def get_last_text_response(self) -> str:
+ """获取最后一个文本响应
+
+ Returns:
+ str - 最后一个响应的文本内容
+ """
+ if not self.responses:
+ return ''
+
+ last_response = self.responses[-1]
+ return str(last_response.get('content', '') or '')
+
+ def clear_responses(self) -> None:
+ """清空所有响应"""
+ self.responses.clear()
+
+
+class WorkflowPipelineCompatibilityLayer:
+ """Workflow-Pipeline兼容性层
+
+ 提供向后兼容性,支持旧的Pipeline Query格式和新的MessageEnvelope格式。
+ """
+
+ def __init__(self):
+ """初始化兼容性层"""
+ self._logger = logging.getLogger(__name__)
+
+ def is_workflow_context(self, query: Any) -> bool:
+ """检查Query是否包含Workflow上下文
+
+ Args:
+ query: Query - Pipeline Query对象
+
+ Returns:
+ bool - 是否来自Workflow
+ """
+ if hasattr(query, 'is_from_workflow'):
+ return query.is_from_workflow()
+
+ if hasattr(query, 'get_workflow_context'):
+ context = query.get_workflow_context()
+ return bool(context and context.get('workflow_id'))
+
+ return False
+
+ def get_workflow_id(self, query: Any) -> Optional[str]:
+ """从Query获取Workflow ID
+
+ Args:
+ query: Query - Pipeline Query对象
+
+ Returns:
+ str - Workflow ID,如果不存在则返回None
+ """
+ if hasattr(query, 'get_workflow_id'):
+ return query.get_workflow_id()
+
+ if hasattr(query, 'get_workflow_context'):
+ context = query.get_workflow_context()
+ return context.get('workflow_id') if context else None
+
+ return None
+
+ def get_execution_id(self, query: Any) -> Optional[str]:
+ """从Query获取执行ID
+
+ Args:
+ query: Query - Pipeline Query对象
+
+ Returns:
+ str - 执行ID,如果不存在则返回None
+ """
+ if hasattr(query, 'get_execution_id'):
+ return query.get_execution_id()
+
+ if hasattr(query, 'get_workflow_context'):
+ context = query.get_workflow_context()
+ return context.get('execution_id') if context else None
+
+ return None
diff --git a/src/langbot/pkg/workflow/debug.py b/src/langbot/pkg/workflow/debug.py
new file mode 100644
index 000000000..383712037
--- /dev/null
+++ b/src/langbot/pkg/workflow/debug.py
@@ -0,0 +1,509 @@
+"""Workflow debug execution support.
+
+This module provides debugging capabilities for workflow execution, including:
+- ExecutionLog: Structured log entries for execution tracking
+- DebugExecutionState: State management for debug sessions (pause, resume, breakpoints)
+- DebugWorkflowExecutor: Extended executor with step-by-step debugging support
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+import traceback
+import uuid
+from datetime import datetime
+from typing import Any, Optional, TYPE_CHECKING
+
+from .entities import (
+ WorkflowDefinition,
+ NodeDefinition,
+ EdgeDefinition,
+ ExecutionContext,
+ ExecutionStatus,
+ NodeState,
+ NodeStatus,
+)
+from .executor import WorkflowExecutor
+
+if TYPE_CHECKING:
+ from ..core import app
+
+logger = logging.getLogger(__name__)
+
+
+class ExecutionLog:
+ """Execution log entry"""
+
+ def __init__(self, level: str, message: str, node_id: Optional[str] = None, data: Optional[dict] = None):
+ self.id = str(uuid.uuid4())
+ self.timestamp = datetime.now().isoformat()
+ self.level = level
+ self.message = message
+ self.node_id = node_id
+ self.data = data or {}
+
+ def to_dict(self) -> dict:
+ return {
+ 'id': self.id,
+ 'timestamp': self.timestamp,
+ 'level': self.level,
+ 'message': self.message,
+ 'node_id': self.node_id,
+ 'data': self.data,
+ }
+
+
+class DebugExecutionState:
+ """State for a debug execution"""
+
+ def __init__(self, execution_id: str, breakpoints: list[str] = None):
+ self.execution_id = execution_id
+ self.status: str = 'running'
+ self.is_paused: bool = False
+ self.is_stopped: bool = False
+ self.current_node_id: Optional[str] = None
+ self.breakpoints: set[str] = set(breakpoints or [])
+ self.logs: list[ExecutionLog] = []
+ self.pending_logs: list[ExecutionLog] = []
+ self._pause_event = asyncio.Event()
+ self._pause_event.set() # Initially not paused
+ self._stop_event = asyncio.Event()
+
+ def add_log(self, level: str, message: str, node_id: str = None, data: dict = None):
+ """Add a log entry"""
+ log = ExecutionLog(level, message, node_id, data)
+ self.logs.append(log)
+ self.pending_logs.append(log)
+ logger.log(
+ getattr(logging, level.upper(), logging.INFO),
+ f'[Workflow Debug] {message}',
+ extra={'node_id': node_id, 'data': data},
+ )
+
+ def get_pending_logs(self) -> list[dict]:
+ """Get and clear pending logs"""
+ logs = [log.to_dict() for log in self.pending_logs]
+ self.pending_logs = []
+ return logs
+
+ def pause(self):
+ """Pause execution"""
+ self.is_paused = True
+ self._pause_event.clear()
+ self.add_log('info', 'Execution paused')
+
+ def resume(self):
+ """Resume execution"""
+ self.is_paused = False
+ self._pause_event.set()
+ self.add_log('info', 'Execution resumed')
+
+ def stop(self):
+ """Stop execution"""
+ self.is_stopped = True
+ self.status = 'cancelled'
+ self._stop_event.set()
+ self._pause_event.set() # Release any pause
+ self.add_log('info', 'Execution stopped')
+
+ async def wait_if_paused(self):
+ """Wait if execution is paused"""
+ if self.is_paused:
+ self.add_log('info', 'Waiting for resume...')
+ await self._pause_event.wait()
+
+ def check_breakpoint(self, node_id: str) -> bool:
+ """Check if there's a breakpoint at the given node"""
+ return node_id in self.breakpoints
+
+
+class DebugWorkflowExecutor(WorkflowExecutor):
+ """
+ Debug-enabled workflow executor with step-by-step execution support.
+ Extends WorkflowExecutor with debugging capabilities.
+ """
+
+ # Class-level storage for active debug sessions
+ _debug_states: dict[str, DebugExecutionState] = {}
+
+ def __init__(self, ap: Optional['app.Application'] = None):
+ super().__init__(ap)
+
+ @classmethod
+ def get_debug_state(cls, execution_id: str) -> Optional[DebugExecutionState]:
+ """Get debug state for an execution"""
+ return cls._debug_states.get(execution_id)
+
+ @classmethod
+ def create_debug_state(cls, execution_id: str, breakpoints: list[str] = None) -> DebugExecutionState:
+ """Create a new debug state"""
+ state = DebugExecutionState(execution_id, breakpoints)
+ cls._debug_states[execution_id] = state
+ return state
+
+ @classmethod
+ def remove_debug_state(cls, execution_id: str):
+ """Remove debug state for an execution"""
+ cls._debug_states.pop(execution_id, None)
+
+ async def execute_debug(
+ self,
+ workflow: WorkflowDefinition,
+ context: ExecutionContext,
+ debug_state: DebugExecutionState,
+ ) -> ExecutionContext:
+ """
+ Execute a workflow in debug mode.
+
+ Args:
+ workflow: Workflow definition
+ context: Execution context
+ debug_state: Debug execution state
+
+ Returns:
+ Updated execution context
+ """
+ context.status = ExecutionStatus.RUNNING
+ context.start_time = datetime.now()
+ debug_state.add_log('info', f'Starting debug execution for workflow: {workflow.name}')
+
+ try:
+ # Build execution graph
+ node_map = {node.id: node for node in workflow.nodes}
+ edge_map = self._build_edge_map(workflow.edges)
+ self._edges = workflow.edges
+
+ # Initialize node states
+ for node in workflow.nodes:
+ if node.id not in context.node_states:
+ context.node_states[node.id] = NodeState(node_id=node.id)
+
+ # Find start node(s)
+ start_nodes = self._find_start_nodes(workflow.nodes, workflow.edges)
+
+ if not start_nodes:
+ raise ValueError('No start nodes found in workflow')
+
+ debug_state.add_log('info', f'Found {len(start_nodes)} start node(s)')
+
+ # Execute from start nodes
+ for start_node in start_nodes:
+ if debug_state.is_stopped:
+ break
+
+ await self._execute_debug_from_node(
+ start_node, node_map, edge_map, context, debug_state, workflow.settings.max_retries
+ )
+
+ # Set final status
+ if debug_state.is_stopped:
+ context.status = ExecutionStatus.CANCELLED
+ debug_state.status = 'cancelled'
+ else:
+ all_completed = all(
+ state.status in (NodeStatus.COMPLETED, NodeStatus.SKIPPED) for state in context.node_states.values()
+ )
+
+ if all_completed:
+ context.status = ExecutionStatus.COMPLETED
+ debug_state.status = 'completed'
+ debug_state.add_log('info', 'Workflow execution completed successfully')
+ else:
+ has_failed = any(state.status == NodeStatus.FAILED for state in context.node_states.values())
+ if has_failed:
+ context.status = ExecutionStatus.FAILED
+ debug_state.status = 'error'
+
+ except Exception as e:
+ context.status = ExecutionStatus.FAILED
+ context.error = str(e)
+ debug_state.status = 'error'
+ debug_state.add_log('error', f'Workflow execution failed: {e}', data={'traceback': traceback.format_exc()})
+ logger.error(f'Debug workflow execution failed: {e}\n{traceback.format_exc()}')
+
+ finally:
+ context.end_time = datetime.now()
+
+ return context
+
+ async def _execute_debug_from_node(
+ self,
+ node: NodeDefinition,
+ node_map: dict[str, NodeDefinition],
+ edge_map: dict[str, list[EdgeDefinition]],
+ context: ExecutionContext,
+ debug_state: DebugExecutionState,
+ max_retries: int = 3,
+ ):
+ """Execute workflow from a node with debug support"""
+
+ # Check if stopped
+ if debug_state.is_stopped:
+ return
+
+ # Wait if paused
+ await debug_state.wait_if_paused()
+
+ # Check if should skip
+ if await self._should_skip_node(node, context):
+ if context.node_states[node.id].status == NodeStatus.SKIPPED:
+ debug_state.add_log('info', f'Skipping node: {node.id}', node_id=node.id)
+ return
+
+ # Check breakpoint
+ if debug_state.check_breakpoint(node.id):
+ debug_state.add_log('info', f'Hit breakpoint at node: {node.id}', node_id=node.id)
+ debug_state.pause()
+ await debug_state.wait_if_paused()
+
+ # Update current node
+ debug_state.current_node_id = node.id
+ debug_state.add_log('info', f'Executing node: {node.id} ({node.type})', node_id=node.id)
+
+ # Execute node
+ await self._execute_debug_node(node, context, debug_state, max_retries)
+
+ # Check if stopped or failed
+ if debug_state.is_stopped:
+ return
+ if context.node_states[node.id].status == NodeStatus.FAILED:
+ return
+
+ # Get outgoing edges
+ outgoing_edges = edge_map.get(node.id, [])
+
+ # Execute next nodes
+ for edge in outgoing_edges:
+ if debug_state.is_stopped:
+ break
+
+ target_node = node_map.get(edge.target_node)
+ if not target_node:
+ continue
+
+ # Check edge condition
+ if edge.condition:
+ condition_met = await self._evaluate_condition(edge.condition, context)
+ if not condition_met:
+ debug_state.add_log('debug', f'Edge condition not met: {edge.condition}', node_id=node.id)
+ continue
+
+ # Check if all inputs are ready
+ if await self._inputs_ready(target_node, edge_map, context):
+ await self._execute_debug_from_node(target_node, node_map, edge_map, context, debug_state, max_retries)
+
+ async def _execute_debug_node(
+ self, node: NodeDefinition, context: ExecutionContext, debug_state: DebugExecutionState, max_retries: int = 3
+ ):
+ """Execute a single node with debug logging"""
+
+ node_state = context.node_states[node.id]
+ node_state.status = NodeStatus.RUNNING
+ node_state.start_time = datetime.now()
+
+ # Get node instance (pass ap for access to services)
+ node_instance = self.registry.create_instance(node.type, node.id, node.config, ap=self.ap)
+
+ if not node_instance:
+ node_state.status = NodeStatus.FAILED
+ node_state.error = f'Unknown node type: {node.type}'
+ node_state.end_time = datetime.now()
+ debug_state.add_log('error', f'Unknown node type: {node.type}', node_id=node.id)
+ self._record_execution_step(node, node_state, context)
+ await self._persist_node_execution(node, node_state, context)
+ return
+
+ # Resolve inputs
+ inputs = await self._resolve_inputs(node, context)
+ node_state.inputs = inputs
+ debug_state.add_log(
+ 'debug', 'Node inputs resolved', node_id=node.id, data={'inputs': self._safe_serialize(inputs)}
+ )
+
+ # Validate inputs
+ validation_errors = await node_instance.validate_inputs(inputs)
+ if validation_errors:
+ node_state.status = NodeStatus.FAILED
+ node_state.error = '; '.join(validation_errors)
+ node_state.end_time = datetime.now()
+ debug_state.add_log('error', f'Input validation failed: {node_state.error}', node_id=node.id)
+ self._record_execution_step(node, node_state, context)
+ await self._persist_node_execution(node, node_state, context)
+ return
+
+ # Execute with retries
+ for attempt in range(max_retries + 1):
+ if debug_state.is_stopped:
+ node_state.status = NodeStatus.FAILED
+ node_state.error = 'Execution stopped'
+ node_state.end_time = datetime.now()
+ break
+
+ try:
+ outputs = await node_instance.execute(inputs, context)
+ node_state.outputs = outputs
+ node_state.status = NodeStatus.COMPLETED
+ node_state.end_time = datetime.now()
+
+ duration_ms = int((node_state.end_time - node_state.start_time).total_seconds() * 1000)
+ debug_state.add_log(
+ 'info',
+ f'Node completed in {duration_ms}ms',
+ node_id=node.id,
+ data={'outputs': self._safe_serialize(outputs), 'duration_ms': duration_ms},
+ )
+ break
+
+ except Exception as e:
+ node_state.retry_count = attempt + 1
+ debug_state.add_log(
+ 'warning', f'Node execution failed (attempt {attempt + 1}/{max_retries + 1}): {e}', node_id=node.id
+ )
+
+ if attempt < max_retries:
+ await asyncio.sleep(1)
+ else:
+ node_state.status = NodeStatus.FAILED
+ node_state.error = str(e)
+ node_state.end_time = datetime.now()
+ debug_state.add_log(
+ 'error',
+ f'Node failed after {max_retries + 1} attempts: {e}',
+ node_id=node.id,
+ data={'error': str(e), 'traceback': traceback.format_exc()},
+ )
+
+ self._record_execution_step(node, node_state, context)
+ await self._persist_node_execution(node, node_state, context)
+
+ async def step_execute(
+ self,
+ workflow: WorkflowDefinition,
+ context: ExecutionContext,
+ debug_state: DebugExecutionState,
+ ) -> dict:
+ """
+ Execute one step (one node) in debug mode.
+
+ Returns:
+ Dict with node_id, node_state, and completed status
+ """
+ # Find next node to execute
+ next_node = self._find_next_executable_node(workflow, context)
+
+ if not next_node:
+ debug_state.status = 'completed'
+ return {'completed': True}
+
+ # Execute single node
+ debug_state.current_node_id = next_node.id
+ await self._execute_debug_node(next_node, context, debug_state, workflow.settings.max_retries)
+
+ node_state = context.node_states.get(next_node.id)
+
+ # Check if workflow is complete
+ all_done = all(
+ state.status in (NodeStatus.COMPLETED, NodeStatus.SKIPPED, NodeStatus.FAILED)
+ for state in context.node_states.values()
+ )
+
+ if all_done:
+ debug_state.status = 'completed'
+ context.status = ExecutionStatus.COMPLETED
+
+ return {
+ 'node_id': next_node.id,
+ 'node_state': {
+ 'status': node_state.status.value if node_state else 'unknown',
+ 'inputs': self._safe_serialize(node_state.inputs) if node_state else {},
+ 'outputs': self._safe_serialize(node_state.outputs) if node_state else {},
+ 'error': node_state.error if node_state else None,
+ },
+ 'completed': all_done,
+ }
+
+ def _find_next_executable_node(
+ self, workflow: WorkflowDefinition, context: ExecutionContext
+ ) -> Optional[NodeDefinition]:
+ """Find the next node that can be executed"""
+ edge_map = self._build_edge_map(workflow.edges)
+
+ for node in workflow.nodes:
+ state = context.node_states.get(node.id)
+
+ # Skip completed, running, or failed nodes
+ if state and state.status in (
+ NodeStatus.COMPLETED,
+ NodeStatus.RUNNING,
+ NodeStatus.FAILED,
+ NodeStatus.SKIPPED,
+ ):
+ continue
+
+ # Check if this node's inputs are ready
+ incoming_nodes = set()
+ for source_id, edges in edge_map.items():
+ for edge in edges:
+ if edge.target_node == node.id:
+ incoming_nodes.add(source_id)
+
+ # If no incoming nodes, it's a start node
+ if not incoming_nodes:
+ return node
+
+ # Check if all incoming nodes are done
+ all_incoming_done = True
+ for source_id in incoming_nodes:
+ source_state = context.node_states.get(source_id)
+ if not source_state or source_state.status not in (NodeStatus.COMPLETED, NodeStatus.SKIPPED):
+ all_incoming_done = False
+ break
+
+ if all_incoming_done:
+ return node
+
+ return None
+
+ def _safe_serialize(self, data: Any) -> Any:
+ """Safely serialize data for logging"""
+ if data is None:
+ return None
+ if isinstance(data, (str, int, float, bool)):
+ return data
+ if isinstance(data, (list, tuple)):
+ return [self._safe_serialize(item) for item in data[:100]] # Limit list size
+ if isinstance(data, dict):
+ result = {}
+ for key, value in list(data.items())[:50]: # Limit dict size
+ result[str(key)] = self._safe_serialize(value)
+ return result
+ # For complex objects, try to convert to string
+ try:
+ return str(data)[:1000] # Limit string length
+ except Exception:
+ return ''
+
+ def get_execution_state(self, context: ExecutionContext, debug_state: DebugExecutionState) -> dict:
+ """Get current execution state for API response"""
+ node_states = {}
+ for node_id, state in context.node_states.items():
+ node_states[node_id] = {
+ 'status': state.status.value,
+ 'inputs': self._safe_serialize(state.inputs),
+ 'outputs': self._safe_serialize(state.outputs),
+ 'error': state.error,
+ 'startTime': state.start_time.isoformat() if state.start_time else None,
+ 'endTime': state.end_time.isoformat() if state.end_time else None,
+ 'duration': int((state.end_time - state.start_time).total_seconds() * 1000)
+ if state.start_time and state.end_time
+ else None,
+ }
+
+ return {
+ 'status': debug_state.status,
+ 'current_node_id': debug_state.current_node_id,
+ 'node_states': node_states,
+ 'new_logs': debug_state.get_pending_logs(),
+ 'error': context.error,
+ }
diff --git a/src/langbot/pkg/workflow/entities.py b/src/langbot/pkg/workflow/entities.py
new file mode 100644
index 000000000..93d6a8f44
--- /dev/null
+++ b/src/langbot/pkg/workflow/entities.py
@@ -0,0 +1,153 @@
+"""Workflow entities and data models
+
+This module defines workflow entities using SDK standard entities where available,
+and local-specific entities for LangBot_copy-specific functionality.
+"""
+
+from __future__ import annotations
+
+from datetime import datetime
+from typing import Any, Optional
+import pydantic
+
+# Import SDK entities for standard workflow protocol types
+from langbot_plugin.api.entities.builtin.workflow import (
+ ExecutionContext,
+ ExecutionStep,
+ ExecutionStatus,
+ MessageContext,
+ NodeDefinition,
+ NodeState,
+ NodeStatus,
+ PortDefinition,
+)
+
+
+class Position(pydantic.BaseModel):
+ """Node position on canvas"""
+
+ x: float = 0
+ y: float = 0
+
+
+class EdgeDefinition(pydantic.BaseModel):
+ """Workflow edge definition (connection between nodes)"""
+
+ id: str
+ source_node: str
+ source_port: str = 'output'
+ target_node: str
+ target_port: str = 'input'
+ condition: Optional[str] = None # Optional condition expression
+
+
+class TriggerDefinition(pydantic.BaseModel):
+ """Workflow trigger definition"""
+
+ id: str
+ type: str # message, cron, event, webhook
+ config: dict[str, Any] = {}
+ enabled: bool = True
+
+
+class WorkflowSettings(pydantic.BaseModel):
+ """Workflow settings"""
+
+ # Execution settings
+ max_execution_time: int = 300 # seconds
+ max_retries: int = 3
+ retry_delay: int = 5 # seconds
+
+ # Error handling
+ error_handling: str = 'stop' # stop, continue, retry
+
+ # Logging
+ log_level: str = 'info'
+ save_execution_history: bool = True
+
+ # Concurrency
+ max_concurrent_executions: int = 10
+
+
+class SafetyConfig(pydantic.BaseModel):
+ """Safety configuration (inherited from Pipeline)"""
+
+ content_filter: dict[str, Any] = {'enable': False, 'sensitive_words': [], 'replace_with': '***'}
+ rate_limit: dict[str, Any] = {'enable': False, 'requests_per_minute': 60, 'burst_limit': 10}
+
+
+class OutputConfig(pydantic.BaseModel):
+ """Output configuration (inherited from Pipeline)"""
+
+ long_text_processing: dict[str, Any] = {
+ 'strategy': 'split', # split, truncate, file
+ 'max_length': 4000,
+ 'split_separator': '\n\n',
+ }
+ force_delay: dict[str, Any] = {'enable': False, 'min_delay_ms': 0, 'max_delay_ms': 0}
+ misc: dict[str, Any] = {}
+
+
+class WorkflowGlobalConfig(pydantic.BaseModel):
+ """Workflow global configuration (inherited from Pipeline capabilities)"""
+
+ safety: SafetyConfig = SafetyConfig()
+ output: OutputConfig = OutputConfig()
+
+
+class ExtensionsPreferences(pydantic.BaseModel):
+ """Extensions preferences (same as Pipeline)"""
+
+ enable_all_plugins: bool = True
+ enable_all_mcp_servers: bool = True
+ plugins: list[str] = []
+ mcp_servers: list[str] = []
+
+
+class ConversationVariable(pydantic.BaseModel):
+ """Conversation-level variable definition"""
+
+ name: str
+ type: str = 'string' # string, number, boolean, object, array
+ description: str = ''
+ default_value: Any = None
+ max_length: Optional[int] = None # For strings
+
+
+class WorkflowDefinition(pydantic.BaseModel):
+ """Complete workflow definition"""
+
+ uuid: str
+ name: str
+ description: str = ''
+ emoji: str = '💼'
+ version: int = 1
+
+ # Workflow graph
+ nodes: list[NodeDefinition] = []
+ edges: list[EdgeDefinition] = []
+
+ # Variables
+ variables: dict[str, Any] = {} # Global variables
+ conversation_variables: list[ConversationVariable] = [] # Session-level variables
+
+ # Settings
+ settings: WorkflowSettings = WorkflowSettings()
+
+ # Triggers (for automation)
+ triggers: list[TriggerDefinition] = []
+
+ # Global configuration (inherited from Pipeline)
+ global_config: WorkflowGlobalConfig = WorkflowGlobalConfig()
+
+ # Extensions
+ extensions_preferences: ExtensionsPreferences = ExtensionsPreferences()
+
+ # Metadata
+ is_enabled: bool = True
+ created_at: Optional[datetime] = None
+ updated_at: Optional[datetime] = None
+
+ # Source tracking (for imported workflows)
+ source: Optional[str] = None # dify, n8n, langflow, etc.
+ source_id: Optional[str] = None
diff --git a/src/langbot/pkg/workflow/executor.py b/src/langbot/pkg/workflow/executor.py
new file mode 100644
index 000000000..a5dee55f2
--- /dev/null
+++ b/src/langbot/pkg/workflow/executor.py
@@ -0,0 +1,826 @@
+"""Workflow execution engine.
+
+This module contains the core workflow execution logic:
+- WorkflowExecutor: Main execution engine with control flow handling
+- ParallelExecutor: Parallel branch execution
+- LoopExecutor: Loop/iterator execution
+
+Debug execution support has been moved to the ``debug`` module.
+"""
+
+from __future__ import annotations
+
+import ast
+import asyncio
+import logging
+import operator
+import uuid
+from datetime import datetime
+from typing import Any, Optional, TYPE_CHECKING
+
+import sqlalchemy
+
+from .entities import (
+ WorkflowDefinition,
+ NodeDefinition,
+ EdgeDefinition,
+ ExecutionContext,
+ ExecutionStatus,
+ NodeState,
+ NodeStatus,
+ ExecutionStep,
+)
+from ..entity.persistence import workflow as persistence_workflow
+from .registry import NodeTypeRegistry
+
+if TYPE_CHECKING:
+ from ..core import app
+
+logger = logging.getLogger(__name__)
+
+
+# ─── Safe expression evaluator (replaces eval()) ─────────────────────
+# Uses Python's ast module to whitelist only comparison / boolean / arithmetic
+# operations. No function calls, attribute access, or subscript injection.
+
+_SAFE_OPS = {
+ ast.Add: operator.add,
+ ast.Sub: operator.sub,
+ ast.Mult: operator.mul,
+ ast.Div: operator.truediv,
+ ast.FloorDiv: operator.floordiv,
+ ast.Mod: operator.mod,
+ ast.Pow: operator.pow,
+ ast.USub: operator.neg,
+ ast.UAdd: operator.pos,
+ ast.Not: operator.not_,
+ ast.Eq: operator.eq,
+ ast.NotEq: operator.ne,
+ ast.Lt: operator.lt,
+ ast.LtE: operator.le,
+ ast.Gt: operator.gt,
+ ast.GtE: operator.ge,
+ ast.Is: operator.is_,
+ ast.IsNot: operator.is_not,
+ ast.In: lambda a, b: a in b,
+ ast.NotIn: lambda a, b: a not in b,
+}
+
+
+def _safe_eval(expr: str) -> Any:
+ """Evaluate a simple expression safely via AST whitelist.
+
+ Supports: literals, comparisons (==, !=, <, >, <=, >=, in, not in, is, is not),
+ boolean logic (and, or, not), arithmetic (+, -, *, /, //, %, **), and
+ string operations (contains via ``in``).
+
+ Raises ``ValueError`` on any disallowed construct (function calls,
+ attribute access, imports, etc.).
+ """
+ tree = ast.parse(expr.strip(), mode='eval')
+ return _eval_node(tree.body)
+
+
+def _eval_node(node: ast.AST) -> Any:
+ # Literals: numbers, strings, True/False/None
+ if isinstance(node, ast.Constant):
+ return node.value
+
+ # Unary operators: -x, +x, not x
+ if isinstance(node, ast.UnaryOp):
+ op_fn = _SAFE_OPS.get(type(node.op))
+ if op_fn is None:
+ raise ValueError(f'Unsupported unary op: {type(node.op).__name__}')
+ return op_fn(_eval_node(node.operand))
+
+ # Binary operators: x + y, x * y, etc.
+ if isinstance(node, ast.BinOp):
+ op_fn = _SAFE_OPS.get(type(node.op))
+ if op_fn is None:
+ raise ValueError(f'Unsupported binary op: {type(node.op).__name__}')
+ return op_fn(_eval_node(node.left), _eval_node(node.right))
+
+ # Comparisons: x == y, x > y, x in y, etc. (chained)
+ if isinstance(node, ast.Compare):
+ left = _eval_node(node.left)
+ for op, comparator in zip(node.ops, node.comparators):
+ op_fn = _SAFE_OPS.get(type(op))
+ if op_fn is None:
+ raise ValueError(f'Unsupported comparison: {type(op).__name__}')
+ right = _eval_node(comparator)
+ if not op_fn(left, right):
+ return False
+ left = right
+ return True
+
+ # Boolean operators: x and y, x or y
+ if isinstance(node, ast.BoolOp):
+ if isinstance(node.op, ast.And):
+ return all(_eval_node(v) for v in node.values)
+ if isinstance(node.op, ast.Or):
+ return any(_eval_node(v) for v in node.values)
+
+ # Ternary: x if cond else y
+ if isinstance(node, ast.IfExp):
+ return _eval_node(node.body) if _eval_node(node.test) else _eval_node(node.orelse)
+
+ # Tuples / Lists (used in "x in [1,2,3]")
+ if isinstance(node, (ast.Tuple, ast.List)):
+ return [_eval_node(e) for e in node.elts]
+
+ # Name lookup – only allow None, True, False
+ if isinstance(node, ast.Name):
+ if node.id == 'None':
+ return None
+ if node.id == 'True':
+ return True
+ if node.id == 'False':
+ return False
+ raise ValueError(f'Unsupported variable reference: {node.id}')
+
+ raise ValueError(f'Unsupported expression node: {type(node).__name__}')
+
+
+class WorkflowExecutor:
+ """
+ Workflow execution engine.
+ Handles the execution of workflow definitions with proper control flow.
+ """
+
+ def __init__(self, ap: Optional['app.Application'] = None):
+ self.ap = ap
+ self.registry = NodeTypeRegistry.instance()
+ self._edges: list[EdgeDefinition] = []
+
+ async def execute(
+ self, workflow: WorkflowDefinition, context: ExecutionContext, start_node_id: Optional[str] = None
+ ) -> ExecutionContext:
+ """
+ Execute a workflow.
+
+ Args:
+ workflow: Workflow definition
+ context: Execution context
+ start_node_id: Optional starting node (for resumption)
+
+ Returns:
+ Updated execution context
+ """
+ context.status = ExecutionStatus.RUNNING
+ context.start_time = datetime.now()
+
+ try:
+ # Build execution graph
+ node_map = {node.id: node for node in workflow.nodes}
+ edge_map = self._build_edge_map(workflow.edges)
+ self._edges = workflow.edges
+
+ # Initialize node states
+ for node in workflow.nodes:
+ if node.id not in context.node_states:
+ context.node_states[node.id] = NodeState(node_id=node.id, node_type=node.type, status=NodeStatus.PENDING)
+
+ # Find start node(s)
+ if start_node_id:
+ start_nodes = [node_map[start_node_id]]
+ else:
+ start_nodes = self._find_start_nodes(workflow.nodes, workflow.edges)
+
+ if not start_nodes:
+ raise ValueError('No start nodes found in workflow')
+
+ # Execute from start nodes
+ for start_node in start_nodes:
+ await self._execute_from_node(
+ start_node, node_map, edge_map, context, workflow.settings.max_retries, path=set()
+ )
+
+ # Check final status
+ all_completed = all(
+ state.status in (NodeStatus.COMPLETED, NodeStatus.SKIPPED) for state in context.node_states.values()
+ )
+
+ if all_completed:
+ context.status = ExecutionStatus.COMPLETED
+ else:
+ # Some nodes might still be waiting
+ has_failed = any(state.status == NodeStatus.FAILED for state in context.node_states.values())
+ if has_failed:
+ context.status = ExecutionStatus.FAILED
+
+ except Exception as e:
+ context.status = ExecutionStatus.FAILED
+ context.error = str(e)
+ logger.error(
+ 'Workflow execution failed',
+ exc_info=True,
+ extra={
+ 'workflow_id': workflow.uuid,
+ 'execution_id': context.execution_id,
+ 'node_states': {
+ node_id: {
+ 'status': state.status.value if state.status else None,
+ 'error': state.error,
+ }
+ for node_id, state in context.node_states.items()
+ },
+ },
+ )
+
+ finally:
+ context.end_time = datetime.now()
+
+ return context
+
+ async def _execute_from_node(
+ self,
+ node: NodeDefinition,
+ node_map: dict[str, NodeDefinition],
+ edge_map: dict[str, list[EdgeDefinition]],
+ context: ExecutionContext,
+ max_retries: int = 3,
+ path: set[str] | None = None,
+ ):
+ """Execute workflow starting from a specific node"""
+
+ # Initialize path set for cycle detection (path-based, not global visited)
+ if path is None:
+ path = set()
+
+ # Check for circular dependency on the *current path* only
+ # This correctly allows diamond shapes (A→B, A→C, B→D, C→D)
+ if node.id in path:
+ logger.warning(f'Circular dependency detected at node: {node.id}')
+ context.node_states[node.id].status = NodeStatus.SKIPPED
+ context.node_states[node.id].error = 'Circular dependency detected'
+ context.node_states[node.id].end_time = datetime.now()
+ await self._persist_node_execution(node, context.node_states[node.id], context)
+ return
+
+ # Add node to current path
+ path.add(node.id)
+
+ # Check if node should be skipped
+ if await self._should_skip_node(node, context):
+ existing_state = context.node_states[node.id]
+ if existing_state.status == NodeStatus.SKIPPED:
+ existing_state.end_time = existing_state.end_time or datetime.now()
+ await self._persist_node_execution(node, existing_state, context)
+ path.discard(node.id)
+ return
+
+ # Execute current node
+ await self._execute_node(node, context, max_retries)
+
+ # If node failed and we should stop on error, return
+ if context.node_states[node.id].status == NodeStatus.FAILED:
+ path.discard(node.id)
+ return
+
+ node_state = context.node_states[node.id]
+ node_type_name = node.type.split('.')[-1] if '.' in node.type else node.type
+
+ # ── Control flow integration ────────────────────────────────
+ # For loop / iterator nodes: run the LoopExecutor over
+ # downstream body nodes for each item, then continue to the
+ # "completed" output edge.
+ if node_type_name in ('loop', 'iterator'):
+ items = node_state.outputs.get('_items') or []
+ if not items:
+ # iterator: items come from inputs
+ items = node_state.inputs.get('items', node_state.inputs.get('array', []))
+ if not isinstance(items, list):
+ items = [items] if items else []
+ max_iter = int(node.config.get('max_iterations', 100))
+ items = items[:max_iter]
+
+ # Collect downstream "body" nodes (connected via edges)
+ outgoing_edges = edge_map.get(node.id, [])
+ body_nodes = []
+ for edge in outgoing_edges:
+ target = node_map.get(edge.target_node)
+ if target:
+ body_nodes.append(target)
+
+ if body_nodes and items:
+ loop_exec = LoopExecutor(self)
+ results = await loop_exec.execute_loop(items, body_nodes, context, max_iter)
+ node_state.outputs['results'] = results
+ node_state.outputs['completed'] = True
+ else:
+ node_state.outputs['results'] = []
+ node_state.outputs['completed'] = True
+
+ path.discard(node.id)
+ return # body nodes already executed by LoopExecutor
+
+ # For parallel nodes: run downstream branches concurrently
+ if node_type_name == 'parallel':
+ outgoing_edges = edge_map.get(node.id, [])
+ branch_nodes = []
+ for edge in outgoing_edges:
+ target = node_map.get(edge.target_node)
+ if target:
+ branch_nodes.append([target])
+
+ if branch_nodes:
+ par_exec = ParallelExecutor(self)
+ results = await par_exec.execute_parallel(branch_nodes, context)
+ node_state.outputs['results'] = results
+
+ path.discard(node.id)
+ return # branch nodes already executed by ParallelExecutor
+
+ # ── Standard edge-based continuation ────────────────────────
+ # Get outgoing edges
+ outgoing_edges = edge_map.get(node.id, [])
+
+ # Execute next nodes based on edge conditions
+ for edge in outgoing_edges:
+ target_node = node_map.get(edge.target_node)
+ if not target_node:
+ continue
+
+ # Check edge condition
+ if edge.condition:
+ condition_met = await self._evaluate_condition(edge.condition, context)
+ if not condition_met:
+ continue
+
+ # Check if all inputs are ready
+ if await self._inputs_ready(target_node, edge_map, context):
+ await self._execute_from_node(target_node, node_map, edge_map, context, max_retries, path)
+
+ # Remove node from path when backtracking (allows diamond revisit)
+ path.discard(node.id)
+
+ async def _execute_node(self, node: NodeDefinition, context: ExecutionContext, max_retries: int = 3):
+ """Execute a single node with retry logic"""
+
+ node_state = context.node_states[node.id]
+ node_state.status = NodeStatus.RUNNING
+ node_state.start_time = datetime.now()
+
+ # Get node instance (pass ap for access to services)
+ node_instance = self.registry.create_instance(node.type, node.id, node.config, ap=self.ap)
+
+ if not node_instance:
+ node_state.status = NodeStatus.FAILED
+ node_state.error = f'Unknown node type: {node.type}'
+ node_state.end_time = datetime.now()
+ self._record_execution_step(node, node_state, context)
+ await self._persist_node_execution(node, node_state, context)
+ return
+
+ # Resolve inputs
+ inputs = await self._resolve_inputs(node, context)
+ node_state.inputs = inputs
+
+ # Validate inputs
+ validation_errors = await node_instance.validate_inputs(inputs)
+ if validation_errors:
+ node_state.status = NodeStatus.FAILED
+ node_state.error = '; '.join(validation_errors)
+ node_state.end_time = datetime.now()
+ self._record_execution_step(node, node_state, context)
+ await self._persist_node_execution(node, node_state, context)
+ return
+
+ # Check if node supports streaming (has execute_stream method and stream config is enabled)
+ use_streaming = hasattr(node_instance, 'execute_stream') and node.config.get('stream', False)
+
+ # Execute with retries
+ for attempt in range(max_retries + 1):
+ try:
+ if use_streaming:
+ # Streaming execution with aggregation and timeout
+ aggregated_response = ''
+ try:
+ async with asyncio.timeout(300): # 5 minute timeout for streaming
+ async for chunk in node_instance.execute_stream(inputs, context):
+ if chunk:
+ aggregated_response += chunk
+ except asyncio.TimeoutError:
+ logger.warning(f'Node {node.id} ({node.type}) streaming timed out, falling back to non-streaming')
+ use_streaming = False
+ outputs = await node_instance.execute(inputs, context)
+ else:
+ # Get response from context if set by execute_stream, otherwise use aggregated
+ final_response = context.variables.pop('_last_llm_response', aggregated_response)
+ outputs = {'response': final_response, 'usage': {'prompt_tokens': 0, 'completion_tokens': 0, 'total_tokens': 0}}
+ logger.info(f'Node {node.id} ({node.type}) streaming completed, response length: {len(final_response)}')
+ else:
+ outputs = await node_instance.execute(inputs, context)
+ node_state.outputs = outputs
+ node_state.status = NodeStatus.COMPLETED
+ node_state.end_time = datetime.now()
+ break
+ except Exception as e:
+ node_state.retry_count = attempt + 1
+ logger.error(
+ f'Node {node.id} ({node.type}) execution failed (attempt {attempt + 1}/{max_retries + 1}): {e}',
+ exc_info=True,
+ extra={
+ 'node_id': node.id,
+ 'node_type': node.type,
+ 'attempt': attempt + 1,
+ 'max_retries': max_retries,
+ 'execution_id': context.execution_id,
+ },
+ )
+
+ if attempt < max_retries:
+ await asyncio.sleep(1) # Brief delay before retry
+ else:
+ node_state.status = NodeStatus.FAILED
+ node_state.error = str(e)
+ node_state.end_time = datetime.now()
+ logger.error(
+ f'Node {node.id} ({node.type}) permanently failed after {max_retries + 1} attempts',
+ extra={
+ 'node_id': node.id,
+ 'node_type': node.type,
+ 'error': str(e),
+ 'execution_id': context.execution_id,
+ },
+ )
+
+ self._record_execution_step(node, node_state, context)
+ await self._persist_node_execution(node, node_state, context)
+
+ async def _resolve_inputs(self, node: NodeDefinition, context: ExecutionContext) -> dict[str, Any]:
+ """Resolve input values for a node from connected nodes and context"""
+ inputs = {}
+
+ # Get inputs from context variables
+ if 'message' in context.variables:
+ inputs['message'] = context.variables['message']
+
+ # Get inputs from message context
+ if context.message_context:
+ inputs['message'] = context.message_context.message_content
+ inputs['message_content'] = context.message_context.message_content
+ inputs['sender_id'] = context.message_context.sender_id
+ inputs['platform'] = context.message_context.platform
+ else:
+ logger.warning(
+ f'[_resolve_inputs] node={node.id} ({node.type}): message_context is None!',
+ extra={
+ 'node_id': node.id,
+ 'node_type': node.type,
+ 'execution_id': context.execution_id,
+ 'variables_keys': list(context.variables.keys()) if context.variables else [],
+ },
+ )
+
+ # Log current inputs state after message_context processing
+ logger.debug(
+ f'[_resolve_inputs] node={node.id} after message_context: {list(inputs.keys())}',
+ )
+
+ # Get inputs from node config that reference other nodes
+ for key, value in node.config.items():
+ if isinstance(value, str) and value.startswith('{{') and value.endswith('}}'):
+ resolved = await self._resolve_expression(value[2:-2], context)
+ inputs[key] = resolved
+ else:
+ inputs[key] = value
+
+ # Get inputs from connected upstream nodes via edges
+ # Build a reverse map: for each incoming edge to this node, find the
+ # source node and the specific source/target port.
+ for edge in self._edges:
+ if edge.target_node != node.id:
+ continue
+ source_state = context.node_states.get(edge.source_node)
+ if not source_state or source_state.status != NodeStatus.COMPLETED:
+ continue
+ target_port = edge.target_port or 'input'
+ source_port = edge.source_port or 'output'
+ # Map the source node's output port value to this node's input port
+ if source_port in source_state.outputs:
+ inputs[target_port] = source_state.outputs[source_port]
+ elif 'output' in source_state.outputs:
+ # Fallback: if exact port not found, try generic 'output'
+ inputs[target_port] = source_state.outputs['output']
+ elif source_state.outputs:
+ # Last resort: use the first available output
+ inputs[target_port] = next(iter(source_state.outputs.values()))
+
+ # Smart input mapping: if a node needs 'message' but received a different
+ # port name (e.g., 'content' from llm_call), copy the value to 'message'.
+ # This handles edge connection mismatches where the sender uses a different
+ # port name than what the receiver expects.
+ if 'message' not in inputs or inputs.get('message') is None:
+ for fallback_key in ('content', 'response', 'input', 'output', 'result', 'text'):
+ if fallback_key in inputs and inputs[fallback_key] is not None:
+ inputs['message'] = inputs[fallback_key]
+ logger.debug(
+ f'[_resolve_inputs] node={node.id}: mapped {fallback_key} -> message',
+ )
+ break
+
+ logger.debug(
+ f'[_resolve_inputs] node={node.id} final inputs keys: {list(inputs.keys())}, message={repr(inputs.get("message", "")[:100] if isinstance(inputs.get("message"), str) else inputs.get("message"))}',
+ )
+ return inputs
+
+ async def _resolve_expression(self, expression: str, context: ExecutionContext) -> Any:
+ """Resolve a variable expression like 'nodes.node1.outputs.text'"""
+ parts = expression.strip().split('.')
+
+ if not parts:
+ return None
+
+ if parts[0] == 'nodes' and len(parts) >= 4:
+ # nodes.node_id.outputs.output_name
+ node_id = parts[1]
+ if parts[2] == 'outputs' and node_id in context.node_states:
+ output_name = '.'.join(parts[3:])
+ return context.node_states[node_id].outputs.get(output_name)
+
+ elif parts[0] == 'variables':
+ # variables.var_name
+ var_name = '.'.join(parts[1:])
+ return context.variables.get(var_name)
+
+ elif parts[0] == 'conversation_variables':
+ # conversation_variables.var_name
+ var_name = '.'.join(parts[1:])
+ return context.conversation_variables.get(var_name)
+
+ elif parts[0] == 'message':
+ # message.content, message.sender_id, etc.
+ if context.message_context:
+ attr = parts[1] if len(parts) > 1 else None
+ if attr == 'content':
+ return context.message_context.message_content
+ elif attr == 'sender_id':
+ return context.message_context.sender_id
+ elif attr == 'platform':
+ return context.message_context.platform
+ elif attr == 'conversation_id':
+ return context.message_context.conversation_id
+
+ return None
+
+ async def _evaluate_condition(self, condition: str, context: ExecutionContext) -> bool:
+ """Evaluate a condition expression safely using AST whitelist"""
+ try:
+ # Resolve variable references in condition
+ if '{{' in condition:
+ import re
+
+ pattern = r'\{\{([^}]+)\}\}'
+
+ # First pass: replace all variable references with placeholders
+ placeholders = {}
+ placeholder_idx = 0
+
+ def replace_with_placeholder(match):
+ nonlocal placeholder_idx
+ var_expr = match.group(1)
+ placeholder = f'__PH{placeholder_idx}__'
+ placeholders[placeholder] = var_expr
+ placeholder_idx += 1
+ return placeholder
+
+ condition_with_placeholders = re.sub(pattern, replace_with_placeholder, condition)
+
+ # Second pass: resolve each placeholder asynchronously
+ for placeholder, var_expr in placeholders.items():
+ value = await self._resolve_expression(var_expr, context)
+ if isinstance(value, str):
+ condition_with_placeholders = condition_with_placeholders.replace(placeholder, f'"{value}"')
+ elif value is None:
+ condition_with_placeholders = condition_with_placeholders.replace(placeholder, 'None')
+ else:
+ condition_with_placeholders = condition_with_placeholders.replace(placeholder, str(value))
+
+ condition = condition_with_placeholders
+
+ # Safe expression evaluation using AST whitelist
+ result = _safe_eval(condition)
+ return bool(result)
+
+ except Exception as e:
+ logger.warning(f'Condition evaluation failed: {condition} - {e}')
+ return False
+
+ async def _should_skip_node(self, node: NodeDefinition, context: ExecutionContext) -> bool:
+ """Check if a node should be skipped"""
+ state = context.node_states.get(node.id)
+ if state and state.status in (NodeStatus.COMPLETED, NodeStatus.RUNNING, NodeStatus.SKIPPED):
+ return True
+ return False
+
+ async def _inputs_ready(
+ self, node: NodeDefinition, edge_map: dict[str, list[EdgeDefinition]], context: ExecutionContext
+ ) -> bool:
+ """Check if all inputs for a node are ready"""
+ # Find all edges that connect to this node
+ incoming_nodes = set()
+ for source_id, edges in edge_map.items():
+ for edge in edges:
+ if edge.target_node == node.id:
+ incoming_nodes.add(source_id)
+
+ # Check if all incoming nodes have completed
+ for source_id in incoming_nodes:
+ state = context.node_states.get(source_id)
+ if not state or state.status not in (NodeStatus.COMPLETED, NodeStatus.SKIPPED):
+ return False
+
+ return True
+
+ def _find_start_nodes(self, nodes: list[NodeDefinition], edges: list[EdgeDefinition]) -> list[NodeDefinition]:
+ """Find nodes that have no incoming edges (start nodes)"""
+ target_nodes = {edge.target_node for edge in edges}
+ start_nodes = [node for node in nodes if node.id not in target_nodes]
+
+ # Also check for trigger nodes
+ trigger_types = {'message_trigger', 'cron_trigger', 'webhook_trigger', 'event_trigger'}
+ for node in nodes:
+ if node.type in trigger_types and node not in start_nodes:
+ start_nodes.insert(0, node)
+
+ return start_nodes
+
+ def _build_edge_map(self, edges: list[EdgeDefinition]) -> dict[str, list[EdgeDefinition]]:
+ """Build a map of source node ID to outgoing edges"""
+ edge_map: dict[str, list[EdgeDefinition]] = {}
+ for edge in edges:
+ if edge.source_node not in edge_map:
+ edge_map[edge.source_node] = []
+ edge_map[edge.source_node].append(edge)
+ return edge_map
+
+ def _record_execution_step(self, node: NodeDefinition, node_state: NodeState, context: ExecutionContext):
+ """Record an execution step in the history"""
+ duration_ms = 0
+ if node_state.start_time and node_state.end_time:
+ duration_ms = int((node_state.end_time - node_state.start_time).total_seconds() * 1000)
+
+ step = ExecutionStep(
+ step_id=f"step_{uuid.uuid4().hex[:8]}",
+ timestamp=datetime.now(),
+ node_id=node.id,
+ node_type=node.type,
+ status=node_state.status,
+ duration_ms=duration_ms,
+ error=node_state.error,
+ inputs=node_state.inputs,
+ outputs=node_state.outputs,
+ )
+ context.history.append(step)
+
+ async def _persist_node_execution(
+ self,
+ node: NodeDefinition,
+ node_state: NodeState,
+ context: ExecutionContext,
+ ):
+ """Persist node execution state for execution detail and logs."""
+ if not self.ap:
+ return
+
+ values = {
+ 'execution_uuid': context.execution_id,
+ 'node_id': node.id,
+ 'node_type': node.type,
+ 'status': node_state.status.value,
+ 'inputs': node_state.inputs,
+ 'outputs': node_state.outputs,
+ 'start_time': node_state.start_time,
+ 'end_time': node_state.end_time,
+ 'error': node_state.error,
+ 'retry_count': node_state.retry_count,
+ }
+
+ existing_query = sqlalchemy.select(persistence_workflow.WorkflowNodeExecution).where(
+ persistence_workflow.WorkflowNodeExecution.execution_uuid == context.execution_id,
+ persistence_workflow.WorkflowNodeExecution.node_id == node.id,
+ )
+ existing_result = await self.ap.persistence_mgr.execute_async(existing_query)
+ existing = existing_result.first()
+
+ if existing is None:
+ await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.insert(persistence_workflow.WorkflowNodeExecution).values(**values)
+ )
+ else:
+ await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.update(persistence_workflow.WorkflowNodeExecution)
+ .where(persistence_workflow.WorkflowNodeExecution.id == existing.id)
+ .values(**values)
+ )
+
+
+class ParallelExecutor:
+ """Execute multiple branches in parallel"""
+
+ def __init__(self, executor: WorkflowExecutor):
+ self.executor = executor
+
+ async def execute_parallel(
+ self, branches: list[list[NodeDefinition]], context: ExecutionContext
+ ) -> list[dict[str, Any]]:
+ """
+ Execute multiple branches in parallel.
+
+ Args:
+ branches: List of node sequences to execute in parallel
+ context: Execution context
+
+ Returns:
+ List of results from each branch
+ """
+ tasks = []
+ for branch in branches:
+ task = self._execute_branch(branch, context)
+ tasks.append(task)
+
+ results = await asyncio.gather(*tasks, return_exceptions=True)
+
+ processed_results = []
+ for result in results:
+ if isinstance(result, Exception):
+ processed_results.append({'error': str(result)})
+ else:
+ processed_results.append(result)
+
+ return processed_results
+
+ async def _execute_branch(self, nodes: list[NodeDefinition], context: ExecutionContext) -> dict[str, Any]:
+ """Execute a single branch"""
+ # Create a copy of context for this branch
+ branch_outputs = {}
+
+ for node in nodes:
+ await self.executor._execute_node(node, context, max_retries=3)
+ state = context.node_states.get(node.id)
+ if state and state.status == NodeStatus.COMPLETED:
+ branch_outputs[node.id] = state.outputs
+ elif state and state.status == NodeStatus.FAILED:
+ branch_outputs['error'] = state.error
+ break
+
+ return branch_outputs
+
+
+class LoopExecutor:
+ """Execute loop iterations"""
+
+ def __init__(self, executor: WorkflowExecutor):
+ self.executor = executor
+
+ async def execute_loop(
+ self, items: list[Any], loop_body: list[NodeDefinition], context: ExecutionContext, max_iterations: int = 100
+ ) -> list[dict[str, Any]]:
+ """
+ Execute a loop over items.
+
+ Args:
+ items: Items to iterate over
+ loop_body: Nodes to execute for each item
+ context: Execution context
+ max_iterations: Maximum number of iterations
+
+ Returns:
+ List of results from each iteration
+ """
+ results = []
+
+ for i, item in enumerate(items[:max_iterations]):
+ # Set loop variables
+ context.variables['loop_item'] = item
+ context.variables['loop_index'] = i
+ context.variables['loop_is_first'] = i == 0
+ context.variables['loop_is_last'] = i == len(items) - 1
+
+ iteration_result = {}
+
+ for node in loop_body:
+ # Reset node state for this iteration
+ context.node_states[node.id] = NodeState(node_id=node.id, node_type=node.type, status=NodeStatus.PENDING)
+
+ await self.executor._execute_node(node, context, max_retries=3)
+
+ state = context.node_states.get(node.id)
+ if state:
+ iteration_result[node.id] = state.outputs
+
+ # Check for break condition
+ if state.outputs.get('break', False):
+ results.append(iteration_result)
+ return results
+
+ results.append(iteration_result)
+
+ # Clean up loop variables
+ context.variables.pop('loop_item', None)
+ context.variables.pop('loop_index', None)
+ context.variables.pop('loop_is_first', None)
+ context.variables.pop('loop_is_last', None)
+
+ return results
diff --git a/src/langbot/pkg/workflow/metadata.py b/src/langbot/pkg/workflow/metadata.py
new file mode 100644
index 000000000..4eaa0de39
--- /dev/null
+++ b/src/langbot/pkg/workflow/metadata.py
@@ -0,0 +1,284 @@
+"""Workflow node metadata loading and validation.
+
+This module makes YAML files under ``templates/metadata/nodes`` the backend
+source of truth for workflow node metadata. Python node classes still provide
+execution logic, but UI-facing metadata is loaded from YAML.
+"""
+
+from __future__ import annotations
+
+import copy
+import logging
+from importlib import resources
+from pathlib import Path
+from typing import Any, Iterable, Optional
+
+import yaml
+
+logger = logging.getLogger(__name__)
+
+
+class MetadataLoadError(Exception):
+ """Raised when a workflow node metadata file cannot be loaded."""
+
+
+class MetadataValidationError(Exception):
+ """Raised when workflow node metadata does not match the expected shape."""
+
+
+class NodeMetadataValidator:
+ """Validate workflow node metadata loaded from YAML files.
+
+ The validator is intentionally strict about the structural fields that the
+ editor needs, but tolerant of legacy YAML details such as missing top-level
+ ``label`` or additional frontend field types.
+ """
+
+ REQUIRED_FIELDS = ('name', 'category', 'inputs', 'outputs', 'config')
+ VALID_CATEGORIES = {'trigger', 'process', 'control', 'action', 'integration', 'misc'}
+ VALID_PORT_TYPES = {'any', 'string', 'number', 'integer', 'boolean', 'object', 'array', 'datetime', 'null'}
+ VALID_CONFIG_TYPES = {
+ 'string',
+ 'integer',
+ 'number',
+ 'float',
+ 'boolean',
+ 'select',
+ 'json',
+ 'textarea',
+ 'text',
+ 'secret',
+ 'array[string]',
+ 'file',
+ 'array[file]',
+ 'llm-model-selector',
+ 'embedding-model-selector',
+ 'rerank-model-selector',
+ 'pipeline-selector',
+ 'knowledge-base-selector',
+ 'knowledge-base-multi-selector',
+ 'bot-selector',
+ 'tools-selector',
+ 'model-fallback-selector',
+ 'prompt-editor',
+ 'plugin-selector',
+ 'webhook-url',
+ 'embed-code',
+ 'workflow-selector',
+ }
+
+ def validate(self, metadata: dict[str, Any]) -> list[str]:
+ """Return validation errors. An empty list means the metadata is valid."""
+ errors: list[str] = []
+
+ if not isinstance(metadata, dict):
+ return ['metadata root must be a mapping']
+
+ for field in self.REQUIRED_FIELDS:
+ if field not in metadata:
+ errors.append(f'missing required field: {field}')
+
+ if errors:
+ return errors
+
+ name = metadata.get('name')
+ if not isinstance(name, str) or not name.strip():
+ errors.append('field "name" must be a non-empty string')
+
+ category = metadata.get('category')
+ if category not in self.VALID_CATEGORIES:
+ errors.append(f'invalid category: {category}')
+
+ errors.extend(self._validate_ports(metadata.get('inputs'), 'inputs'))
+ errors.extend(self._validate_ports(metadata.get('outputs'), 'outputs'))
+ errors.extend(self._validate_config(metadata.get('config')))
+
+ return errors
+
+ def validate_or_raise(self, metadata: dict[str, Any]) -> dict[str, Any]:
+ """Validate metadata and raise ``MetadataValidationError`` on failure."""
+ errors = self.validate(metadata)
+ if errors:
+ node_name = metadata.get('name', 'unknown') if isinstance(metadata, dict) else 'unknown'
+ raise MetadataValidationError(f'invalid metadata for {node_name}: {errors}')
+ return metadata
+
+ def _validate_ports(self, ports: Any, field_name: str) -> list[str]:
+ errors: list[str] = []
+ if not isinstance(ports, list):
+ return [f'{field_name} must be a list']
+
+ seen_names: set[str] = set()
+ for index, port in enumerate(ports):
+ path = f'{field_name}[{index}]'
+ if not isinstance(port, dict):
+ errors.append(f'{path} must be a mapping')
+ continue
+
+ name = port.get('name')
+ if not isinstance(name, str) or not name:
+ errors.append(f'{path}.name must be a non-empty string')
+ continue
+
+ if name in seen_names:
+ errors.append(f'{path}.name duplicates "{name}"')
+ seen_names.add(name)
+
+ port_type = port.get('type', 'any')
+ if port_type not in self.VALID_PORT_TYPES:
+ errors.append(f'{path}.type has unsupported value "{port_type}"')
+
+ return errors
+
+ def _validate_config(self, config: Any) -> list[str]:
+ errors: list[str] = []
+ if not isinstance(config, list):
+ return ['config must be a list']
+
+ seen_names: set[str] = set()
+ for index, item in enumerate(config):
+ path = f'config[{index}]'
+ if not isinstance(item, dict):
+ errors.append(f'{path} must be a mapping')
+ continue
+
+ name = item.get('name')
+ if not isinstance(name, str) or not name:
+ errors.append(f'{path}.name must be a non-empty string')
+ continue
+
+ if name in seen_names:
+ errors.append(f'{path}.name duplicates "{name}"')
+ seen_names.add(name)
+
+ item_type = item.get('type', 'string')
+ if item_type not in self.VALID_CONFIG_TYPES:
+ errors.append(f'{path}.type has unsupported value "{item_type}"')
+
+ min_value = item.get('min_value')
+ max_value = item.get('max_value')
+ if isinstance(min_value, (int, float)) and isinstance(max_value, (int, float)) and min_value > max_value:
+ errors.append(f'{path}.min_value must be <= max_value')
+
+ return errors
+
+
+class NodeMetadataLoader:
+ """Load and cache workflow node metadata from YAML files."""
+
+ def __init__(self, validator: Optional[NodeMetadataValidator] = None) -> None:
+ self._validator = validator or NodeMetadataValidator()
+ self._metadata: dict[str, dict[str, Any]] = {}
+ self._sources: dict[str, str] = {}
+ self._load_errors: list[dict[str, str]] = []
+
+ async def load_core_metadata(self, resource_dir: str = 'metadata/nodes') -> int:
+ """Load all core node metadata from the ``langbot.templates`` package."""
+ return await self.load_package_directory('langbot.templates', resource_dir, source='core')
+
+ async def load_package_directory(self, package: str, resource_dir: str, source: str = 'core') -> int:
+ """Load YAML files from a package resource directory."""
+ try:
+ root = resources.files(package).joinpath(resource_dir)
+ yaml_files = sorted(
+ (item for item in root.iterdir() if item.is_file() and item.name.endswith(('.yaml', '.yml'))),
+ key=lambda item: item.name,
+ )
+ except Exception as exc:
+ raise MetadataLoadError(f'failed to scan package directory {package}:{resource_dir}: {exc}') from exc
+
+ return self._load_files(yaml_files, source=source)
+
+ async def load_directory(self, directory: str | Path, source: str) -> int:
+ """Load YAML files from an external filesystem directory, e.g. a plugin."""
+ directory_path = Path(directory)
+ if not directory_path.exists():
+ logger.warning('Workflow metadata directory does not exist: %s', directory_path)
+ return 0
+ if not directory_path.is_dir():
+ raise MetadataLoadError(f'workflow metadata path is not a directory: {directory_path}')
+
+ yaml_files = sorted(directory_path.glob('*.yml')) + sorted(directory_path.glob('*.yaml'))
+ return self._load_files(yaml_files, source=source)
+
+ def get_metadata(self, node_type: str) -> Optional[dict[str, Any]]:
+ """Return metadata by full type or short node name."""
+ if node_type in self._metadata:
+ return copy.deepcopy(self._metadata[node_type])
+
+ short_name = node_type.split('.')[-1]
+ for registered_type, metadata in self._metadata.items():
+ if registered_type.split('.')[-1] == short_name or metadata.get('name') == short_name:
+ return copy.deepcopy(metadata)
+
+ return None
+
+ def get_all_metadata(self) -> dict[str, dict[str, Any]]:
+ """Return a deep copy of all loaded metadata keyed by canonical node type."""
+ return copy.deepcopy(self._metadata)
+
+ def get_load_errors(self) -> list[dict[str, str]]:
+ """Return metadata files that failed to load or validate."""
+ return copy.deepcopy(self._load_errors)
+
+ def clear(self) -> None:
+ """Clear all cached metadata and errors."""
+ self._metadata.clear()
+ self._sources.clear()
+ self._load_errors.clear()
+
+ def _load_files(self, yaml_files: Iterable[Any], source: str) -> int:
+ count = 0
+ for yaml_file in yaml_files:
+ file_name = getattr(yaml_file, 'name', str(yaml_file))
+ try:
+ metadata = self._load_yaml(yaml_file)
+ self._validator.validate_or_raise(metadata)
+ node_type = build_node_type(metadata)
+
+ if node_type in self._metadata:
+ existing_source = self._sources.get(node_type, 'unknown')
+ if existing_source == 'core' and source != 'core':
+ raise MetadataLoadError(
+ f'plugin source "{source}" attempted to override core node "{node_type}"'
+ )
+ logger.warning(
+ 'Workflow node metadata %s from %s overrides previous source %s',
+ node_type,
+ source,
+ existing_source,
+ )
+
+ cached_metadata = copy.deepcopy(metadata)
+ cached_metadata['_source'] = source
+ cached_metadata['_file'] = file_name
+ self._metadata[node_type] = cached_metadata
+ self._sources[node_type] = source
+ count += 1
+ except Exception as exc:
+ self._load_errors.append({'file': file_name, 'source': source, 'error': str(exc)})
+ logger.error('Failed to load workflow node metadata %s: %s', file_name, exc)
+
+ return count
+
+ def _load_yaml(self, yaml_file: Any) -> dict[str, Any]:
+ try:
+ if hasattr(yaml_file, 'open'):
+ with yaml_file.open('r', encoding='utf-8') as file:
+ data = yaml.load(file, Loader=yaml.FullLoader)
+ else:
+ with open(yaml_file, 'r', encoding='utf-8') as file:
+ data = yaml.load(file, Loader=yaml.FullLoader)
+ except Exception as exc:
+ raise MetadataLoadError(f'failed to parse YAML: {exc}') from exc
+
+ if not isinstance(data, dict):
+ raise MetadataLoadError('YAML root must be a mapping')
+ return data
+
+
+def build_node_type(metadata: dict[str, Any]) -> str:
+ """Build canonical ``category.name`` node type from metadata."""
+ category = metadata.get('category') or 'misc'
+ name = metadata.get('name') or ''
+ return f'{category}.{name}'
diff --git a/src/langbot/pkg/workflow/node.py b/src/langbot/pkg/workflow/node.py
new file mode 100644
index 000000000..41daedc9f
--- /dev/null
+++ b/src/langbot/pkg/workflow/node.py
@@ -0,0 +1,175 @@
+"""Workflow node base class and decorators"""
+
+from __future__ import annotations
+
+import abc
+from typing import Any, Callable, Optional, TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from .entities import ExecutionContext
+ from ..core import app
+
+
+class WorkflowNode(abc.ABC):
+ """Base class for all workflow nodes.
+
+ Node metadata (inputs, outputs, config schema, label, icon, etc.) is
+ defined exclusively in YAML files under templates/metadata/nodes/.
+ Python subclasses only provide execution logic and runtime behaviour.
+ """
+
+ # Set by @workflow_node decorator
+ type_name: str = ''
+
+ # Category is kept as a fallback for registry when YAML is missing
+ category: str = 'misc'
+
+ # Pipeline config reuse (referenced by registry merge logic)
+ config_schema_source: Optional[str] = None
+ config_stages: list[str] = []
+
+ def __init__(self, node_id: str, config: dict[str, Any], ap: Optional['app.Application'] = None):
+ """Initialize node with ID and configuration"""
+ self.node_id = node_id
+ self.config = config
+ self.ap = ap
+
+ @abc.abstractmethod
+ async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
+ """Execute the node logic.
+
+ Args:
+ inputs: Input data from connected nodes
+ context: Execution context with workflow state
+
+ Returns:
+ Dictionary of output values
+ """
+ pass
+
+ # ------------------------------------------------------------------
+ # Validation helpers — metadata is resolved from the registry at
+ # runtime so that YAML remains the single source of truth.
+ # ------------------------------------------------------------------
+
+ async def validate_inputs(self, inputs: dict[str, Any]) -> list[str]:
+ """Validate input data against YAML port definitions.
+
+ Returns:
+ List of validation error messages (empty if valid)
+ """
+ metadata = self._get_metadata()
+ if metadata is None:
+ return []
+
+ errors: list[str] = []
+ for port in metadata.get('inputs', []):
+ if port.get('required', True) and port.get('name') and port['name'] not in inputs:
+ errors.append(f"Missing required input: {port['name']}")
+ return errors
+
+ async def validate_config(self) -> list[str]:
+ """Validate node configuration against YAML config schema.
+
+ Returns:
+ List of validation error messages (empty if valid)
+ """
+ metadata = self._get_metadata()
+ if metadata is None:
+ return []
+
+ errors: list[str] = []
+ for cfg in metadata.get('config', []):
+ name = cfg.get('name', '')
+ if not name:
+ continue
+ required = cfg.get('required', False)
+ cfg_type = cfg.get('type', 'string')
+
+ if required and name not in self.config:
+ errors.append(f'Missing required config: {name}')
+ elif name in self.config:
+ value = self.config[name]
+ # Type validation
+ if cfg_type == 'integer' and not isinstance(value, int):
+ errors.append(f'Config {name} must be an integer')
+ elif cfg_type == 'number' and not isinstance(value, (int, float)):
+ errors.append(f'Config {name} must be a number')
+ elif cfg_type == 'boolean' and not isinstance(value, bool):
+ errors.append(f'Config {name} must be a boolean')
+ # Range validation
+ min_val = cfg.get('min_value')
+ max_val = cfg.get('max_value')
+ if min_val is not None and isinstance(value, (int, float)):
+ if value < min_val:
+ errors.append(f'Config {name} must be >= {min_val}')
+ if max_val is not None and isinstance(value, (int, float)):
+ if value > max_val:
+ errors.append(f'Config {name} must be <= {max_val}')
+ return errors
+
+ def get_config(self, key: str, default: Any = None) -> Any:
+ """Get configuration value with default"""
+ return self.config.get(key, default)
+
+ def _get_metadata(self) -> Optional[dict[str, Any]]:
+ """Retrieve YAML metadata for this node from the registry."""
+ from .registry import NodeTypeRegistry
+ registry = NodeTypeRegistry.instance()
+ return registry.get_metadata(self.type_name)
+
+ @classmethod
+ def to_schema(cls) -> dict[str, Any]:
+ """Return a schema dict for this node type.
+
+ This is used by tests and tooling to inspect node capabilities.
+ """
+ from .registry import NodeTypeRegistry
+ registry = NodeTypeRegistry.instance()
+ metadata = registry.get_metadata(cls.type_name)
+ if metadata:
+ return registry._metadata_to_schema(metadata)
+ # Fallback: build a minimal schema from class attributes
+ return {
+ 'type': f'{cls.category}.{cls.type_name}' if cls.type_name else cls.type_name,
+ 'category': cls.category,
+ 'label': getattr(cls, 'name', cls.type_name),
+ 'description': getattr(cls, 'description', ''),
+ 'inputs': [],
+ 'outputs': [],
+ 'config_schema': [],
+ }
+
+
+# ------------------------------------------------------------------
+# Decorator and pending registration helpers
+# ------------------------------------------------------------------
+
+_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()
diff --git a/src/langbot/pkg/workflow/nodes/__init__.py b/src/langbot/pkg/workflow/nodes/__init__.py
new file mode 100644
index 000000000..517989950
--- /dev/null
+++ b/src/langbot/pkg/workflow/nodes/__init__.py
@@ -0,0 +1,93 @@
+"""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 call_workflow
+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',
+ 'call_workflow',
+ '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',
+]
diff --git a/src/langbot/pkg/workflow/nodes/call_pipeline.py b/src/langbot/pkg/workflow/nodes/call_pipeline.py
new file mode 100644
index 000000000..99ea558cd
--- /dev/null
+++ b/src/langbot/pkg/workflow/nodes/call_pipeline.py
@@ -0,0 +1,262 @@
+"""Call Pipeline Node - invoke an existing pipeline
+
+Node metadata is loaded from: ../../templates/metadata/nodes/call_pipeline.yaml
+"""
+
+from __future__ import annotations
+
+from typing import Any, Optional
+
+import pydantic
+
+import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
+import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_event_logger
+import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
+import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
+import langbot_plugin.api.entities.builtin.platform.events as platform_events
+import langbot_plugin.api.entities.builtin.platform.message as platform_message
+import langbot_plugin.api.entities.builtin.provider.session as provider_session
+
+from langbot_plugin.api.entities.builtin.workflow import ExecutionContext
+from ..node import WorkflowNode, workflow_node
+
+
+class _NoOpEventLogger(abstract_event_logger.AbstractEventLogger):
+ """No-op event logger for workflow pipeline adapter."""
+
+ async def info(
+ self,
+ text: str,
+ images: Optional[list[platform_message.Image]] = None,
+ message_session_id: Optional[str] = None,
+ no_throw: bool = True,
+ ):
+ pass
+
+ async def debug(
+ self,
+ text: str,
+ images: Optional[list[platform_message.Image]] = None,
+ message_session_id: Optional[str] = None,
+ no_throw: bool = True,
+ ):
+ pass
+
+ async def warning(
+ self,
+ text: str,
+ images: Optional[list[platform_message.Image]] = None,
+ message_session_id: Optional[str] = None,
+ no_throw: bool = True,
+ ):
+ pass
+
+ async def error(
+ self,
+ text: str,
+ images: Optional[list[platform_message.Image]] = None,
+ message_session_id: Optional[str] = None,
+ no_throw: bool = True,
+ ):
+ pass
+
+@workflow_node('call_pipeline')
+class CallPipelineNode(WorkflowNode):
+ """Call pipeline node - invoke an existing pipeline"""
+
+ category = 'action'
+
+ async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
+ if not self.ap:
+ raise RuntimeError('Application instance not available — cannot call pipeline')
+
+ raw_query = inputs.get('query', '')
+ query_text = str(raw_query or inputs.get('input') or '')
+ pipeline_ref = str(self.get_config('pipeline_uuid', '') or '').strip()
+
+ if not pipeline_ref:
+ raise ValueError('No pipeline configured for call pipeline node')
+
+ pipeline_data = await self.ap.pipeline_service.get_pipeline(pipeline_ref)
+ if pipeline_data is None:
+ pipeline_data = await self.ap.pipeline_service.get_pipeline_by_name(pipeline_ref)
+ if pipeline_data is None:
+ raise ValueError(f'Pipeline not found: {pipeline_ref}')
+
+ pipeline_uuid = str(pipeline_data.get('uuid', '') or '')
+ if not pipeline_uuid:
+ raise ValueError(f'Pipeline UUID missing for: {pipeline_ref}')
+
+ runtime_pipeline = await self.ap.pipeline_mgr.get_pipeline_by_uuid(pipeline_uuid)
+ if runtime_pipeline is None:
+ raise ValueError(f'Runtime pipeline not loaded: {pipeline_uuid}')
+
+ adapter = _WorkflowPipelineCaptureAdapter(context=context)
+ adapter.bot_account_id = 'workflow-call-pipeline'
+
+ message_event = self._build_message_event(query_text, context)
+ message_chain = message_event.message_chain
+ launcher_type = (
+ provider_session.LauncherTypes.GROUP
+ if context.message_context and context.message_context.is_group
+ else provider_session.LauncherTypes.PERSON
+ )
+ launcher_id = context.session_id or context.execution_id
+ sender_id = (
+ context.message_context.sender_id
+ if context.message_context and context.message_context.sender_id
+ else context.user_id or f'workflow_{context.execution_id}'
+ )
+
+ query = pipeline_query.Query(
+ bot_uuid=context.bot_id,
+ query_id=-1,
+ launcher_type=launcher_type,
+ launcher_id=launcher_id,
+ sender_id=sender_id,
+ message_event=message_event,
+ message_chain=message_chain,
+ variables={
+ '_called_from_workflow': True,
+ '_workflow_execution_id': context.execution_id,
+ '_workflow_id': context.workflow_id,
+ **dict(context.variables or {}),
+ },
+ resp_messages=[],
+ resp_message_chain=[],
+ adapter=adapter,
+ pipeline_uuid=pipeline_uuid,
+ )
+
+ await runtime_pipeline.run(query)
+
+ response_text = adapter.get_last_text_response()
+ result = {
+ 'pipeline_uuid': pipeline_uuid,
+ 'pipeline_name': pipeline_data.get('name', ''),
+ 'responses': adapter.responses,
+ 'query_text': query_text,
+ }
+
+ return {'response': response_text, 'result': result}
+
+ def _build_message_event(
+ self,
+ query_text: str,
+ context: ExecutionContext,
+ ) -> platform_events.MessageEvent:
+ message_chain_data = context.trigger_data.get('message_chain') or context.trigger_data.get('message', [])
+ if isinstance(message_chain_data, list) and message_chain_data:
+ message_chain = platform_message.MessageChain.model_validate(message_chain_data)
+ else:
+ message_chain = platform_message.MessageChain([platform_message.Plain(text=query_text)])
+
+ if context.message_context and context.message_context.is_group:
+ group = platform_entities.Group(
+ id=context.message_context.group_id or context.session_id or 'workflow_group',
+ permission=platform_entities.Permission.Member,
+ )
+ sender = platform_entities.GroupMember(
+ id=context.message_context.sender_id,
+ member_name=context.message_context.sender_name or 'Workflow User',
+ permission=platform_entities.Permission.Member,
+ group=group,
+ )
+ return platform_events.GroupMessage(
+ sender=sender,
+ message_chain=message_chain,
+ time=context.message_context.raw_message.get('time') if context.message_context.raw_message else None,
+ )
+
+ sender = platform_entities.Friend(
+ id=context.message_context.sender_id if context.message_context else context.user_id or 'workflow_user',
+ nickname=context.message_context.sender_name if context.message_context else 'Workflow User',
+ remark=context.message_context.sender_name if context.message_context else 'Workflow User',
+ )
+ return platform_events.FriendMessage(
+ sender=sender,
+ message_chain=message_chain,
+ time=context.message_context.raw_message.get('time')
+ if context.message_context and context.message_context.raw_message
+ else None,
+ )
+
+class _WorkflowPipelineCaptureAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
+ """Adapter to capture pipeline responses for workflow execution."""
+
+ class Config:
+ arbitrary_types_allowed = True
+
+ responses: list[dict[str, Any]] = []
+ context: Optional[ExecutionContext] = pydantic.Field(default=None, exclude=True)
+
+ def __init__(self, context: ExecutionContext):
+ super().__init__(config={}, logger=_NoOpEventLogger(), context=context)
+ self.responses = []
+
+ async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
+ payload = {
+ 'type': 'send',
+ 'target_type': target_type,
+ 'target_id': target_id,
+ 'content': str(message),
+ 'message_chain': message.model_dump(),
+ }
+ self.responses.append(payload)
+ return payload
+
+ async def reply_message(
+ self,
+ message_source: platform_events.MessageEvent,
+ message: platform_message.MessageChain,
+ quote_origin: bool = False,
+ ):
+ payload = {
+ 'type': 'reply',
+ 'content': str(message),
+ 'message_chain': message.model_dump(),
+ 'quote_origin': quote_origin,
+ }
+ self.responses.append(payload)
+ return payload
+
+ async def reply_message_chunk(
+ self,
+ message_source: platform_events.MessageEvent,
+ bot_message: dict,
+ message: platform_message.MessageChain,
+ quote_origin: bool = False,
+ is_final: bool = False,
+ ):
+ payload = {
+ 'type': 'reply_chunk',
+ 'content': str(message),
+ 'message_chain': message.model_dump(),
+ 'quote_origin': quote_origin,
+ 'is_final': is_final,
+ }
+ self.responses.append(payload)
+ return payload
+
+ async def create_message_card(self, message_id, event: platform_events.MessageEvent) -> bool:
+ return False
+
+ def register_listener(self, event_type, callback):
+ return None
+
+ def unregister_listener(self, event_type, callback):
+ return None
+
+ async def run_async(self):
+ return None
+
+ async def is_stream_output_supported(self) -> bool:
+ return False
+
+ async def kill(self) -> bool:
+ return True
+
+ def get_last_text_response(self) -> str:
+ if not self.responses:
+ return ''
+ return str(self.responses[-1].get('content', '') or '')
diff --git a/src/langbot/pkg/workflow/nodes/call_workflow.py b/src/langbot/pkg/workflow/nodes/call_workflow.py
new file mode 100644
index 000000000..e3c21f9fa
--- /dev/null
+++ b/src/langbot/pkg/workflow/nodes/call_workflow.py
@@ -0,0 +1,85 @@
+"""Call Workflow Node - invoke an existing workflow
+
+Node metadata is loaded from: ../../templates/metadata/nodes/call_workflow.yaml
+"""
+
+from __future__ import annotations
+
+from typing import Any, Optional
+
+from langbot_plugin.api.entities.builtin.workflow import ExecutionContext
+
+from ..node import WorkflowNode, workflow_node
+
+
+@workflow_node('call_workflow')
+class CallWorkflowNode(WorkflowNode):
+ """Call workflow node - invoke an existing workflow"""
+
+ category = 'action'
+
+ async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
+ if not self.ap:
+ raise RuntimeError('Application instance not available — cannot call workflow')
+
+ # Get workflow reference from config
+ workflow_ref = str(self.get_config('workflow_uuid', '') or '').strip()
+ if not workflow_ref:
+ raise ValueError('No workflow configured for call workflow node')
+
+ # Get workflow definition from service
+ workflow_data = await self.ap.workflow_service.get_workflow(workflow_ref)
+ if workflow_data is None:
+ raise ValueError(f'Workflow not found: {workflow_ref}')
+
+ workflow_uuid = str(workflow_data.get('uuid', '') or '')
+ if not workflow_uuid:
+ raise ValueError(f'Workflow UUID missing for: {workflow_ref}')
+
+ # Build variables to pass to the called workflow
+ variables = dict(inputs.get('variables', {}) or {})
+
+ # Inherit current workflow variables if configured
+ if self.get_config('inherit_variables', True):
+ for key, value in (context.variables or {}).items():
+ if key not in variables:
+ variables[key] = value
+
+ # Add context markers for debugging
+ variables['_called_from_workflow'] = True
+ variables['_parent_workflow_id'] = context.workflow_id
+ variables['_parent_execution_id'] = context.execution_id
+
+ # Execute the workflow
+ execution_id = await self.ap.workflow_service.execute_workflow(
+ workflow_uuid=workflow_uuid,
+ trigger_type='workflow_call',
+ trigger_data={
+ 'variables': variables,
+ 'parent_execution_id': context.execution_id,
+ },
+ session_id=context.session_id,
+ user_id=context.user_id,
+ bot_id=context.bot_id,
+ )
+
+ # Get execution result
+ execution = await self.ap.workflow_service.get_execution(execution_id)
+ if execution is None:
+ raise ValueError(f'Execution result not found: {execution_id}')
+
+ # Build result
+ result = {
+ 'workflow_uuid': workflow_uuid,
+ 'workflow_name': workflow_data.get('name', ''),
+ 'execution_id': execution_id,
+ 'status': execution.get('status', 'unknown'),
+ 'variables': execution.get('variables', {}),
+ 'error': execution.get('error'),
+ }
+
+ return {
+ 'result': result,
+ 'status': execution.get('status', 'unknown'),
+ 'error': execution.get('error'),
+ }
diff --git a/src/langbot/pkg/workflow/nodes/code_executor.py b/src/langbot/pkg/workflow/nodes/code_executor.py
new file mode 100644
index 000000000..82cbd1f44
--- /dev/null
+++ b/src/langbot/pkg/workflow/nodes/code_executor.py
@@ -0,0 +1,156 @@
+"""Code Executor Node - run Python or JavaScript code
+
+Node metadata is loaded from: ../../templates/metadata/nodes/code_executor.yaml
+"""
+
+from __future__ import annotations
+
+import ast
+import io
+import logging
+import sys
+import threading
+from typing import Any
+
+from langbot_plugin.api.entities.builtin.workflow import ExecutionContext
+from ..node import WorkflowNode, workflow_node
+
+logger = logging.getLogger(__name__)
+
+# 危险的内置函数和模块黑名单
+_DANGEROUS_BUILTINS = {
+ '__import__', 'eval', 'exec', 'compile', 'open', 'file',
+ 'input', 'exit', 'quit', 'globals', 'locals', 'vars',
+ 'dir', 'help', 'breakpoint',
+}
+
+# 允许的安全内置函数
+_SAFE_BUILTINS = {
+ 'abs': abs, 'all': all, 'any': any, 'bin': bin, 'bool': bool,
+ 'bytearray': bytearray, 'bytes': bytes, 'callable': callable,
+ 'chr': chr, 'complex': complex, 'dict': dict, 'divmod': divmod,
+ 'enumerate': enumerate, 'filter': filter, 'float': float,
+ 'format': format, 'frozenset': frozenset, 'hash': hash,
+ 'hex': hex, 'int': int, 'isinstance': isinstance, 'issubclass': issubclass,
+ 'iter': iter, 'len': len, 'list': list, 'map': map, 'max': max,
+ 'min': min, 'next': next, 'object': object, 'oct': oct, 'ord': ord,
+ 'pow': pow, 'print': print, 'range': range, 'repr': repr,
+ 'reversed': reversed, 'round': round, 'set': set, 'slice': slice,
+ 'sorted': sorted, 'str': str, 'sum': sum, 'tuple': tuple,
+ 'type': type, 'zip': zip,
+}
+
+
+def _check_code_safety(code: str) -> list[str]:
+ """检查代码中是否包含危险操作"""
+ warnings = []
+ try:
+ tree = ast.parse(code)
+ for node in ast.walk(tree):
+ # 检查 import 语句
+ if isinstance(node, (ast.Import, ast.ImportFrom)):
+ warnings.append('Import statements are not allowed')
+ # 检查危险函数调用
+ if isinstance(node, ast.Call):
+ if isinstance(node.func, ast.Name) and node.func.id in _DANGEROUS_BUILTINS:
+ warnings.append(f'Dangerous function call: {node.func.id}')
+ # 检查 __import__ 通过 getattr 调用
+ if isinstance(node.func, ast.Attribute):
+ if node.func.attr in ('__import__', 'eval', 'exec', 'open', 'file'):
+ warnings.append(f'Dangerous attribute access: {node.func.attr}')
+ except SyntaxError as e:
+ warnings.append(f'Syntax error in code: {e}')
+ return warnings
+
+
+class _ExecutionTimeoutError(Exception):
+ """执行超时错误"""
+ pass
+
+
+def _run_with_timeout(func, timeout: float = 10.0):
+ """带超时限制的函数执行"""
+ result = [None]
+ error = [None]
+
+ def _target():
+ try:
+ result[0] = func()
+ except Exception as e:
+ error[0] = e
+
+ thread = threading.Thread(target=_target)
+ thread.daemon = True
+ thread.start()
+ thread.join(timeout)
+
+ if thread.is_alive():
+ raise _ExecutionTimeoutError(f'Code execution timed out after {timeout} seconds')
+
+ if error[0]:
+ raise error[0]
+
+ return result[0]
+
+
+@workflow_node('code_executor')
+class CodeExecutorNode(WorkflowNode):
+ """Code executor node - run Python or JavaScript code"""
+
+ category = 'process'
+
+ async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
+ code = self.get_config('code', '')
+ language = self.get_config('language', 'python')
+ timeout = self.get_config('timeout', 10)
+
+ # 限制最大超时时间
+ timeout = min(max(timeout, 1), 30)
+
+ if not code:
+ return {'output': None, 'console': '', 'error': 'No code provided'}
+
+ if language == 'python':
+ return await self._execute_python(code, inputs, context, timeout)
+ else:
+ return await self._execute_javascript(code, inputs, context)
+
+ async def _execute_python(self, code: str, inputs: dict[str, Any], context: ExecutionContext, timeout: float) -> dict[str, Any]:
+ # 安全检查
+ warnings = _check_code_safety(code)
+ if warnings:
+ logger.warning('Code safety warnings: %s', warnings)
+ return {'output': None, 'console': '', 'error': '; '.join(warnings)}
+
+ stdout_capture = io.StringIO()
+ old_stdout = sys.stdout
+
+ def _exec_code():
+ nonlocal stdout_capture
+ sys.stdout = stdout_capture
+ try:
+ # 使用更安全的执行方式
+ compiled = compile(code, '', 'exec')
+ safe_globals = {
+ '__builtins__': _SAFE_BUILTINS,
+ '__name__': '__workflow_sandbox__',
+ }
+ local_vars = {'inputs': inputs, 'output': None}
+ exec(compiled, safe_globals, local_vars)
+ return local_vars.get('output')
+ finally:
+ sys.stdout = old_stdout
+
+ try:
+ output = _run_with_timeout(_exec_code, timeout)
+ console_output = stdout_capture.getvalue()
+ return {'output': output, 'console': console_output, 'error': None}
+ except _ExecutionTimeoutError as e:
+ logger.error('Code execution timeout: %s', e)
+ return {'output': None, 'console': stdout_capture.getvalue(), 'error': str(e)}
+ except Exception as e:
+ logger.error('Code execution error: %s', e)
+ return {'output': None, 'console': stdout_capture.getvalue(), 'error': f'{type(e).__name__}: {e}'}
+
+ async def _execute_javascript(self, code: str, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
+ return {'output': None, 'console': '', 'error': 'JavaScript execution is not implemented'}
diff --git a/src/langbot/pkg/workflow/nodes/condition.py b/src/langbot/pkg/workflow/nodes/condition.py
new file mode 100644
index 000000000..f403a0ebe
--- /dev/null
+++ b/src/langbot/pkg/workflow/nodes/condition.py
@@ -0,0 +1,125 @@
+"""Condition Node - branch based on condition
+
+Node metadata is loaded from: ../../templates/metadata/nodes/condition.yaml
+"""
+
+from __future__ import annotations
+
+import logging
+import re
+import signal
+from typing import Any
+
+from langbot_plugin.api.entities.builtin.workflow import ExecutionContext
+from ..node import WorkflowNode, workflow_node
+from ..safe_eval import safe_eval_with_vars
+
+logger = logging.getLogger(__name__)
+
+# 正则表达式超时限制(秒)
+_REGEX_TIMEOUT = 2
+
+
+class _RegexTimeoutError(Exception):
+ """正则表达式超时错误"""
+ pass
+
+
+def _handle_timeout(signum, frame):
+ """超时信号处理"""
+ raise _RegexTimeoutError('Regex match timed out')
+
+
+def _safe_regex_match(pattern: str, text: str) -> tuple[bool, str]:
+ """安全地执行正则表达式匹配,带有超时限制"""
+ # 设置超时信号
+ old_handler = signal.signal(signal.SIGALRM, _handle_timeout)
+ signal.setitimer(signal.ITIMER_REAL, _REGEX_TIMEOUT)
+
+ try:
+ result = bool(re.match(pattern, str(text)))
+ return result, ''
+ except _RegexTimeoutError:
+ logger.warning('Regex match timed out for pattern: %s', pattern[:50])
+ return False, 'Regex match timed out'
+ except re.error as e:
+ logger.warning('Invalid regex pattern: %s', e)
+ return False, f'Invalid regex: {e}'
+ finally:
+ signal.setitimer(signal.ITIMER_REAL, 0)
+ signal.signal(signal.SIGALRM, old_handler)
+
+
+@workflow_node('condition')
+class ConditionNode(WorkflowNode):
+ """Condition node - branch based on condition"""
+
+ category = 'control'
+
+ async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
+ condition_type = self.get_config('condition_type', 'expression')
+ input_data = inputs.get('input')
+
+ result = False
+
+ if condition_type == 'expression':
+ expression = self.get_config('expression', 'false')
+ result = await self._evaluate_expression(expression, input_data, context)
+ elif condition_type == 'comparison':
+ result = await self._evaluate_comparison(input_data, context)
+ elif condition_type == 'contains':
+ left = self.get_config('left_value', '')
+ right = self.get_config('right_value', '')
+ result = right in left
+ elif condition_type == 'empty':
+ result = not bool(input_data)
+ elif condition_type == 'regex':
+ left = self.get_config('left_value', '')
+ pattern = self.get_config('right_value', '')
+ result, error = _safe_regex_match(pattern, left)
+ if error:
+ return {'true': None, 'false': input_data, 'error': error}
+
+ if result:
+ return {'true': input_data, 'false': None}
+ else:
+ return {'true': None, 'false': input_data}
+
+ async def _evaluate_expression(self, expression: str, data: Any, context: ExecutionContext) -> bool:
+ try:
+ local_vars = {'input': data, 'data': data, 'variables': context.variables}
+ return bool(safe_eval_with_vars(expression, local_vars))
+ except Exception as e:
+ logger.warning('Expression evaluation error: %s', e)
+ return False
+
+ async def _evaluate_comparison(self, data: Any, context: ExecutionContext) -> bool:
+ left = self.get_config('left_value', '')
+ right = self.get_config('right_value', '')
+ operator = self.get_config('operator', '==')
+
+ try:
+ left_num = float(left)
+ right_num = float(right)
+
+ if operator == '==':
+ return left_num == right_num
+ elif operator == '!=':
+ return left_num != right_num
+ elif operator == '>':
+ return left_num > right_num
+ elif operator == '<':
+ return left_num < right_num
+ elif operator == '>=':
+ return left_num >= right_num
+ elif operator == '<=':
+ return left_num <= right_num
+ except ValueError:
+ if operator == '==':
+ return left == right
+ elif operator == '!=':
+ return left != right
+ elif operator in ('>', '<', '>=', '<='):
+ return False
+
+ return False
diff --git a/src/langbot/pkg/workflow/nodes/coze_bot.py b/src/langbot/pkg/workflow/nodes/coze_bot.py
new file mode 100644
index 000000000..715a2c47b
--- /dev/null
+++ b/src/langbot/pkg/workflow/nodes/coze_bot.py
@@ -0,0 +1,39 @@
+"""Coze Bot Node - call Coze API bot
+
+Node metadata is loaded from: ../../templates/metadata/nodes/coze_bot.yaml
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from langbot_plugin.api.entities.builtin.workflow import ExecutionContext
+from ..node import WorkflowNode, workflow_node
+
+@workflow_node('coze_bot')
+class CozeBotNode(WorkflowNode):
+ """Coze bot node - call Coze API bot"""
+
+ category = 'integration'
+
+ async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
+ api_key = self.get_config('api_key', '')
+ bot_id = self.get_config('bot_id', '')
+ api_base = self.get_config('api_base', 'https://api.coze.cn')
+ query = inputs.get('query', '')
+ conversation_id = inputs.get('conversation_id')
+
+ # Safe API key truncation
+ masked_key = f'{api_key[:4]}...{api_key[-4:]}' if len(api_key) > 8 else '***' if api_key else ''
+
+ return {
+ 'answer': '',
+ 'conversation_id': conversation_id,
+ 'success': False,
+ '_debug': {
+ 'api_key': masked_key,
+ 'bot_id': bot_id,
+ 'api_base': api_base,
+ 'query': query,
+ },
+ }
diff --git a/src/langbot/pkg/workflow/nodes/cron_trigger.py b/src/langbot/pkg/workflow/nodes/cron_trigger.py
new file mode 100644
index 000000000..13c76639c
--- /dev/null
+++ b/src/langbot/pkg/workflow/nodes/cron_trigger.py
@@ -0,0 +1,26 @@
+"""Cron Trigger Node - triggers workflow on schedule
+
+Node metadata is loaded from: ../../templates/metadata/nodes/cron_trigger.yaml
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from langbot_plugin.api.entities.builtin.workflow import ExecutionContext
+from ..node import WorkflowNode, workflow_node
+
+@workflow_node('cron_trigger')
+class CronTriggerNode(WorkflowNode):
+ """Cron trigger node - triggers workflow on schedule"""
+
+ category = 'trigger'
+
+ async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
+ from datetime import datetime
+
+ return {
+ 'timestamp': datetime.now().isoformat(),
+ 'schedule': self.get_config('cron', ''),
+ 'context': context.trigger_data,
+ }
diff --git a/src/langbot/pkg/workflow/nodes/data_transform.py b/src/langbot/pkg/workflow/nodes/data_transform.py
new file mode 100644
index 000000000..7445662c9
--- /dev/null
+++ b/src/langbot/pkg/workflow/nodes/data_transform.py
@@ -0,0 +1,68 @@
+"""Data Transform Node - transform data using templates or JSONPath
+
+Node metadata is loaded from: ../../templates/metadata/nodes/data_transform.yaml
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from langbot_plugin.api.entities.builtin.workflow import ExecutionContext
+from ..node import WorkflowNode, workflow_node
+from ..safe_eval import safe_eval_with_vars
+
+@workflow_node('data_transform')
+class DataTransformNode(WorkflowNode):
+ """Data transform node - transform data using templates or JSONPath"""
+
+ category = 'process'
+
+ async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
+ data = inputs.get('data')
+ transform_type = self.get_config('transform_type', 'template')
+
+ if transform_type == 'template':
+ template = self.get_config('template', '')
+ result = self._apply_template(template, data, context)
+ elif transform_type == 'jsonpath':
+ expression = self.get_config('expression', '$')
+ result = self._apply_jsonpath(expression, data)
+ elif transform_type == 'expression':
+ expression = self.get_config('expression', '')
+ result = self._evaluate_expression(expression, data, context)
+ else:
+ result = data
+
+ return {'result': result}
+
+ def _apply_template(self, template: str, data: Any, context: ExecutionContext) -> str:
+ result = template
+ if isinstance(data, dict):
+ for key, value in data.items():
+ result = result.replace(f'{{{{data.{key}}}}}', str(value))
+ for key, value in context.variables.items():
+ result = result.replace(f'{{{{variables.{key}}}}}', str(value))
+ return result
+
+ def _apply_jsonpath(self, expression: str, data: Any) -> Any:
+ if expression == '$':
+ return data
+ if expression.startswith('$.'):
+ parts = expression[2:].split('.')
+ result = data
+ for part in parts:
+ if isinstance(result, dict):
+ result = result.get(part)
+ elif isinstance(result, list) and part.isdigit():
+ result = result[int(part)]
+ else:
+ return None
+ return result
+ return data
+
+ def _evaluate_expression(self, expression: str, data: Any, context: ExecutionContext) -> Any:
+ local_vars = {'data': data, 'variables': context.variables}
+ try:
+ return safe_eval_with_vars(expression, local_vars)
+ except Exception:
+ return None
diff --git a/src/langbot/pkg/workflow/nodes/database_query.py b/src/langbot/pkg/workflow/nodes/database_query.py
new file mode 100644
index 000000000..65f96bb12
--- /dev/null
+++ b/src/langbot/pkg/workflow/nodes/database_query.py
@@ -0,0 +1,38 @@
+"""Database Query Node - execute database queries
+
+Node metadata is loaded from: ../../templates/metadata/nodes/database_query.yaml
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from langbot_plugin.api.entities.builtin.workflow import ExecutionContext
+from ..node import WorkflowNode, workflow_node
+
+@workflow_node('database_query')
+class DatabaseQueryNode(WorkflowNode):
+ """Database query node - execute database queries"""
+
+ category = 'integration'
+
+ async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
+ connection_type = self.get_config('connection_type', 'postgresql')
+ query = self.get_config('query', '')
+ query_type = self.get_config('query_type', 'select')
+ timeout = self.get_config('timeout', 30)
+
+ parameters = inputs.get('parameters', {})
+
+ return {
+ 'results': [],
+ 'row_count': 0,
+ 'success': False,
+ '_debug': {
+ 'connection_type': connection_type,
+ 'query': query,
+ 'query_type': query_type,
+ 'timeout': timeout,
+ 'parameters': parameters,
+ },
+ }
diff --git a/src/langbot/pkg/workflow/nodes/dify_knowledge_query.py b/src/langbot/pkg/workflow/nodes/dify_knowledge_query.py
new file mode 100644
index 000000000..20e534cbb
--- /dev/null
+++ b/src/langbot/pkg/workflow/nodes/dify_knowledge_query.py
@@ -0,0 +1,37 @@
+"""Dify Knowledge Query Node - query Dify knowledge base
+
+Node metadata is loaded from: ../../templates/metadata/nodes/dify_knowledge_query.yaml
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from langbot_plugin.api.entities.builtin.workflow import ExecutionContext
+from ..node import WorkflowNode, workflow_node
+
+@workflow_node('dify_knowledge_query')
+class DifyKnowledgeQueryNode(WorkflowNode):
+ """Dify knowledge base query node - query Dify knowledge base"""
+
+ category = 'integration'
+
+ async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
+ base_url = self.get_config('base_url', 'https://api.dify.ai/v1')
+ api_key = self.get_config('api_key', '')
+ dataset_id = self.get_config('dataset_id', '')
+ query = inputs.get('query', '')
+
+ # Safe API key truncation
+ masked_key = f'{api_key[:4]}...{api_key[-4:]}' if len(api_key) > 8 else '***' if api_key else ''
+
+ return {
+ 'results': [],
+ 'success': False,
+ '_debug': {
+ 'base_url': base_url,
+ 'api_key': masked_key,
+ 'dataset_id': dataset_id,
+ 'query': query,
+ },
+ }
diff --git a/src/langbot/pkg/workflow/nodes/dify_workflow.py b/src/langbot/pkg/workflow/nodes/dify_workflow.py
new file mode 100644
index 000000000..df8a2c4b3
--- /dev/null
+++ b/src/langbot/pkg/workflow/nodes/dify_workflow.py
@@ -0,0 +1,39 @@
+"""Dify Workflow Node - call Dify service API
+
+Node metadata is loaded from: ../../templates/metadata/nodes/dify_workflow.yaml
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from langbot_plugin.api.entities.builtin.workflow import ExecutionContext
+from ..node import WorkflowNode, workflow_node
+
+@workflow_node('dify_workflow')
+class DifyWorkflowNode(WorkflowNode):
+ """Dify workflow node - call Dify service API"""
+
+ category = 'integration'
+
+ async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
+ base_url = self.get_config('base_url', 'https://api.dify.ai/v1')
+ api_key = self.get_config('api_key', '')
+ app_type = self.get_config('app_type', 'chat')
+ query = inputs.get('query', '')
+ conversation_id = inputs.get('conversation_id')
+
+ # Safe API key truncation
+ masked_key = f'{api_key[:4]}...{api_key[-4:]}' if len(api_key) > 8 else '***' if api_key else ''
+
+ return {
+ 'answer': '',
+ 'conversation_id': conversation_id,
+ 'success': False,
+ '_debug': {
+ 'base_url': base_url,
+ 'api_key': masked_key,
+ 'app_type': app_type,
+ 'query': query,
+ },
+ }
diff --git a/src/langbot/pkg/workflow/nodes/end.py b/src/langbot/pkg/workflow/nodes/end.py
new file mode 100644
index 000000000..879023a39
--- /dev/null
+++ b/src/langbot/pkg/workflow/nodes/end.py
@@ -0,0 +1,33 @@
+"""End Node - marks the end of workflow execution
+
+Node metadata is loaded from: ../../templates/metadata/nodes/end.yaml
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from langbot_plugin.api.entities.builtin.workflow import ExecutionContext
+from ..node import WorkflowNode, workflow_node
+
+@workflow_node('end')
+class EndNode(WorkflowNode):
+ """End node - marks the end of workflow execution"""
+
+ category = 'control'
+
+ async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
+ result = inputs.get('result')
+ output_format = self.get_config('output_format', 'passthrough')
+
+ if output_format == 'text':
+ return {'output': str(result)}
+ elif output_format == 'json':
+ import json
+
+ try:
+ return {'output': json.dumps(result, ensure_ascii=False)}
+ except Exception:
+ return {'output': str(result)}
+ else:
+ return {'output': result}
diff --git a/src/langbot/pkg/workflow/nodes/event_trigger.py b/src/langbot/pkg/workflow/nodes/event_trigger.py
new file mode 100644
index 000000000..96d5fae16
--- /dev/null
+++ b/src/langbot/pkg/workflow/nodes/event_trigger.py
@@ -0,0 +1,28 @@
+"""Event Trigger Node - triggers workflow on system events
+
+Node metadata is loaded from: ../../templates/metadata/nodes/event_trigger.yaml
+"""
+
+from __future__ import annotations
+
+from datetime import datetime
+from typing import Any
+
+from langbot_plugin.api.entities.builtin.workflow import ExecutionContext
+from ..node import WorkflowNode, workflow_node
+
+@workflow_node('event_trigger')
+class EventTriggerNode(WorkflowNode):
+ """Event trigger node - triggers workflow on system events"""
+
+ category = 'trigger'
+
+ async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
+ # Safe access to trigger_data which may be None
+ trigger_data = context.trigger_data or {}
+
+ return {
+ 'event_type': trigger_data.get('event_type', ''),
+ 'event_data': trigger_data.get('event_data', {}),
+ 'timestamp': trigger_data.get('timestamp', datetime.now().isoformat()),
+ }
diff --git a/src/langbot/pkg/workflow/nodes/http_request.py b/src/langbot/pkg/workflow/nodes/http_request.py
new file mode 100644
index 000000000..0bfde4436
--- /dev/null
+++ b/src/langbot/pkg/workflow/nodes/http_request.py
@@ -0,0 +1,152 @@
+"""HTTP Request Node - make HTTP API calls
+
+Node metadata is loaded from: ../../templates/metadata/nodes/http_request.yaml
+"""
+
+from __future__ import annotations
+
+import ipaddress
+import logging
+from typing import Any
+from urllib.parse import urlparse
+
+from langbot_plugin.api.entities.builtin.workflow import ExecutionContext
+from ..node import WorkflowNode, workflow_node
+
+logger = logging.getLogger(__name__)
+
+# 内网地址黑名单
+_PRIVATE_NETWORKS = [
+ ipaddress.ip_network('10.0.0.0/8'),
+ ipaddress.ip_network('172.16.0.0/12'),
+ ipaddress.ip_network('192.168.0.0/16'),
+ ipaddress.ip_network('127.0.0.0/8'),
+ ipaddress.ip_network('169.254.0.0/16'),
+ ipaddress.ip_network('0.0.0.0/8'),
+ ipaddress.ip_network('::1/128'),
+ ipaddress.ip_network('fc00::/7'),
+ ipaddress.ip_network('fe80::/10'),
+]
+
+# 危险协议
+_DANGEROUS_SCHEMES = {'file', 'gopher', 'dict', 'ftp', 'telnet'}
+
+
+def _is_safe_url(url: str) -> tuple[bool, str]:
+ """检查 URL 是否安全(非内网地址)"""
+ try:
+ parsed = urlparse(url)
+ except Exception as e:
+ return False, f'Invalid URL: {e}'
+
+ # 检查协议
+ scheme = parsed.scheme.lower()
+ if scheme in _DANGEROUS_SCHEMES:
+ return False, f'Dangerous scheme: {scheme}'
+
+ if scheme not in ('http', 'https'):
+ return False, f'Unsupported scheme: {scheme}'
+
+ # 检查主机名
+ hostname = parsed.hostname
+ if not hostname:
+ return False, 'Missing hostname'
+
+ # 检查是否是危险主机名
+ dangerous_hosts = {'localhost', '0.0.0.0', '127.0.0.1', '::1'}
+ if hostname.lower() in dangerous_hosts:
+ return False, f'Dangerous hostname: {hostname}'
+
+ # 解析 IP 地址并检查是否在私有网络
+ try:
+ ip = ipaddress.ip_address(hostname)
+ for network in _PRIVATE_NETWORKS:
+ if ip in network:
+ return False, f'Private network address: {ip}'
+ except ValueError:
+ # 不是 IP 地址,尝试 DNS 解析检查
+ # 这里可以添加 DNS 解析检查,但为了避免复杂性,暂时跳过
+ pass
+
+ return True, ''
+
+
+@workflow_node('http_request')
+class HTTPRequestNode(WorkflowNode):
+ """HTTP request node - make HTTP API calls"""
+
+ category = 'action'
+
+ async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
+ import aiohttp
+
+ url = self.get_config('url', '')
+ method = self.get_config('method', 'GET').upper()
+ timeout = self.get_config('timeout', 30)
+ content_type = self.get_config('content_type', 'application/json')
+ allow_redirects = self.get_config('allow_redirects', False) # 默认禁用重定向
+
+ # 限制超时时间
+ timeout = min(max(timeout, 1), 120)
+
+ if not url:
+ return {'response': None, 'status_code': 0, 'headers': {}, 'error': 'No URL provided'}
+
+ # 安全检查 URL
+ is_safe, error_msg = _is_safe_url(url)
+ if not is_safe:
+ logger.warning('Unsafe URL blocked: %s - %s', url, error_msg)
+ return {'response': None, 'status_code': 0, 'headers': {}, 'error': f'Unsafe URL: {error_msg}'}
+
+ # 验证 HTTP 方法
+ allowed_methods = {'GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'}
+ if method not in allowed_methods:
+ return {'response': None, 'status_code': 0, 'headers': {}, 'error': f'Invalid method: {method}'}
+
+ # 创建 headers 副本,避免修改输入
+ headers = dict(inputs.get('headers', {}))
+ headers['Content-Type'] = content_type
+
+ auth_type = self.get_config('auth_type', 'none')
+ auth_config = self.get_config('auth_config', {})
+
+ if auth_type == 'bearer':
+ headers['Authorization'] = f'Bearer {auth_config.get("token", "")}'
+ elif auth_type == 'api_key':
+ header_name = auth_config.get('header', 'X-API-Key')
+ headers[header_name] = auth_config.get('key', '')
+
+ body = inputs.get('body')
+
+ logger.info('HTTP %s %s (timeout=%s)', method, url, timeout)
+
+ try:
+ async with aiohttp.ClientSession() as session:
+ async with session.request(
+ method=method,
+ url=url,
+ json=body if content_type == 'application/json' else None,
+ data=body if content_type != 'application/json' else None,
+ headers=headers,
+ timeout=aiohttp.ClientTimeout(total=timeout),
+ allow_redirects=allow_redirects,
+ ) as response:
+ try:
+ response_data = await response.json()
+ except Exception:
+ response_data = await response.text()
+
+ logger.info('HTTP %s %s -> %d', method, url, response.status)
+
+ return {
+ 'response': response_data,
+ 'status_code': response.status,
+ 'headers': dict(response.headers),
+ 'error': None,
+ }
+ except aiohttp.ClientError as e:
+ logger.error('HTTP request failed: %s', e)
+ return {'response': None, 'status_code': 0, 'headers': {}, 'error': f'HTTP error: {e}'}
+ except Exception as e:
+ logger.error('HTTP request unexpected error: %s', e)
+ return {'response': None, 'status_code': 0, 'headers': {}, 'error': f'Unexpected error: {e}'}
diff --git a/src/langbot/pkg/workflow/nodes/iterator.py b/src/langbot/pkg/workflow/nodes/iterator.py
new file mode 100644
index 000000000..fa33f04ec
--- /dev/null
+++ b/src/langbot/pkg/workflow/nodes/iterator.py
@@ -0,0 +1,32 @@
+"""Iterator Node - Dify-style iterator for processing array items"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from langbot_plugin.api.entities.builtin.workflow import ExecutionContext
+from ..node import WorkflowNode, workflow_node
+
+@workflow_node('iterator')
+class IteratorNode(WorkflowNode):
+ """Iterator node - iterate over array items one by one"""
+
+ category = 'control'
+
+ async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
+ items = inputs.get('items', [])
+ if not isinstance(items, list):
+ items = [items] if items else []
+
+ max_iterations = self.get_config('max_iterations', 1000)
+ items = items[:max_iterations]
+
+ return {
+ 'item': items[0] if items else None,
+ 'index': 0,
+ 'is_first': True,
+ 'is_last': len(items) <= 1,
+ 'results': [],
+ 'completed': len(items) == 0,
+ '_items': items,
+ }
diff --git a/src/langbot/pkg/workflow/nodes/knowledge_retrieval.py b/src/langbot/pkg/workflow/nodes/knowledge_retrieval.py
new file mode 100644
index 000000000..46eafeb14
--- /dev/null
+++ b/src/langbot/pkg/workflow/nodes/knowledge_retrieval.py
@@ -0,0 +1,21 @@
+"""Knowledge Retrieval Node - search in knowledge base
+
+Node metadata is loaded from: ../../templates/metadata/nodes/knowledge_retrieval.yaml
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from langbot_plugin.api.entities.builtin.workflow import ExecutionContext
+from ..node import WorkflowNode, workflow_node
+
+@workflow_node('knowledge_retrieval')
+class KnowledgeRetrievalNode(WorkflowNode):
+ """Knowledge retrieval node - search in knowledge base"""
+
+ category = 'process'
+
+ async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
+ query = inputs.get('query', '')
+ return {'documents': [], 'citations': [], 'context': f'[Knowledge base search for: {query}]'}
diff --git a/src/langbot/pkg/workflow/nodes/langflow_flow.py b/src/langbot/pkg/workflow/nodes/langflow_flow.py
new file mode 100644
index 000000000..340e10ed5
--- /dev/null
+++ b/src/langbot/pkg/workflow/nodes/langflow_flow.py
@@ -0,0 +1,37 @@
+"""Langflow Flow Node - call Langflow API
+
+Node metadata is loaded from: ../../templates/metadata/nodes/langflow_flow.yaml
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from langbot_plugin.api.entities.builtin.workflow import ExecutionContext
+from ..node import WorkflowNode, workflow_node
+
+@workflow_node('langflow_flow')
+class LangflowFlowNode(WorkflowNode):
+ """Langflow flow node - call Langflow API"""
+
+ category = 'integration'
+
+ async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
+ base_url = self.get_config('base_url', 'http://localhost:7860')
+ api_key = self.get_config('api_key', '')
+ flow_id = self.get_config('flow_id', '')
+ input_value = inputs.get('input_value', '')
+
+ # Safe API key truncation
+ masked_key = f'{api_key[:4]}...{api_key[-4:]}' if len(api_key) > 8 else '***' if api_key else ''
+
+ return {
+ 'result': None,
+ 'success': False,
+ '_debug': {
+ 'base_url': base_url,
+ 'api_key': masked_key,
+ 'flow_id': flow_id,
+ 'input_value': input_value,
+ },
+ }
diff --git a/src/langbot/pkg/workflow/nodes/llm_call.py b/src/langbot/pkg/workflow/nodes/llm_call.py
new file mode 100644
index 000000000..512b34de5
--- /dev/null
+++ b/src/langbot/pkg/workflow/nodes/llm_call.py
@@ -0,0 +1,744 @@
+"""LLM Call Node - invoke large language model with Agent capabilities.
+
+Supports:
+- Primary model with fallback models
+- Knowledge base retrieval with reranking
+- Max round context control
+- Streaming output
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import re
+from typing import Any, AsyncGenerator
+
+import langbot_plugin.api.entities.builtin.provider.message as provider_message
+import langbot_plugin.api.entities.builtin.rag.context as rag_context
+
+from langbot_plugin.api.entities.builtin.workflow import ExecutionContext
+from ..node import WorkflowNode, workflow_node
+
+logger = logging.getLogger(__name__)
+
+# Pre-compiled regex patterns for CoT content removal (performance optimization)
+_THINK_PATTERNS = [
+ re.compile(r'.*?', re.DOTALL | re.IGNORECASE),
+ re.compile(r'.*?', re.DOTALL | re.IGNORECASE),
+ re.compile(r'.*?', re.DOTALL | re.IGNORECASE),
+ re.compile(r'<\u601d\u8003>.*?\u601d\u8003>', re.DOTALL | re.IGNORECASE),
+ re.compile(r'<\u63a8\u7406>.*?\u63a8\u7406>', re.DOTALL | re.IGNORECASE),
+]
+
+# Template variable regex
+_TEMPLATE_VAR_RE = re.compile(r'\{\{([^}]+)\}\}')
+
+
+@workflow_node('llm_call')
+class LLMCallNode(WorkflowNode):
+ """LLM call node - invoke large language model"""
+
+ category = 'process'
+
+ def _resolve_template(self, template: str, inputs: dict[str, Any], context: ExecutionContext) -> str:
+ """Resolve {{variable}} placeholders in a template string."""
+ if not template:
+ return ''
+
+ unresolved_vars = []
+
+ def replacer(match: re.Match) -> str:
+ expr = match.group(1).strip()
+ # Try inputs first
+ if expr in inputs:
+ return str(inputs[expr])
+ # Try context variables
+ if expr.startswith('variables.'):
+ var_name = expr[len('variables.'):]
+ return str(context.variables.get(var_name, ''))
+ # Try message context
+ if expr.startswith('message.') and context.message_context:
+ attr = expr[len('message.'):]
+ return str(getattr(context.message_context, attr, ''))
+ unresolved_vars.append(expr)
+ return match.group(0) # leave unresolved
+
+ result = _TEMPLATE_VAR_RE.sub(replacer, template)
+
+ # Log warning for unresolved variables
+ if unresolved_vars:
+ logger.warning(
+ f'LLM call node {self.node_id}: unresolved template variables: {unresolved_vars}'
+ )
+
+ return result
+
+ def _remove_think_content(self, text: str) -> str:
+ """Remove CoT (Chain of Thought) thinking content from response."""
+ if not text:
+ return text
+
+ result = text
+ for pattern in _THINK_PATTERNS:
+ result = pattern.sub('', result)
+
+ return result.strip()
+
+ def _apply_content_filter(self, text: str) -> tuple[str, bool, str]:
+ """Apply content safety filter to text.
+
+ Returns:
+ (filtered_text, is_blocked, user_notice)
+ """
+ if not text or not self.ap:
+ return text, False, ''
+
+ # Check if content filter is enabled
+ safety_config = getattr(self.ap, 'pipeline_cfg', None)
+ if not safety_config:
+ return text, False, ''
+
+ # Check sensitive words
+ sensitive_words = []
+ try:
+ if hasattr(self.ap, 'sensitive_meta') and hasattr(self.ap.sensitive_meta, 'data'):
+ sensitive_words = self.ap.sensitive_meta.data.get('words', [])
+ except Exception:
+ pass
+
+ if not sensitive_words:
+ return text, False, ''
+
+ found = False
+ filtered_text = text
+ for word in sensitive_words:
+ try:
+ matches = re.findall(word, filtered_text, re.IGNORECASE)
+ if matches:
+ found = True
+ mask_word = ''
+ mask = '*'
+ try:
+ if hasattr(self.ap, 'sensitive_meta') and hasattr(self.ap.sensitive_meta, 'data'):
+ mask_word = self.ap.sensitive_meta.data.get('mask_word', '')
+ mask = self.ap.sensitive_meta.data.get('mask', '*')
+ except Exception:
+ pass
+
+ for m in matches:
+ if mask_word:
+ filtered_text = filtered_text.replace(m, mask_word)
+ else:
+ filtered_text = filtered_text.replace(m, mask * len(m))
+ except re.error:
+ # Invalid regex pattern, skip
+ continue
+
+ if found:
+ return filtered_text, False, '消息中存在不合适的内容, 请修改'
+
+ return text, False, ''
+
+ # RAG combined prompt template (same as localagent.py)
+ RAG_COMBINED_PROMPT_TEMPLATE = """
+The following are relevant context entries retrieved from the knowledge base.
+Please use them to answer the user's message.
+Respond in the same language as the user's input.
+
+
+{rag_context}
+
+
+
+{user_message}
+
+"""
+
+ def _build_system_prompt_with_format(self, base_prompt: str, output_format: str, json_schema: str) -> str:
+ """Build system prompt with output format instructions."""
+ prompt = base_prompt
+
+ if output_format == 'json':
+ prompt += '\n\nPlease respond in valid JSON format.'
+ if json_schema:
+ prompt += f'\nFollow this JSON schema:\n{json_schema}'
+ elif output_format == 'markdown':
+ prompt += '\n\nPlease respond in Markdown format.'
+
+ return prompt
+
+ def _build_messages_from_prompt_array(
+ self,
+ prompt_array: list[dict],
+ inputs: dict[str, Any],
+ context: ExecutionContext,
+ output_format: str,
+ json_schema: str,
+ ) -> list[provider_message.Message]:
+ """Build messages list from prompt array (same format as pipeline).
+
+ Each item in prompt_array is {role: str, content: str}.
+ Resolves template variables in content.
+ """
+ messages: list[provider_message.Message] = []
+
+ for item in prompt_array:
+ role = item.get('role', 'user')
+ content = item.get('content', '')
+
+ # Resolve template variables in content
+ resolved_content = self._resolve_template(content, inputs, context)
+
+ # Apply format instructions to system prompt
+ if role == 'system':
+ resolved_content = self._build_system_prompt_with_format(
+ resolved_content, output_format, json_schema
+ )
+
+ messages.append(provider_message.Message(role=role, content=resolved_content))
+
+ return messages
+
+ async def _get_model_candidates(self, model_uuid: str, fallback_models: list) -> list:
+ """Build ordered list of models to try: primary model + fallback models."""
+ candidates = []
+
+ # Primary model
+ if model_uuid:
+ try:
+ primary = await self.ap.model_mgr.get_model_by_uuid(model_uuid)
+ candidates.append(primary)
+ except ValueError:
+ logger.warning(f'[LLM:{self.node_id}] Primary model {model_uuid} not found')
+
+ # Fallback models
+ for fb_uuid in fallback_models:
+ try:
+ fb_model = await self.ap.model_mgr.get_model_by_uuid(fb_uuid)
+ candidates.append(fb_model)
+ except ValueError:
+ logger.warning(f'[LLM:{self.node_id}] Fallback model {fb_uuid} not found, skipping')
+
+ return candidates
+
+ async def _invoke_with_fallback(
+ self,
+ candidates: list,
+ messages: list,
+ funcs: list | None,
+ extra_args: dict,
+ ) -> tuple[Any, Any]:
+ """Try non-streaming invocation with sequential fallback. Returns (message, model_used)."""
+ last_error = None
+ for model in candidates:
+ try:
+ msg = await model.provider.invoke_llm(
+ query=None,
+ model=model,
+ messages=messages,
+ funcs=funcs if model.model_entity.abilities.__contains__('func_call') else [],
+ extra_args=extra_args,
+ )
+ return msg, model
+ except Exception as e:
+ last_error = e
+ logger.warning(f'[LLM:{self.node_id}] Model {model.model_entity.name} failed: {e}, trying next...')
+ raise last_error or RuntimeError('No model candidates available')
+
+ async def _retrieve_knowledge(
+ self,
+ user_message_text: str,
+ knowledge_bases: list[str],
+ rerank_model_uuid: str,
+ rerank_top_k: int,
+ ) -> str:
+ """Retrieve from knowledge bases and optionally rerank results.
+
+ Returns the enhanced user message text with RAG context, or original text if no results.
+ """
+ if not knowledge_bases or not user_message_text:
+ return user_message_text
+
+ all_results: list[rag_context.RetrievalResultEntry] = []
+
+ # Retrieve from each knowledge base
+ for kb_uuid in knowledge_bases:
+ try:
+ kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(kb_uuid)
+ if not kb:
+ logger.warning(f'[LLM:{self.node_id}] Knowledge base {kb_uuid} not found, skipping')
+ continue
+
+ result = await kb.retrieve(user_message_text, settings={})
+ if result:
+ all_results.extend(result)
+ except Exception as e:
+ logger.warning(f'[LLM:{self.node_id}] Failed to retrieve from KB {kb_uuid}: {e}')
+
+ # Rerank step: re-score results using a rerank model if configured
+ if all_results and rerank_model_uuid:
+ try:
+ rerank_model = await self.ap.model_mgr.get_rerank_model_by_uuid(rerank_model_uuid)
+
+ doc_texts = []
+ for entry in all_results:
+ text = ' '.join(c.text for c in entry.content if c.type == 'text' and c.text)
+ doc_texts.append(text)
+
+ doc_texts_capped = doc_texts[:64] # Cap for reranker input
+ scores = await rerank_model.provider.invoke_rerank(
+ model=rerank_model,
+ query=user_message_text,
+ documents=doc_texts_capped,
+ )
+
+ scored = sorted(scores, key=lambda x: x.get('relevance_score', 0), reverse=True)
+ top_indices = [s['index'] for s in scored[:rerank_top_k] if s['index'] < len(all_results)]
+ all_results = [all_results[i] for i in top_indices]
+
+ logger.info(
+ f'[LLM:{self.node_id}] Rerank complete: {len(doc_texts)} docs -> top {len(all_results)} kept (top_k={rerank_top_k})'
+ )
+ except ValueError:
+ logger.warning(f'[LLM:{self.node_id}] Rerank model {rerank_model_uuid} not found, skipping rerank')
+ except Exception as e:
+ logger.warning(f'[LLM:{self.node_id}] Rerank failed, using original order: {e}')
+
+ # Build RAG context text
+ if all_results:
+ texts = []
+ idx = 1
+ for entry in all_results:
+ for content in entry.content:
+ if content.type == 'text' and content.text is not None:
+ texts.append(f'[{idx}] {content.text}')
+ idx += 1
+ rag_context_text = '\n\n'.join(texts)
+ return self.RAG_COMBINED_PROMPT_TEMPLATE.format(
+ rag_context=rag_context_text,
+ user_message=user_message_text,
+ )
+
+ return user_message_text
+
+ def _build_messages_with_history(
+ self,
+ system_prompt: str,
+ user_message_text: str,
+ context: ExecutionContext,
+ max_round: int,
+ ) -> list[provider_message.Message]:
+ """Build messages list with conversation history up to max_round."""
+ messages: list[provider_message.Message] = []
+
+ # Add system prompt
+ if system_prompt:
+ messages.append(provider_message.Message(role='system', content=system_prompt))
+
+ # Get conversation history from context
+ conversation_history = context.variables.get('_conversation_history', [])
+
+ # Apply max_round limit (each round = 1 user + 1 assistant message)
+ if max_round > 0 and conversation_history:
+ # Keep only the last max_round * 2 messages (user + assistant pairs)
+ max_messages = max_round * 2
+ if len(conversation_history) > max_messages:
+ conversation_history = conversation_history[-max_messages:]
+
+ # Add conversation history
+ for msg in conversation_history:
+ if isinstance(msg, dict):
+ role = msg.get('role', 'user')
+ content = msg.get('content', '')
+ messages.append(provider_message.Message(role=role, content=content))
+ elif hasattr(msg, 'role') and hasattr(msg, 'content'):
+ messages.append(provider_message.Message(role=msg.role, content=msg.content))
+
+ # Add current user message
+ messages.append(provider_message.Message(role='user', content=user_message_text))
+
+ return messages
+
+ def _save_to_conversation_history(
+ self,
+ context: ExecutionContext,
+ user_message_text: str,
+ response_text: str,
+ max_round: int,
+ ) -> None:
+ """Save the exchange to conversation history."""
+ if max_round <= 0:
+ return
+
+ history = context.variables.get('_conversation_history', [])
+ history.append({'role': 'user', 'content': user_message_text})
+ history.append({'role': 'assistant', 'content': response_text})
+
+ # Enforce max_round limit
+ max_messages = max_round * 2
+ if len(history) > max_messages:
+ history = history[-max_messages:]
+
+ context.variables['_conversation_history'] = history
+
+ async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
+ 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')
+
+ # Get error handling config
+ exception_handling = self.get_config('exception_handling', 'show-error')
+ failure_hint = self.get_config('failure_hint', 'Request failed.')
+ track_function_calls = self.get_config('track_function_calls', False)
+
+ # Get output format and json_schema config
+ output_format = self.get_config('output_format', 'text')
+ json_schema = self.get_config('json_schema', '')
+
+ # Agent config: fallback models, knowledge bases, rerank, max_round
+ fallback_models = self.get_config('fallback_models', [])
+ knowledge_bases = self.get_config('knowledge_bases', [])
+ rerank_model = self.get_config('rerank_model', '')
+ rerank_top_k = self.get_config('rerank_top_k', 5)
+ max_round = self.get_config('max_round', 10)
+
+ # Resolve prompts - support both new prompt array format and legacy format
+ prompt_array = self.get_config('prompt')
+ user_prompt = '' # Initialize for later use in _save_to_conversation_history
+
+ if prompt_array and isinstance(prompt_array, list):
+ # New format: prompt array like pipeline
+ messages = self._build_messages_from_prompt_array(
+ prompt_array, inputs, context, output_format, json_schema
+ )
+
+ # Get user input text for knowledge retrieval
+ user_input = inputs.get('input', '')
+
+ # Knowledge retrieval: enhance user input with RAG context
+ user_input = await self._retrieve_knowledge(
+ user_message_text=user_input,
+ knowledge_bases=knowledge_bases,
+ rerank_model_uuid=rerank_model,
+ rerank_top_k=rerank_top_k,
+ )
+
+ # Track user_prompt for conversation history
+ user_prompt = user_input
+
+ # Add user input as last message
+ if user_input:
+ messages.append(provider_message.Message(role='user', content=user_input))
+
+ # Apply max_round to conversation history
+ conversation_history = context.variables.get('_conversation_history', [])
+ if max_round > 0 and conversation_history:
+ max_messages = max_round * 2
+ if len(conversation_history) > max_messages:
+ conversation_history = conversation_history[-max_messages:]
+ # Insert conversation history before user input
+ history_messages = []
+ for msg in conversation_history:
+ if isinstance(msg, dict):
+ role = msg.get('role', 'user')
+ content = msg.get('content', '')
+ history_messages.append(provider_message.Message(role=role, content=content))
+ elif hasattr(msg, 'role') and hasattr(msg, 'content'):
+ history_messages.append(provider_message.Message(role=msg.role, content=msg.content))
+ # Insert history before user message
+ if history_messages and len(messages) > 0:
+ messages = messages[:-1] + history_messages + [messages[-1]]
+ else:
+ # Legacy format: separate system_prompt and user_prompt_template
+ system_prompt = self._resolve_template(self.get_config('system_prompt') or '', inputs, context)
+ user_prompt_template = self.get_config('user_prompt_template')
+ if user_prompt_template is None:
+ user_prompt_template = '{{input}}'
+ user_prompt = self._resolve_template(user_prompt_template, inputs, context)
+
+ # Build system prompt with format instructions
+ system_prompt = self._build_system_prompt_with_format(system_prompt, output_format, json_schema)
+
+ # Knowledge retrieval: enhance user prompt with RAG context
+ user_prompt = await self._retrieve_knowledge(
+ user_message_text=user_prompt,
+ knowledge_bases=knowledge_bases,
+ rerank_model_uuid=rerank_model,
+ rerank_top_k=rerank_top_k,
+ )
+
+ # Build messages with conversation history
+ messages = self._build_messages_with_history(
+ system_prompt=system_prompt,
+ user_message_text=user_prompt,
+ context=context,
+ max_round=max_round,
+ )
+
+ # Get model candidates (primary + fallbacks)
+ candidates = await self._get_model_candidates(model_uuid, fallback_models)
+ if not candidates:
+ raise ValueError('No valid model candidates available')
+
+ # Build extra args from config
+ extra_args: dict[str, Any] = {}
+ temperature = self.get_config('temperature')
+ if temperature is not None:
+ extra_args['temperature'] = float(temperature)
+ max_tokens = self.get_config('max_tokens', 0)
+ if max_tokens and int(max_tokens) > 0:
+ extra_args['max_tokens'] = int(max_tokens)
+
+ # Invoke LLM with fallback
+ try:
+ result_message, used_model = await self._invoke_with_fallback(
+ candidates=candidates,
+ messages=messages,
+ funcs=None,
+ extra_args=extra_args,
+ )
+ except Exception as e:
+ logger.warning(f'[LLM:{self.node_id}] LLM call failed: {e}')
+
+ # Handle based on exception handling strategy
+ if exception_handling == 'show-error':
+ raise
+ elif exception_handling == 'show-hint':
+ return {
+ 'response': failure_hint,
+ 'usage': {
+ 'prompt_tokens': 0,
+ 'completion_tokens': 0,
+ 'total_tokens': 0,
+ },
+ 'error': str(e),
+ 'error_hint_shown': True,
+ }
+ else: # hide
+ return {
+ 'response': '',
+ 'usage': {
+ 'prompt_tokens': 0,
+ 'completion_tokens': 0,
+ 'total_tokens': 0,
+ },
+ 'error': str(e),
+ }
+
+ # Extract response text
+ response_text = ''
+ if isinstance(result_message.content, str):
+ response_text = result_message.content
+ elif isinstance(result_message.content, list):
+ for elem in result_message.content:
+ if hasattr(elem, 'text') and elem.text:
+ response_text += elem.text
+ elif isinstance(elem, str):
+ response_text += elem
+
+ # Remove CoT content (always remove to avoid leaking internal reasoning)
+ response_text = self._remove_think_content(response_text)
+
+ # Initialize usage default
+ usage = {
+ 'prompt_tokens': 0,
+ 'completion_tokens': 0,
+ 'total_tokens': 0,
+ }
+
+ # Apply content safety filter
+ response_text, is_blocked, filter_notice = self._apply_content_filter(response_text)
+ if is_blocked:
+ logger.warning(f'[LLM:{self.node_id}] Response blocked by content filter: {filter_notice}')
+ return {
+ 'response': filter_notice,
+ 'usage': usage,
+ 'blocked_by_filter': True,
+ }
+
+ # Extract usage info
+ if hasattr(result_message, 'usage') and result_message.usage:
+ u = result_message.usage
+ 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,
+ }
+
+ # Save to conversation history
+ self._save_to_conversation_history(
+ context=context,
+ user_message_text=user_prompt,
+ response_text=response_text,
+ max_round=max_round,
+ )
+
+ # Build result
+ result: dict[str, Any] = {
+ 'response': response_text,
+ 'usage': usage,
+ 'model_used': used_model.model_entity.name if used_model else None,
+ 'model_uuid': used_model.model_entity.uuid if used_model else None,
+ }
+
+ # Parse JSON output if format is json
+ if output_format == 'json' and response_text:
+ try:
+ result['parsed'] = json.loads(response_text)
+ except json.JSONDecodeError as e:
+ logger.warning(f'[LLM:{self.node_id}] Failed to parse JSON: {e}')
+ result['parsed'] = None
+ result['parse_error'] = str(e)
+
+ # Add function call tracking info if configured
+ if track_function_calls:
+ result['function_calls'] = []
+
+ return result
+
+ async def execute_stream(
+ self, inputs: dict[str, Any], context: ExecutionContext
+ ) -> AsyncGenerator[str, None]:
+ """Execute the LLM call with streaming output.
+
+ Yields chunks of response text as they arrive.
+ Falls back to non-streaming if streaming is not available.
+ """
+ model_uuid = self.get_config('model', '')
+ if not model_uuid:
+ raise ValueError('No model configured for LLM call node')
+
+ if not self.ap:
+ raise RuntimeError('Application instance not available - cannot call LLM')
+
+ exception_handling = self.get_config('exception_handling', 'show-error')
+ failure_hint = self.get_config('failure_hint', 'Request failed.')
+
+ # Resolve prompts - support both new prompt array format and legacy format
+ prompt_array = self.get_config('prompt')
+ if prompt_array and isinstance(prompt_array, list):
+ # New format: prompt array like pipeline
+ messages = self._build_messages_from_prompt_array(
+ prompt_array, inputs, context, 'text', '' # No format instructions for streaming
+ )
+
+ # Add user input
+ user_input = inputs.get('input', '')
+ if user_input:
+ messages.append(provider_message.Message(role='user', content=user_input))
+ else:
+ # Legacy format
+ system_prompt = self._resolve_template(self.get_config('system_prompt') or '', inputs, context)
+ user_prompt_template = self.get_config('user_prompt_template')
+ if user_prompt_template is None:
+ user_prompt_template = '{{input}}'
+ user_prompt = self._resolve_template(user_prompt_template, inputs, context)
+
+ # Build messages
+ messages = []
+ if system_prompt:
+ messages.append(provider_message.Message(role='system', content=system_prompt))
+ messages.append(provider_message.Message(role='user', content=user_prompt))
+
+ # Get model
+ runtime_model = await self.ap.model_mgr.get_model_by_uuid(model_uuid)
+
+ # Build extra args
+ extra_args: dict[str, Any] = {}
+ temperature = self.get_config('temperature')
+ if temperature is not None:
+ extra_args['temperature'] = float(temperature)
+ max_tokens = self.get_config('max_tokens', 0)
+ if max_tokens and int(max_tokens) > 0:
+ extra_args['max_tokens'] = int(max_tokens)
+
+ logger.info(f'[LLM:{self.node_id}] Streaming model {model_uuid}')
+
+ try:
+ # Try streaming first
+ stream = runtime_model.provider.invoke_llm_stream(
+ query=None,
+ model=runtime_model,
+ messages=messages,
+ funcs=None,
+ extra_args=extra_args,
+ )
+
+ full_response = ''
+ in_think_block = False
+ async for chunk in stream:
+ chunk_text = ''
+ if hasattr(chunk, 'content'):
+ if isinstance(chunk.content, str):
+ chunk_text = chunk.content
+ elif isinstance(chunk.content, list):
+ for elem in chunk.content:
+ if hasattr(elem, 'text') and elem.text:
+ chunk_text += elem.text
+ elif isinstance(elem, str):
+ chunk_text += elem
+
+ if chunk_text:
+ # Filter blocks in streaming mode
+ if '' in chunk_text or '' in chunk_text:
+ in_think_block = True
+ if in_think_block:
+ if '' in chunk_text or '' in chunk_text:
+ in_think_block = False
+ chunk_text = chunk_text.split('')[-1].split('')[-1]
+ else:
+ chunk_text = ''
+
+ if chunk_text:
+ full_response += chunk_text
+ yield chunk_text
+
+ # Store in context for downstream nodes
+ context.variables['_last_llm_response'] = full_response
+
+ except Exception as e:
+ logger.warning(f'[LLM:{self.node_id}] Streaming failed, falling back - {e}')
+ # Fallback to non-streaming
+ try:
+ result_message = await runtime_model.provider.invoke_llm(
+ query=None,
+ model=runtime_model,
+ messages=messages,
+ funcs=None,
+ extra_args=extra_args,
+ )
+ response_text = self._extract_response_text(result_message)
+ # Always remove content in fallback
+ response_text = self._remove_think_content(response_text)
+ yield response_text
+ context.variables['_last_llm_response'] = response_text
+ except Exception as e2:
+ logger.error(f'[LLM:{self.node_id}] Fallback also failed - {e2}')
+ if exception_handling == 'show-hint':
+ yield failure_hint
+ elif exception_handling != 'hide':
+ raise
+
+ def _extract_response_text(self, result_message: provider_message.Message) -> str:
+ """Extract response text from LLM result message."""
+ response_text = ''
+ if isinstance(result_message.content, str):
+ response_text = result_message.content
+ elif isinstance(result_message.content, list):
+ for elem in result_message.content:
+ if hasattr(elem, 'text') and elem.text:
+ response_text += elem.text
+ elif isinstance(elem, str):
+ response_text += elem
+ return response_text
diff --git a/src/langbot/pkg/workflow/nodes/loop.py b/src/langbot/pkg/workflow/nodes/loop.py
new file mode 100644
index 000000000..18cfcb2ff
--- /dev/null
+++ b/src/langbot/pkg/workflow/nodes/loop.py
@@ -0,0 +1,30 @@
+"""Loop Node - iterate over items"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from langbot_plugin.api.entities.builtin.workflow import ExecutionContext
+from ..node import WorkflowNode, workflow_node
+
+@workflow_node('loop')
+class LoopNode(WorkflowNode):
+ """Loop node - iterate over items"""
+
+ category = 'control'
+
+ async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
+ items = inputs.get('items', [])
+ if not isinstance(items, list):
+ items = [items] if items else []
+
+ max_iterations = self.get_config('max_iterations', 100)
+ items = items[:max_iterations]
+
+ return {
+ 'item': items[0] if items else None,
+ 'index': 0,
+ 'results': [],
+ 'completed': len(items) == 0,
+ '_items': items,
+ }
diff --git a/src/langbot/pkg/workflow/nodes/mcp_tool.py b/src/langbot/pkg/workflow/nodes/mcp_tool.py
new file mode 100644
index 000000000..ee4e35470
--- /dev/null
+++ b/src/langbot/pkg/workflow/nodes/mcp_tool.py
@@ -0,0 +1,58 @@
+"""MCP Tool Node - Invoke MCP (Model Context Protocol) tools
+
+This module contains the implementation for the MCP Tool workflow node.
+Node metadata (label, description, inputs, outputs, config) is loaded from:
+../../templates/metadata/nodes/mcp_tool.yaml
+
+The i18n for label and description is handled on the frontend side.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from langbot_plugin.api.entities.builtin.workflow import ExecutionContext
+from ..node import WorkflowNode, workflow_node
+
+@workflow_node('mcp_tool')
+class MCPToolNode(WorkflowNode):
+ """MCP tool node - invoke MCP (Model Context Protocol) tools"""
+
+ # Node type for registration
+
+ # Category and icon - these are not i18n
+ category = 'integration'
+
+ # Name and description - i18n handled on frontend side
+ # Frontend will use node type key to look up translation
+
+ # Inputs/outputs/config - loaded from YAML at runtime
+
+ async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
+ """Execute the MCP tool node
+
+ Args:
+ inputs: Input data from connected nodes
+ context: Execution context with workflow state
+
+ Returns:
+ Dictionary of output values
+ """
+ server_name = self.get_config('server_name', '')
+ tool_name = self.get_config('tool_name', '')
+ arguments_template = self.get_config('arguments_template', '')
+ timeout = self.get_config('timeout', 30)
+
+ arguments = inputs.get('arguments', arguments_template)
+
+ return {
+ 'result': None,
+ 'success': False,
+ 'error': f"MCP tool '{server_name}/{tool_name}' not implemented yet",
+ '_debug': {
+ 'server_name': server_name,
+ 'tool_name': tool_name,
+ 'arguments': arguments,
+ 'timeout': timeout,
+ },
+ }
diff --git a/src/langbot/pkg/workflow/nodes/memory_store.py b/src/langbot/pkg/workflow/nodes/memory_store.py
new file mode 100644
index 000000000..8cc2b1105
--- /dev/null
+++ b/src/langbot/pkg/workflow/nodes/memory_store.py
@@ -0,0 +1,85 @@
+"""Memory Store Node - store and retrieve from workflow memory
+
+Node metadata is loaded from: ../../templates/metadata/nodes/memory_store.yaml
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from langbot_plugin.api.entities.builtin.workflow import ExecutionContext
+from ..node import WorkflowNode, workflow_node
+
+class MemoryHelper:
+ """Helper class wrapping context.memory dict with get/set/delete/list_all/append operations"""
+
+ def __init__(self, memory_dict: dict[str, Any]):
+ self._data = memory_dict
+
+ def get(self, key: str, scope: str = 'execution', default: Any = None) -> Any:
+ """Get a value from memory by key"""
+ scoped_key = f'{scope}:{key}' if scope else key
+ return self._data.get(scoped_key, default)
+
+ def set(self, key: str, value: Any, scope: str = 'execution', ttl: int = 0) -> None:
+ """Set a value in memory"""
+ scoped_key = f'{scope}:{key}' if scope else key
+ self._data[scoped_key] = value
+
+ def delete(self, key: str, scope: str = 'execution') -> None:
+ """Delete a value from memory"""
+ scoped_key = f'{scope}:{key}' if scope else key
+ self._data.pop(scoped_key, None)
+
+ def list_all(self, scope: str = 'execution') -> dict[str, Any]:
+ """List all values in the given scope"""
+ prefix = f'{scope}:'
+ return {k[len(prefix) :]: v for k, v in self._data.items() if k.startswith(prefix)}
+
+ def append(self, key: str, value: Any, scope: str = 'execution', ttl: int = 0) -> list:
+ """Append a value to a list in memory"""
+ current = self.get(key, scope=scope, default=[])
+ if isinstance(current, list):
+ current.append(value)
+ else:
+ current = [current, value]
+ self.set(key, current, scope=scope, ttl=ttl)
+ return current
+
+@workflow_node('memory_store')
+class MemoryStoreNode(WorkflowNode):
+ """Memory store node - store and retrieve from workflow memory"""
+
+ category = 'integration'
+
+ async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
+ operation = self.get_config('operation', 'get')
+ key = self.get_config('key', '')
+ scope = self.get_config('scope', 'execution')
+ ttl = self.get_config('ttl', 0)
+
+ value = inputs.get('value')
+
+ # Wrap context.memory dict with MemoryHelper for structured operations
+ memory = MemoryHelper(context.memory)
+
+ try:
+ if operation == 'get':
+ result = memory.get(key, scope=scope)
+ return {'result': result, 'success': True}
+ elif operation == 'set':
+ memory.set(key, value, scope=scope, ttl=ttl)
+ return {'result': value, 'success': True}
+ elif operation == 'delete':
+ memory.delete(key, scope=scope)
+ return {'result': None, 'success': True}
+ elif operation == 'append':
+ result = memory.append(key, value, scope=scope, ttl=ttl)
+ return {'result': result, 'success': True}
+ elif operation == 'list':
+ result = memory.list_all(scope=scope)
+ return {'result': result, 'success': True}
+ else:
+ return {'result': None, 'success': False, 'error': f'Unknown operation: {operation}'}
+ except Exception as e:
+ return {'result': None, 'success': False, 'error': str(e)}
diff --git a/src/langbot/pkg/workflow/nodes/merge.py b/src/langbot/pkg/workflow/nodes/merge.py
new file mode 100644
index 000000000..483f3701e
--- /dev/null
+++ b/src/langbot/pkg/workflow/nodes/merge.py
@@ -0,0 +1,52 @@
+"""Merge Node - combine multiple inputs
+
+Node metadata is loaded from: ../../templates/metadata/nodes/merge.yaml
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from langbot_plugin.api.entities.builtin.workflow import ExecutionContext
+from ..node import WorkflowNode, workflow_node
+
+@workflow_node('merge')
+class MergeNode(WorkflowNode):
+ """Merge node - combine multiple inputs"""
+
+ category = 'control'
+
+ async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
+ strategy = self.get_config('merge_strategy', 'object')
+
+ values = [inputs.get('input_1'), inputs.get('input_2'), inputs.get('input_3'), inputs.get('input_4')]
+ non_null_values = [v for v in values if v is not None]
+
+ if strategy == 'object':
+ merged = {}
+ for i, v in enumerate(non_null_values):
+ if isinstance(v, dict):
+ merged.update(v)
+ else:
+ merged[f'value_{i}'] = v
+ return {'merged': merged, 'array': non_null_values}
+
+ elif strategy == 'array':
+ return {'merged': non_null_values, 'array': non_null_values}
+
+ elif strategy == 'first_non_null':
+ first = non_null_values[0] if non_null_values else None
+ return {'merged': first, 'array': non_null_values}
+
+ elif strategy == 'concat':
+ if all(isinstance(v, str) for v in non_null_values):
+ return {'merged': ''.join(non_null_values), 'array': non_null_values}
+ elif all(isinstance(v, list) for v in non_null_values):
+ merged_list = []
+ for v in non_null_values:
+ merged_list.extend(v)
+ return {'merged': merged_list, 'array': merged_list}
+ else:
+ return {'merged': non_null_values, 'array': non_null_values}
+
+ return {'merged': non_null_values, 'array': non_null_values}
diff --git a/src/langbot/pkg/workflow/nodes/message_trigger.py b/src/langbot/pkg/workflow/nodes/message_trigger.py
new file mode 100644
index 000000000..c4b450716
--- /dev/null
+++ b/src/langbot/pkg/workflow/nodes/message_trigger.py
@@ -0,0 +1,44 @@
+"""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
+
+from langbot_plugin.api.entities.builtin.workflow import ExecutionContext
+from ..node import WorkflowNode, workflow_node
+
+@workflow_node('message_trigger')
+class MessageTriggerNode(WorkflowNode):
+ """Message trigger node - triggers workflow on message arrival"""
+
+ category = 'trigger'
+
+ async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
+ msg_ctx = context.message_context
+
+ if msg_ctx:
+ return {
+ 'message': msg_ctx.message_content,
+ 'sender_id': msg_ctx.sender_id,
+ 'sender_name': msg_ctx.sender_name,
+ 'platform': msg_ctx.platform,
+ 'conversation_id': msg_ctx.conversation_id,
+ 'is_group': msg_ctx.is_group,
+ 'context': msg_ctx.model_dump(),
+ }
+
+ # Use safe variable access with fallback
+ return {
+ 'message': context.get_variable('message') or '',
+ 'sender_id': context.get_variable('sender_id') or '',
+ 'sender_name': context.get_variable('sender_name') or '',
+ 'platform': context.get_variable('platform') or '',
+ 'conversation_id': context.get_variable('conversation_id') or '',
+ 'is_group': context.get_variable('is_group') or False,
+ 'context': context.trigger_data or {},
+ }
diff --git a/src/langbot/pkg/workflow/nodes/n8n_workflow.py b/src/langbot/pkg/workflow/nodes/n8n_workflow.py
new file mode 100644
index 000000000..2137b1926
--- /dev/null
+++ b/src/langbot/pkg/workflow/nodes/n8n_workflow.py
@@ -0,0 +1,34 @@
+"""N8n Workflow Node - call n8n workflow API
+
+Node metadata is loaded from: ../../templates/metadata/nodes/n8n_workflow.yaml
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from langbot_plugin.api.entities.builtin.workflow import ExecutionContext
+from ..node import WorkflowNode, workflow_node
+
+@workflow_node('n8n_workflow')
+class N8nWorkflowNode(WorkflowNode):
+ """n8n workflow node - call n8n workflow API"""
+
+ category = 'integration'
+
+ async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
+ webhook_url = self.get_config('webhook_url', '')
+ auth_type = self.get_config('auth_type', 'none')
+ timeout = self.get_config('timeout', 120)
+ payload = inputs.get('payload', {})
+
+ return {
+ 'result': None,
+ 'success': False,
+ '_debug': {
+ 'webhook_url': webhook_url,
+ 'auth_type': auth_type,
+ 'timeout': timeout,
+ 'payload': payload,
+ },
+ }
diff --git a/src/langbot/pkg/workflow/nodes/opening_statement.py b/src/langbot/pkg/workflow/nodes/opening_statement.py
new file mode 100644
index 000000000..20e500080
--- /dev/null
+++ b/src/langbot/pkg/workflow/nodes/opening_statement.py
@@ -0,0 +1,24 @@
+"""Opening Statement Node - provide conversation opener and suggested questions
+
+Node metadata is loaded from: ../../templates/metadata/nodes/opening_statement.yaml
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from langbot_plugin.api.entities.builtin.workflow import ExecutionContext
+from ..node import WorkflowNode, workflow_node
+
+@workflow_node('opening_statement')
+class OpeningStatementNode(WorkflowNode):
+ """Opening statement node - provide conversation opener and suggested questions"""
+
+ category = 'action'
+
+ async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
+ statement = self.get_config('statement', '')
+ suggestions = self.get_config('suggested_questions', [])
+ show = self.get_config('show_suggestions', True)
+
+ return {'statement': statement, 'suggested_questions': suggestions if show else []}
diff --git a/src/langbot/pkg/workflow/nodes/parallel.py b/src/langbot/pkg/workflow/nodes/parallel.py
new file mode 100644
index 000000000..0cb2eeec6
--- /dev/null
+++ b/src/langbot/pkg/workflow/nodes/parallel.py
@@ -0,0 +1,20 @@
+"""Parallel Node - execute multiple branches simultaneously"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from langbot_plugin.api.entities.builtin.workflow import ExecutionContext
+from ..node import WorkflowNode, workflow_node
+
+@workflow_node('parallel')
+class ParallelNode(WorkflowNode):
+ """Parallel node - execute multiple branches simultaneously"""
+
+ category = 'control'
+
+ async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
+ return {
+ 'results': {},
+ 'errors': [],
+ }
diff --git a/src/langbot/pkg/workflow/nodes/parameter_extractor.py b/src/langbot/pkg/workflow/nodes/parameter_extractor.py
new file mode 100644
index 000000000..2f7cca72b
--- /dev/null
+++ b/src/langbot/pkg/workflow/nodes/parameter_extractor.py
@@ -0,0 +1,133 @@
+"""Parameter Extractor Node - extract structured parameters from text
+
+Node metadata is loaded from: ../../templates/metadata/nodes/parameter_extractor.yaml
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+from typing import Any
+
+from langbot_plugin.api.entities.builtin.workflow import ExecutionContext
+from ..node import WorkflowNode, workflow_node
+
+logger = logging.getLogger(__name__)
+
+@workflow_node('parameter_extractor')
+class ParameterExtractorNode(WorkflowNode):
+ """Parameter extractor node - extract structured parameters from text"""
+
+ category = 'process'
+ icon: str = 'Variable'
+
+ async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
+ # Get input text
+ input_text = inputs.get('input') or inputs.get('message') or inputs.get('content') or ''
+ input_text = str(input_text) if input_text is not None else ''
+
+ # Get configuration
+ param_defs = self.get_config('parameters', [])
+ model_id = self.get_config('model', '')
+ system_prompt = self.get_config('system_prompt', '')
+
+ if not input_text.strip():
+ return {
+ 'parameters': {},
+ 'extraction_success': False,
+ 'error': 'Empty input',
+ }
+
+ if not param_defs:
+ return {
+ 'parameters': {},
+ 'extraction_success': False,
+ 'error': 'No parameters configured',
+ }
+
+ # Build parameter schema for LLM prompt
+ param_schema = []
+ for param in param_defs:
+ schema_item = {
+ 'name': param.get('name', ''),
+ 'type': param.get('type', 'string'),
+ 'description': param.get('description', ''),
+ 'required': param.get('required', False),
+ }
+ param_schema.append(schema_item)
+
+ # Build extraction prompt
+ if not system_prompt:
+ system_prompt = (
+ f'Extract the following parameters from the user\'s text as JSON. '
+ f'Respond with ONLY a valid JSON object containing the extracted parameters.\n\n'
+ f'Parameters to extract:\n'
+ f'{json.dumps(param_schema, indent=2, ensure_ascii=False)}\n\n'
+ f'Respond with a JSON object like: {{"param_name": "value", ...}}'
+ )
+
+ # Call LLM for extraction
+ if self.ap and model_id:
+ try:
+ # Get model (same as llm_call.py)
+ runtime_model = await self.ap.model_mgr.get_model_by_uuid(model_id)
+
+ # Build messages
+ from langbot_plugin.api.entities.builtin.provider.message import Message
+ messages = []
+ if system_prompt:
+ messages.append(Message(role='system', content=system_prompt))
+ messages.append(Message(role='user', content=input_text))
+
+ # Invoke LLM (same as llm_call.py)
+ result_message = await runtime_model.provider.invoke_llm(
+ query=None,
+ model=runtime_model,
+ messages=messages,
+ funcs=None,
+ extra_args={},
+ )
+
+ # Extract response text
+ response_text = ''
+ if isinstance(result_message.content, str):
+ response_text = result_message.content
+ elif isinstance(result_message.content, list):
+ for elem in result_message.content:
+ if hasattr(elem, 'text') and elem.text:
+ response_text += elem.text
+ elif isinstance(elem, str):
+ response_text += elem
+
+ response_text = response_text.strip()
+
+ # Parse JSON response
+ try:
+ extracted = json.loads(response_text)
+ return {
+ 'parameters': extracted,
+ 'extraction_success': True,
+ 'raw_response': response_text[:500],
+ }
+ except json.JSONDecodeError as e:
+ logger.error('ParameterExtractorNode JSON parse error: %s', e)
+ return {
+ 'parameters': {},
+ 'extraction_success': False,
+ 'error': f'Failed to parse JSON: {e}',
+ 'raw_response': response_text[:500],
+ }
+
+ except Exception as e:
+ logger.error('ParameterExtractorNode LLM error: %s', e, exc_info=True)
+ return {
+ 'parameters': {},
+ 'extraction_success': False,
+ 'error': f'LLM error: {e}',
+ }
+ else:
+ return {
+ 'parameters': {},
+ 'extraction_success': False,
+ 'error': 'Missing model configuration',
+ }
diff --git a/src/langbot/pkg/workflow/nodes/plugin_call.py b/src/langbot/pkg/workflow/nodes/plugin_call.py
new file mode 100644
index 000000000..428f333ae
--- /dev/null
+++ b/src/langbot/pkg/workflow/nodes/plugin_call.py
@@ -0,0 +1,41 @@
+# """Plugin Call Node - invoke a plugin
+
+# Node metadata is loaded from: ../../templates/metadata/nodes/plugin_call.yaml
+# """
+
+# from __future__ import annotations
+
+# from typing import Any
+
+# from langbot_plugin.api.entities.builtin.workflow import ExecutionContext
+# from ..node import WorkflowNode, workflow_node
+
+# @workflow_node('plugin_call')
+# class PluginCallNode(WorkflowNode):
+# """Plugin call node - invoke a plugin"""
+
+# type_name = "plugin_call"
+# category = "action"
+# icon = "🔌"
+# name = "plugin_call"
+# description = "plugin_call"
+
+# inputs: ClassVar[list[NodePort]] = []
+# outputs: ClassVar[list[NodePort]] = []
+# config_schema: ClassVar[list[NodeConfig]] = []
+
+# async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
+# plugin_name = self.get_config("plugin_name", "")
+# method_name = self.get_config("method_name", "")
+# arguments = inputs.get("arguments", {})
+
+# return {
+# "result": None,
+# "success": False,
+# "error": f"Plugin call '{plugin_name}/{method_name}' not implemented yet",
+# "_debug": {
+# "plugin_name": plugin_name,
+# "method_name": method_name,
+# "arguments": arguments,
+# },
+# }
diff --git a/src/langbot/pkg/workflow/nodes/question_classifier.py b/src/langbot/pkg/workflow/nodes/question_classifier.py
new file mode 100644
index 000000000..df561ff9c
--- /dev/null
+++ b/src/langbot/pkg/workflow/nodes/question_classifier.py
@@ -0,0 +1,129 @@
+"""Question Classifier Node - classify user questions into categories
+
+Node metadata is loaded from: ../../templates/metadata/nodes/question_classifier.yaml
+"""
+
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+from langbot_plugin.api.entities.builtin.workflow import ExecutionContext
+from ..node import WorkflowNode, workflow_node
+
+logger = logging.getLogger(__name__)
+
+@workflow_node('question_classifier')
+class QuestionClassifierNode(WorkflowNode):
+ """Question classifier node - classify user questions into categories"""
+
+ category = 'process'
+
+ async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
+ # Get input text
+ input_text = inputs.get('input') or inputs.get('message') or inputs.get('content') or ''
+ input_text = str(input_text) if input_text is not None else ''
+
+ # Get configuration
+ categories = self.get_config('categories', [])
+ model_id = self.get_config('model', '')
+ system_prompt = self.get_config('system_prompt', '')
+
+ if not input_text.strip():
+ return {
+ 'category': 'unknown',
+ 'confidence': 0.0,
+ 'all_scores': {},
+ 'error': 'Empty input',
+ }
+
+ if not categories:
+ return {
+ 'category': 'unknown',
+ 'confidence': 0.0,
+ 'all_scores': {},
+ 'error': 'No categories configured',
+ }
+
+ # Build category list for LLM prompt
+ category_names = [cat.get('name', '') for cat in categories if cat.get('name')]
+
+ # Build classification prompt
+ if not system_prompt:
+ system_prompt = (
+ f'You are a question classifier. Classify the user\'s question into one of these categories: '
+ f'{", ".join(category_names)}. '
+ f'Respond with ONLY the category name, nothing else.'
+ )
+
+ # Call LLM for classification
+ if self.ap and model_id:
+ try:
+ # Get model (same as llm_call.py)
+ runtime_model = await self.ap.model_mgr.get_model_by_uuid(model_id)
+
+ # Build messages
+ from langbot_plugin.api.entities.builtin.provider.message import Message
+ messages = []
+ if system_prompt:
+ messages.append(Message(role='system', content=system_prompt))
+ messages.append(Message(role='user', content=input_text))
+
+ # Invoke LLM (same as llm_call.py)
+ result_message = await runtime_model.provider.invoke_llm(
+ query=None,
+ model=runtime_model,
+ messages=messages,
+ funcs=None,
+ extra_args={},
+ )
+
+ # Extract response text
+ response_text = ''
+ if isinstance(result_message.content, str):
+ response_text = result_message.content
+ elif isinstance(result_message.content, list):
+ for elem in result_message.content:
+ if hasattr(elem, 'text') and elem.text:
+ response_text += elem.text
+ elif isinstance(elem, str):
+ response_text += elem
+
+ response_text = response_text.strip()
+
+ # Find matching category
+ matched_category = None
+ for cat in categories:
+ if cat.get('name', '').lower() == response_text.lower():
+ matched_category = cat
+ break
+
+ if matched_category:
+ return {
+ 'category': matched_category['name'],
+ 'confidence': 0.9,
+ 'all_scores': {cat.get('name', ''): 0.1 for cat in categories},
+ }
+ else:
+ # Default to first category if no match
+ return {
+ 'category': category_names[0] if category_names else 'unknown',
+ 'confidence': 0.5,
+ 'all_scores': {cat.get('name', ''): 0.1 for cat in categories},
+ }
+
+ except Exception as e:
+ logger.error('QuestionClassifierNode LLM error: %s', e, exc_info=True)
+ return {
+ 'category': category_names[0] if category_names else 'unknown',
+ 'confidence': 0.0,
+ 'all_scores': {},
+ 'error': f'LLM error: {e}',
+ }
+ else:
+ return {
+ 'category': category_names[0] if category_names else 'unknown',
+ 'confidence': 0.0,
+ 'all_scores': {},
+ 'error': 'Missing model configuration',
+ }
diff --git a/src/langbot/pkg/workflow/nodes/redis_operation.py b/src/langbot/pkg/workflow/nodes/redis_operation.py
new file mode 100644
index 000000000..6e4b4eab4
--- /dev/null
+++ b/src/langbot/pkg/workflow/nodes/redis_operation.py
@@ -0,0 +1,40 @@
+"""Redis Operation Node - perform Redis cache operations
+
+Node metadata is loaded from: ../../templates/metadata/nodes/redis_operation.yaml
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from langbot_plugin.api.entities.builtin.workflow import ExecutionContext
+from ..node import WorkflowNode, workflow_node
+
+@workflow_node('redis_operation')
+class RedisOperationNode(WorkflowNode):
+ """Redis operation node - perform Redis cache operations"""
+
+ category = 'integration'
+
+ async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
+ connection_url = self.get_config('connection_url', 'redis://localhost:6379')
+ operation = self.get_config('operation', 'get')
+ key_template = self.get_config('key_template', '')
+ hash_field = self.get_config('hash_field', '')
+ ttl = self.get_config('ttl', 0)
+
+ key = inputs.get('key', key_template)
+ value = inputs.get('value')
+
+ return {
+ 'result': None,
+ 'success': False,
+ '_debug': {
+ 'connection_url': connection_url,
+ 'operation': operation,
+ 'key': key,
+ 'hash_field': hash_field,
+ 'ttl': ttl,
+ 'value': value,
+ },
+ }
diff --git a/src/langbot/pkg/workflow/nodes/reply_message.py b/src/langbot/pkg/workflow/nodes/reply_message.py
new file mode 100644
index 000000000..d168531bd
--- /dev/null
+++ b/src/langbot/pkg/workflow/nodes/reply_message.py
@@ -0,0 +1,110 @@
+"""Reply Message Node - reply to the triggering message
+
+Node metadata is loaded from: ../../templates/metadata/nodes/reply_message.yaml
+"""
+
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+from langbot_plugin.api.entities.builtin.workflow import ExecutionContext
+from ..node import WorkflowNode, workflow_node
+
+logger = logging.getLogger(__name__)
+
+@workflow_node('reply_message')
+class ReplyMessageNode(WorkflowNode):
+ """Reply message node - reply to the triggering message"""
+
+ category = 'action'
+
+ async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
+ # Priority: content/response (from upstream nodes) > message (original input) > context
+ message = inputs.get('content')
+ if message in (None, ''):
+ message = inputs.get('response')
+ if message in (None, ''):
+ message = inputs.get('input')
+ if message in (None, ''):
+ message = inputs.get('message')
+ if message in (None, '') and context.message_context:
+ message = context.message_context.message_content
+ if message is None:
+ message = ''
+
+ template = self.get_config('message_template')
+
+ if template:
+ message = template
+ for key, value in inputs.items():
+ try:
+ message = message.replace(f'{{{{{key}}}}}', str(value) if value is not None else '')
+ except Exception:
+ pass
+ for key, value in context.variables.items():
+ try:
+ message = message.replace(f'{{{{variables.{key}}}}}', str(value) if value is not None else '')
+ except Exception:
+ pass
+
+ message_str = str(message) if message is not None else ''
+
+ logger.info(
+ 'ReplyMessageNode resolved message',
+ extra={
+ 'node_id': self.node_id,
+ 'execution_id': context.execution_id,
+ 'input_keys': list(inputs.keys()),
+ 'message_preview': message_str[:200],
+ 'has_template': bool(template),
+ 'session_id': context.session_id,
+ },
+ )
+
+ if not message_str.strip():
+ logger.warning(
+ 'ReplyMessageNode has empty message after resolution',
+ extra={
+ 'node_id': self.node_id,
+ 'execution_id': context.execution_id,
+ 'input_keys': list(inputs.keys()),
+ },
+ )
+
+ # 实际发送消息
+ send_success = False
+ send_error = None
+ if self.ap:
+ try:
+ from langbot_plugin.api.entities.builtin.platform.message import MessageChain, Plain
+
+ message_chain = MessageChain([Plain(text=message_str)])
+ target_type = getattr(context, 'target_type', 'person') or 'person'
+ session_id = context.session_id or 'unknown'
+ target_id = f'websocket_{session_id}'
+
+ await self.ap.platform_mgr.websocket_proxy_bot.adapter.send_message(
+ target_type=target_type,
+ target_id=target_id,
+ message=message_chain,
+ )
+ send_success = True
+ except Exception as e:
+ send_error = str(e)
+ logger.error('ReplyMessageNode send message failed: %s', e, exc_info=True)
+ else:
+ send_error = 'Missing application instance'
+ logger.warning(
+ 'ReplyMessageNode missing application instance',
+ extra={
+ 'node_id': self.node_id,
+ 'execution_id': context.execution_id,
+ },
+ )
+
+ return {
+ 'status': 'sent' if send_success else 'failed',
+ 'message_preview': message_str[:200],
+ 'error': send_error,
+ }
diff --git a/src/langbot/pkg/workflow/nodes/send_message.py b/src/langbot/pkg/workflow/nodes/send_message.py
new file mode 100644
index 000000000..8fe7bdf43
--- /dev/null
+++ b/src/langbot/pkg/workflow/nodes/send_message.py
@@ -0,0 +1,71 @@
+"""Send Message Node - send message to a target
+
+Node metadata is loaded from: ../../templates/metadata/nodes/send_message.yaml
+"""
+
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+from langbot_plugin.api.entities.builtin.workflow import ExecutionContext
+from ..node import WorkflowNode, workflow_node
+
+logger = logging.getLogger(__name__)
+
+@workflow_node('send_message')
+class SendMessageNode(WorkflowNode):
+ """Send message node - send message to a target"""
+
+ category = 'action'
+
+ async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
+ # Get message content from inputs
+ message = inputs.get('message') or inputs.get('content') or inputs.get('input') or ''
+ message = str(message) if message is not None else ''
+
+ # Get target configuration
+ target_type = self.get_config('target_type', 'person')
+ target_id = self.get_config('target_id', '')
+
+ # If no target_id configured, use session_id from context
+ if not target_id:
+ target_id = f'{context.session_id or "unknown"}'
+
+ if not message.strip():
+ logger.warning('SendMessageNode has empty message')
+ return {
+ 'status': 'failed',
+ 'error': 'Empty message',
+ 'message_preview': '',
+ }
+
+ # Send message if application instance is available
+ send_success = False
+ send_error = None
+ if self.ap:
+ try:
+ from langbot_plugin.api.entities.builtin.platform.message import MessageChain, Plain
+
+ message_chain = MessageChain([Plain(text=message)])
+ await self.ap.platform_mgr.websocket_proxy_bot.adapter.send_message(
+ target_type=target_type,
+ target_id=target_id,
+ message=message_chain,
+ )
+ send_success = True
+ logger.info('SendMessageNode sent message to %s:%s', target_type, target_id)
+ except Exception as e:
+ send_error = str(e)
+ logger.error('SendMessageNode send failed: %s', e, exc_info=True)
+ else:
+ send_error = 'Missing application instance'
+ logger.warning('SendMessageNode missing application instance')
+
+ return {
+ 'status': 'sent' if send_success else 'failed',
+ 'message_preview': message[:200],
+ 'target_type': target_type,
+ 'target_id': target_id,
+ 'error': send_error,
+ }
diff --git a/src/langbot/pkg/workflow/nodes/set_variable.py b/src/langbot/pkg/workflow/nodes/set_variable.py
new file mode 100644
index 000000000..bee36e36d
--- /dev/null
+++ b/src/langbot/pkg/workflow/nodes/set_variable.py
@@ -0,0 +1,51 @@
+"""Set Variable Node - set workflow or conversation variable
+
+Node metadata is loaded from: ../../templates/metadata/nodes/set_variable.yaml
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from langbot_plugin.api.entities.builtin.workflow import ExecutionContext
+from ..node import WorkflowNode, workflow_node
+
+@workflow_node('set_variable')
+class SetVariableNode(WorkflowNode):
+ """Set variable node - set workflow or conversation variable"""
+
+ category = 'action'
+
+ async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
+ value = inputs.get('value')
+ name = self.get_config('variable_name', '')
+ scope = self.get_config('variable_scope', 'workflow')
+ operation = self.get_config('operation', 'set')
+
+ if scope == 'conversation':
+ current = context.get_conversation_variable(name)
+ else:
+ current = context.get_variable(name)
+
+ if operation == 'set':
+ final_value = value
+ elif operation == 'append':
+ if isinstance(current, list):
+ final_value = current + [value]
+ elif isinstance(current, str):
+ final_value = current + str(value)
+ else:
+ final_value = [current, value] if current else [value]
+ elif operation == 'increment':
+ final_value = (current or 0) + (value if isinstance(value, (int, float)) else 1)
+ elif operation == 'decrement':
+ final_value = (current or 0) - (value if isinstance(value, (int, float)) else 1)
+ else:
+ final_value = value
+
+ if scope == 'conversation':
+ context.set_conversation_variable(name, final_value)
+ else:
+ context.set_variable(name, final_value)
+
+ return {'value': final_value}
diff --git a/src/langbot/pkg/workflow/nodes/store_data.py b/src/langbot/pkg/workflow/nodes/store_data.py
new file mode 100644
index 000000000..7eb20e0ba
--- /dev/null
+++ b/src/langbot/pkg/workflow/nodes/store_data.py
@@ -0,0 +1,32 @@
+"""Store Data Node - save data to storage
+
+Node metadata is loaded from: ../../templates/metadata/nodes/store_data.yaml
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from langbot_plugin.api.entities.builtin.workflow import ExecutionContext
+from ..node import WorkflowNode, workflow_node
+
+@workflow_node('store_data')
+class StoreDataNode(WorkflowNode):
+ """Store data node - save data to storage"""
+
+ category = 'action'
+
+ async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
+ key = inputs.get('key', '')
+ value = inputs.get('value')
+ storage_type = self.get_config('storage_type', 'session')
+ prefix = self.get_config('key_prefix', '')
+
+ full_key = f'{prefix}{key}' if prefix else key
+
+ if storage_type == 'session':
+ context.set_conversation_variable(full_key, value)
+ else:
+ context.set_variable(full_key, value)
+
+ return {'status': 'stored'}
diff --git a/src/langbot/pkg/workflow/nodes/switch.py b/src/langbot/pkg/workflow/nodes/switch.py
new file mode 100644
index 000000000..8d1966472
--- /dev/null
+++ b/src/langbot/pkg/workflow/nodes/switch.py
@@ -0,0 +1,51 @@
+"""Switch Node - multi-way branch based on value
+
+Node metadata is loaded from: ../../templates/metadata/nodes/switch.yaml
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from langbot_plugin.api.entities.builtin.workflow import ExecutionContext
+from ..node import WorkflowNode, workflow_node
+
+@workflow_node('switch')
+class SwitchNode(WorkflowNode):
+ """Switch node - multi-way branch based on value"""
+
+ category = 'control'
+
+ async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
+ expression = self.get_config('expression', '')
+ cases = self.get_config('cases', [])
+ input_data = inputs.get('input')
+
+ value = await self._evaluate_expression(expression, input_data, context)
+
+ for case in cases:
+ if str(case.get('value')) == str(value):
+ return {'matched_case': input_data, 'default': None, '_matched_output': case.get('output')}
+
+ return {'matched_case': None, 'default': input_data}
+
+ async def _evaluate_expression(self, expression: str, data: Any, context: ExecutionContext) -> Any:
+ if not expression:
+ return data
+
+ if expression.startswith('{{') and expression.endswith('}}'):
+ var_path = expression[2:-2].strip()
+ parts = var_path.split('.')
+
+ if parts[0] == 'input':
+ result = data
+ for part in parts[1:]:
+ if isinstance(result, dict):
+ result = result.get(part)
+ else:
+ return None
+ return result
+ elif parts[0] == 'variables':
+ return context.variables.get('.'.join(parts[1:]))
+
+ return expression
diff --git a/src/langbot/pkg/workflow/nodes/variable_aggregator.py b/src/langbot/pkg/workflow/nodes/variable_aggregator.py
new file mode 100644
index 000000000..e9cfbce8f
--- /dev/null
+++ b/src/langbot/pkg/workflow/nodes/variable_aggregator.py
@@ -0,0 +1,38 @@
+"""Variable Aggregator Node - aggregate variables from multiple branches
+
+Node metadata is loaded from: ../../templates/metadata/nodes/variable_aggregator.yaml
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from langbot_plugin.api.entities.builtin.workflow import ExecutionContext
+from ..node import WorkflowNode, workflow_node
+
+@workflow_node('variable_aggregator')
+class VariableAggregatorNode(WorkflowNode):
+ """Variable aggregator node - aggregate variables from multiple branches"""
+
+ category = 'control'
+
+ async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
+ variables = inputs.get('variables', {})
+ mode = self.get_config('aggregation_mode', 'merge')
+
+ aggregated = {}
+
+ if mode == 'merge':
+ if isinstance(variables, dict):
+ aggregated.update(variables)
+ elif mode == 'override':
+ if isinstance(variables, dict):
+ aggregated = variables.copy()
+ elif mode == 'append':
+ for key, value in (variables if isinstance(variables, dict) else {}).items():
+ if key in aggregated and isinstance(aggregated[key], list):
+ aggregated[key].append(value)
+ else:
+ aggregated[key] = [value]
+
+ return {'aggregated': aggregated}
diff --git a/src/langbot/pkg/workflow/nodes/wait.py b/src/langbot/pkg/workflow/nodes/wait.py
new file mode 100644
index 000000000..57b9bc3d1
--- /dev/null
+++ b/src/langbot/pkg/workflow/nodes/wait.py
@@ -0,0 +1,50 @@
+"""Wait Node - pause execution for a duration
+
+Node metadata is loaded from: ../../templates/metadata/nodes/wait.yaml
+"""
+
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+from langbot_plugin.api.entities.builtin.workflow import ExecutionContext
+from ..node import WorkflowNode, workflow_node
+
+logger = logging.getLogger(__name__)
+
+# 最大等待时间(秒)
+_MAX_WAIT_SECONDS = 300 # 5 分钟
+
+
+@workflow_node('wait')
+class WaitNode(WorkflowNode):
+ """Wait node - pause execution for a duration"""
+
+ category = 'control'
+
+ async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
+ import asyncio
+
+ duration = self.get_config('duration', 1)
+ duration_type = self.get_config('duration_type', 'seconds')
+
+ # 转换为秒
+ if duration_type == 'minutes':
+ duration *= 60
+ elif duration_type == 'hours':
+ duration *= 3600
+
+ # 限制最大等待时间
+ if duration > _MAX_WAIT_SECONDS:
+ logger.warning('Wait duration %s exceeds maximum %s, capping to %s',
+ duration, _MAX_WAIT_SECONDS, _MAX_WAIT_SECONDS)
+ duration = _MAX_WAIT_SECONDS
+
+ # 确保 duration 为正数
+ duration = max(0, duration)
+
+ logger.info('Waiting for %.2f seconds', duration)
+ await asyncio.sleep(duration)
+
+ return {'output': inputs.get('input'), 'waited_seconds': duration}
diff --git a/src/langbot/pkg/workflow/nodes/webhook_trigger.py b/src/langbot/pkg/workflow/nodes/webhook_trigger.py
new file mode 100644
index 000000000..e7b6eca6d
--- /dev/null
+++ b/src/langbot/pkg/workflow/nodes/webhook_trigger.py
@@ -0,0 +1,33 @@
+"""Webhook Trigger Node - triggers workflow via HTTP request
+
+Node metadata is loaded from: ../../templates/metadata/nodes/webhook_trigger.yaml
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from langbot_plugin.api.entities.builtin.workflow import ExecutionContext
+from ..node import WorkflowNode, workflow_node
+
+@workflow_node('webhook_trigger')
+class WebhookTriggerNode(WorkflowNode):
+ """Webhook trigger node - triggers workflow via HTTP request"""
+
+ category = 'trigger'
+
+ async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
+ # Safe access to trigger_data which may be None
+ trigger_data = context.trigger_data or {}
+
+ # Filter sensitive headers (Authorization, Cookie, etc.)
+ headers = trigger_data.get('headers', {})
+ safe_headers = {k: v for k, v in headers.items()
+ if k.lower() not in ('authorization', 'cookie', 'x-api-key', 'x-secret')}
+
+ return {
+ 'body': trigger_data.get('body', {}),
+ 'headers': safe_headers,
+ 'query': trigger_data.get('query', {}),
+ 'method': trigger_data.get('method', 'POST'),
+ }
diff --git a/src/langbot/pkg/workflow/registry.py b/src/langbot/pkg/workflow/registry.py
new file mode 100644
index 000000000..37fae1803
--- /dev/null
+++ b/src/langbot/pkg/workflow/registry.py
@@ -0,0 +1,433 @@
+"""Workflow node type registry."""
+
+from __future__ import annotations
+
+import copy
+import logging
+from typing import Any, Optional
+
+from .metadata import build_node_type
+from .node import WorkflowNode, clear_pending_registrations, get_pending_registrations
+
+logger = logging.getLogger(__name__)
+
+
+class NodeConflictError(Exception):
+ """Raised when two workflow node metadata definitions conflict."""
+
+
+class NodeTypeRegistry:
+ """
+ Central registry for workflow node types.
+
+ YAML metadata is the UI-facing source of truth. Python node classes are
+ registered separately and provide execution logic only.
+ """
+
+ _instance: Optional['NodeTypeRegistry'] = None
+
+ def __init__(self):
+ self._nodes: dict[str, type[WorkflowNode]] = {}
+ self._metadata: dict[str, dict[str, Any]] = {}
+ self._metadata_sources: dict[str, str] = {}
+ self._categories: dict[str, list[str]] = {
+ 'trigger': [],
+ 'process': [],
+ 'control': [],
+ 'action': [],
+ 'integration': [],
+ 'misc': [],
+ }
+ self._conflicts: list[dict[str, str]] = []
+
+ @classmethod
+ def instance(cls) -> 'NodeTypeRegistry':
+ """Get singleton instance."""
+ if cls._instance is None:
+ cls._instance = cls()
+ return cls._instance
+
+ def register_metadata(self, metadata: dict[str, Any], source: str = 'core') -> bool:
+ """Register YAML metadata for a workflow node type.
+
+ Core metadata cannot be overridden by plugin metadata. Plugin-plugin
+ conflicts are allowed with a warning so hot-reload/development flows can
+ replace plugin definitions.
+ """
+ node_type = build_node_type(metadata)
+ existing_source = self._metadata_sources.get(node_type)
+
+ if existing_source:
+ conflict = {'type': node_type, 'existing_source': existing_source, 'new_source': source}
+ if existing_source == 'core' and source != 'core':
+ self._conflicts.append(conflict)
+ logger.error('Plugin source %s attempted to override core workflow node %s', source, node_type)
+ return False
+ logger.warning(
+ 'Workflow node metadata %s from %s overrides previous source %s', node_type, source, existing_source
+ )
+
+ cached_metadata = copy.deepcopy(metadata)
+ cached_metadata['_source'] = source
+ self._metadata[node_type] = cached_metadata
+ self._metadata_sources[node_type] = source
+ self._add_to_category(metadata.get('category', 'misc'), node_type)
+ return True
+
+ def register(self, node_type: str, node_class: type[WorkflowNode]):
+ """Register a Python workflow node implementation class."""
+ canonical_type = self._canonical_type_for_class(node_type, node_class)
+ self._nodes[canonical_type] = node_class
+
+ metadata = self.get_metadata(canonical_type)
+ if metadata:
+ category = metadata.get('category', getattr(node_class, 'category', 'misc'))
+ else:
+ category = getattr(node_class, 'category', 'misc')
+ logger.warning('Workflow node implementation %s has no YAML metadata', canonical_type)
+
+ self._add_to_category(category, canonical_type)
+
+ def unregister(self, node_type: str):
+ """Unregister a Python workflow node implementation."""
+ canonical_type = self._resolve_registered_node_key(node_type)
+ if canonical_type is None:
+ return
+
+ node_class = self._nodes[canonical_type]
+ metadata = self.get_metadata(canonical_type)
+ category = metadata.get('category') if metadata else getattr(node_class, 'category', 'misc')
+ self._remove_from_category(category or 'misc', canonical_type)
+ del self._nodes[canonical_type]
+
+ def unregister_metadata(self, node_type: str):
+ """Unregister YAML metadata for a node type, primarily for plugin unload."""
+ canonical_type = self._resolve_metadata_key(node_type)
+ if canonical_type is None:
+ return
+
+ metadata = self._metadata[canonical_type]
+ self._remove_from_category(metadata.get('category', 'misc'), canonical_type)
+ del self._metadata[canonical_type]
+ self._metadata_sources.pop(canonical_type, None)
+
+ def get(self, node_type: str) -> Optional[type[WorkflowNode]]:
+ """Get node class by type. Supports both ``category.name`` and short names."""
+ canonical_type = self._resolve_registered_node_key(node_type)
+ if canonical_type:
+ return self._nodes[canonical_type]
+
+ if get_pending_registrations():
+ self.process_pending_registrations()
+ canonical_type = self._resolve_registered_node_key(node_type)
+ if canonical_type:
+ return self._nodes[canonical_type]
+
+ return None
+
+ def get_metadata(self, node_type: str) -> Optional[dict[str, Any]]:
+ """Get YAML metadata by full type or short node name."""
+ canonical_type = self._resolve_metadata_key(node_type)
+ if canonical_type:
+ return copy.deepcopy(self._metadata[canonical_type])
+ return None
+
+ def create_instance(
+ self, node_type: str, node_id: str, config: dict[str, Any], ap: Optional['app.Application'] = None
+ ) -> Optional[WorkflowNode]:
+ """Create a node instance. Supports both ``category.name`` and short names."""
+ node_class = self.get(node_type)
+ if node_class:
+ return node_class(node_id, config, ap=ap)
+ logger.warning('No workflow node implementation registered for type: %s', node_type)
+ return None
+
+ def get_merged_schema(self, node_type: str) -> Optional[dict[str, Any]]:
+ """Get frontend schema from YAML metadata.
+
+ Python node classes no longer carry UI metadata. If a node class is
+ registered but has no YAML metadata, a minimal schema is generated
+ from the class attributes (category, type_name) so it still appears
+ in the editor.
+ """
+ metadata = self.get_metadata(node_type)
+ node_class = self.get(node_type)
+
+ if metadata:
+ schema = self._metadata_to_schema(metadata)
+ if node_class:
+ # Supplement pipeline config reuse fields from Python class
+ for key in ('config_schema_source', 'config_stages'):
+ if not schema.get(key) and getattr(node_class, key, None):
+ schema[key] = getattr(node_class, key)
+ return schema
+
+ if node_class:
+ # Fallback: node has Python class but no YAML metadata
+ short_name = getattr(node_class, 'type_name', '') or node_type.split('.')[-1]
+ category = getattr(node_class, 'category', 'misc')
+ return {
+ 'type': f'{category}.{short_name}',
+ 'name': short_name,
+ 'label': self._normalize_i18n(None, self._prettify_name(short_name)),
+ 'description': self._normalize_i18n(None, ''),
+ 'category': category,
+ 'icon': '',
+ 'color': '',
+ 'inputs': [],
+ 'outputs': [],
+ 'config_schema': [],
+ 'config_schema_source': getattr(node_class, 'config_schema_source', None),
+ 'config_stages': getattr(node_class, 'config_stages', []),
+ 'source': 'python-only',
+ }
+
+ return None
+
+ def list_all(self) -> list[dict[str, Any]]:
+ """Get all registered node type schemas, including metadata-only nodes."""
+ node_types = self._ordered_node_types(set(self._metadata.keys()) | set(self._nodes.keys()))
+ return [schema for node_type in node_types if (schema := self.get_merged_schema(node_type)) is not None]
+
+ def list_by_category(self, category: str) -> list[dict[str, Any]]:
+ """Get node type schemas by category."""
+ if category not in self._categories:
+ return []
+ return [schema for node_type in self._categories[category] if (schema := self.get_merged_schema(node_type)) is not None]
+
+ def get_categories(self) -> dict[str, list[dict[str, Any]]]:
+ """Get all nodes organized by category."""
+ return {category: self.list_by_category(category) for category in self._categories.keys()}
+
+ def has_type(self, node_type: str) -> bool:
+ """Check whether a node has metadata or an implementation registered."""
+ return self.get_metadata(node_type) is not None or self.get(node_type) is not None
+
+ def process_pending_registrations(self):
+ """Process all pending node registrations from decorators."""
+ for node_type, node_class in get_pending_registrations():
+ self.register(node_type, node_class)
+ clear_pending_registrations()
+
+ def count(self) -> int:
+ """Get total number of node types exposed by metadata or implementation."""
+ return len(set(self._metadata.keys()) | set(self._nodes.keys()))
+
+ def metadata_count(self) -> int:
+ """Get number of registered YAML metadata definitions."""
+ return len(self._metadata)
+
+ def get_conflicts(self) -> list[dict[str, str]]:
+ """Return metadata registration conflicts."""
+ return copy.deepcopy(self._conflicts)
+
+ def clear(self):
+ """Clear all registrations (for testing)."""
+ self._nodes.clear()
+ self._metadata.clear()
+ self._metadata_sources.clear()
+ self._conflicts.clear()
+ for category in self._categories:
+ self._categories[category] = []
+
+ def _canonical_type_for_class(self, node_type: str, node_class: type[WorkflowNode]) -> str:
+ short_name = node_type.split('.')[-1]
+ metadata_key = self._resolve_metadata_key(node_type) or self._resolve_metadata_key(short_name)
+ if metadata_key:
+ return metadata_key
+
+ category = getattr(node_class, 'category', 'misc')
+ return node_type if '.' in node_type else f'{category}.{short_name}'
+
+ def _resolve_registered_node_key(self, node_type: str) -> Optional[str]:
+ if node_type in self._nodes:
+ return node_type
+
+ short_name = node_type.split('.')[-1]
+ for registered_type, node_class in self._nodes.items():
+ if registered_type.split('.')[-1] == short_name or getattr(node_class, 'type_name', None) == short_name:
+ return registered_type
+
+ return None
+
+ def _resolve_metadata_key(self, node_type: str) -> Optional[str]:
+ if node_type in self._metadata:
+ return node_type
+
+ short_name = node_type.split('.')[-1]
+ for registered_type, metadata in self._metadata.items():
+ if registered_type.split('.')[-1] == short_name or metadata.get('name') == short_name:
+ return registered_type
+
+ return None
+
+ def _ordered_node_types(self, node_types: set[str]) -> list[str]:
+ ordered: list[str] = []
+ for category in self._categories:
+ for node_type in self._categories[category]:
+ if node_type in node_types and node_type not in ordered:
+ ordered.append(node_type)
+
+ for node_type in sorted(node_types):
+ if node_type not in ordered:
+ ordered.append(node_type)
+
+ return ordered
+
+ def _add_to_category(self, category: str, node_type: str) -> None:
+ if category not in self._categories:
+ self._categories[category] = []
+ if node_type not in self._categories[category]:
+ self._categories[category].append(node_type)
+
+ def _remove_from_category(self, category: str, node_type: str) -> None:
+ if category in self._categories and node_type in self._categories[category]:
+ self._categories[category].remove(node_type)
+
+ def _metadata_to_schema(self, metadata: dict[str, Any]) -> dict[str, Any]:
+ node_type = build_node_type(metadata)
+ node_name = metadata.get('name', node_type.split('.')[-1])
+ return {
+ 'type': node_type,
+ 'name': node_name,
+ 'label': self._normalize_i18n(metadata.get('label'), self._prettify_name(node_name)),
+ 'description': self._normalize_i18n(metadata.get('description'), ''),
+ 'category': metadata.get('category', 'misc'),
+ 'icon': metadata.get('icon', ''),
+ 'color': metadata.get('color', ''),
+ 'inputs': [self._normalize_port_item(item) for item in metadata.get('inputs', [])],
+ 'outputs': [self._normalize_port_item(item) for item in metadata.get('outputs', [])],
+ 'config_schema': [self._normalize_config_item(item) for item in metadata.get('config', [])],
+ 'config_schema_source': metadata.get('config_schema_source'),
+ 'config_stages': metadata.get('config_stages', []),
+ 'source': metadata.get('_source', 'core'),
+ }
+
+ def _merge_missing_schema_fields(self, yaml_schema: dict[str, Any], python_schema: dict[str, Any]) -> dict[str, Any]:
+ result = copy.deepcopy(yaml_schema)
+ for key in ('config_schema_source', 'config_stages'):
+ if not result.get(key) and python_schema.get(key):
+ result[key] = python_schema[key]
+ return result
+
+ def _normalize_port_item(self, port: dict[str, Any]) -> dict[str, Any]:
+ item = copy.deepcopy(port)
+ name = item.get('name', '')
+ item['label'] = self._normalize_i18n(item.get('label'), self._prettify_name(name))
+ item['description'] = self._normalize_i18n(item.get('description'), '')
+ item.setdefault('type', 'any')
+ item.setdefault('required', True)
+ return item
+
+ def _normalize_config_item(self, config: dict[str, Any]) -> dict[str, Any]:
+ item = copy.deepcopy(config)
+ name = item.get('name', '')
+ frontend_type = self._normalize_config_type(item.get('type', 'string'))
+
+ item['id'] = item.get('id') or name
+ item['type'] = frontend_type
+ item['label'] = self._normalize_i18n(item.get('label'), self._prettify_name(name))
+ item['description'] = self._normalize_i18n(item.get('description'), '')
+ item['required'] = bool(item.get('required', False))
+ item['default'] = item.get('default', self._default_value_for_type(frontend_type))
+
+ if 'options' in item:
+ item['options'] = self._normalize_options(item.get('options'), name)
+
+ return item
+
+ def _normalize_options(self, options: Any, field_name: str) -> list[dict[str, Any]]:
+ if not isinstance(options, list):
+ return []
+
+ normalized: list[dict[str, Any]] = []
+ for option in options:
+ if isinstance(option, dict):
+ option_item = copy.deepcopy(option)
+ option_name = option_item.get('name', option_item.get('value', ''))
+ option_item['name'] = str(option_name)
+ option_item['label'] = self._normalize_i18n(option_item.get('label'), str(option_name))
+ normalized.append(option_item)
+ else:
+ option_name = str(option)
+ normalized.append({'name': option_name, 'label': self._normalize_i18n(None, option_name)})
+
+ return normalized
+
+ def _normalize_i18n(self, value: Any, fallback: str) -> dict[str, str]:
+ if isinstance(value, dict):
+ en_value = (
+ value.get('en_US')
+ or value.get('en-US')
+ or value.get('en')
+ or value.get('en_US'.replace('_', '-'))
+ or fallback
+ )
+ zh_value = value.get('zh_Hans') or value.get('zh-Hans') or value.get('zh-CN') or value.get('zh') or en_value
+ return {
+ 'en_US': str(en_value),
+ 'en': str(en_value),
+ 'en-US': str(en_value),
+ 'zh_Hans': str(zh_value),
+ 'zh-Hans': str(zh_value),
+ 'zh-CN': str(zh_value),
+ }
+
+ if isinstance(value, str) and value:
+ return {
+ 'en_US': value,
+ 'en': value,
+ 'en-US': value,
+ 'zh_Hans': value,
+ 'zh-Hans': value,
+ 'zh-CN': value,
+ }
+
+ return {
+ 'en_US': fallback,
+ 'en': fallback,
+ 'en-US': fallback,
+ 'zh_Hans': fallback,
+ 'zh-Hans': fallback,
+ 'zh-CN': fallback,
+ }
+
+ def _normalize_config_type(self, field_type: str) -> str:
+ type_map = {
+ 'number': 'float',
+ 'json': 'text',
+ 'textarea': 'text',
+ }
+ return type_map.get(field_type, field_type)
+
+ def _default_value_for_type(self, field_type: str) -> Any:
+ if field_type == 'boolean':
+ return False
+ if field_type in {'integer', 'float'}:
+ return 0
+ if field_type in {'array[string]', 'knowledge-base-multi-selector', 'tools-selector'}:
+ return []
+ if field_type == 'model-fallback-selector':
+ return {'primary': '', 'fallbacks': []}
+ if field_type == 'prompt-editor':
+ return [{'role': 'system', 'content': ''}]
+ return ''
+
+ def _prettify_name(self, name: str) -> str:
+ return ' '.join(part.capitalize() for part in str(name).replace('-', '_').split('_') if part)
+
+
+# Convenience functions for module-level access
+def register_node(node_type: str, node_class: type[WorkflowNode]):
+ """Register a node type to the global registry."""
+ NodeTypeRegistry.instance().register(node_type, node_class)
+
+
+def get_node_class(node_type: str) -> Optional[type[WorkflowNode]]:
+ """Get a node class from the global registry."""
+ return NodeTypeRegistry.instance().get(node_type)
+
+
+def list_node_types() -> list[dict[str, Any]]:
+ """List all registered node types."""
+ return NodeTypeRegistry.instance().list_all()
diff --git a/src/langbot/pkg/workflow/safe_eval.py b/src/langbot/pkg/workflow/safe_eval.py
new file mode 100644
index 000000000..f732e9418
--- /dev/null
+++ b/src/langbot/pkg/workflow/safe_eval.py
@@ -0,0 +1,147 @@
+"""Safe expression evaluator for workflow nodes.
+
+Uses Python's ``ast`` module to whitelist only comparison, boolean, arithmetic,
+and simple attribute / subscript access. No function calls, imports, or
+arbitrary code execution.
+
+The public API is :func:`safe_eval_with_vars` which accepts a mapping of
+allowed variable names so that expressions like ``input == "hello"`` or
+``data.x > 3`` work without resorting to :func:`eval`.
+"""
+
+from __future__ import annotations
+
+import ast
+import operator
+from typing import Any
+
+
+_SAFE_OPS = {
+ # Arithmetic
+ ast.Add: operator.add,
+ ast.Sub: operator.sub,
+ ast.Mult: operator.mul,
+ ast.Div: operator.truediv,
+ ast.FloorDiv: operator.floordiv,
+ ast.Mod: operator.mod,
+ ast.Pow: operator.pow,
+ # Unary
+ ast.USub: operator.neg,
+ ast.UAdd: operator.pos,
+ ast.Not: operator.not_,
+ # Comparison
+ ast.Eq: operator.eq,
+ ast.NotEq: operator.ne,
+ ast.Lt: operator.lt,
+ ast.LtE: operator.le,
+ ast.Gt: operator.gt,
+ ast.GtE: operator.ge,
+ ast.Is: operator.is_,
+ ast.IsNot: operator.is_not,
+ ast.In: lambda a, b: a in b,
+ ast.NotIn: lambda a, b: a not in b,
+}
+
+
+def safe_eval_with_vars(expr: str, variables: dict[str, Any] | None = None) -> Any:
+ """Evaluate an expression safely with an optional variable mapping.
+
+ Supports:
+ - Literals (numbers, strings, booleans, None)
+ - Comparisons (==, !=, <, >, <=, >=, in, not in, is, is not)
+ - Boolean logic (and, or, not)
+ - Arithmetic (+, -, *, /, //, %, **)
+ - Ternary (x if cond else y)
+ - Variable references from *variables* dict (e.g. ``input``, ``data``)
+ - Attribute access on known variables (e.g. ``data.name``)
+ - Subscript access on known variables (e.g. ``data["key"]``, ``items[0]``)
+
+ Raises :class:`ValueError` on any disallowed construct (function calls,
+ starred expressions, walrus operator, etc.).
+ """
+ variables = variables or {}
+ tree = ast.parse(expr.strip(), mode='eval')
+ return _eval_node(tree.body, variables)
+
+
+def _eval_node(node: ast.AST, variables: dict[str, Any]) -> Any:
+ # Literals
+ if isinstance(node, ast.Constant):
+ return node.value
+
+ # Variable references
+ if isinstance(node, ast.Name):
+ if node.id in ('None', 'True', 'False'):
+ return {'None': None, 'True': True, 'False': False}[node.id]
+ if node.id in variables:
+ return variables[node.id]
+ raise ValueError(f'Unsupported variable reference: {node.id}')
+
+ # Attribute access: obj.attr (only on allowed variables)
+ if isinstance(node, ast.Attribute):
+ obj = _eval_node(node.value, variables)
+ attr = node.attr
+ if isinstance(obj, dict):
+ return obj.get(attr)
+ if hasattr(obj, attr):
+ return getattr(obj, attr)
+ return None
+
+ # Subscript access: obj[key] (only on allowed variables)
+ if isinstance(node, ast.Subscript):
+ obj = _eval_node(node.value, variables)
+ key = _eval_node(node.slice, variables)
+ try:
+ return obj[key]
+ except (KeyError, IndexError, TypeError):
+ return None
+
+ # Unary operators
+ if isinstance(node, ast.UnaryOp):
+ op_fn = _SAFE_OPS.get(type(node.op))
+ if op_fn is None:
+ raise ValueError(f'Unsupported unary op: {type(node.op).__name__}')
+ return op_fn(_eval_node(node.operand, variables))
+
+ # Binary operators
+ if isinstance(node, ast.BinOp):
+ op_fn = _SAFE_OPS.get(type(node.op))
+ if op_fn is None:
+ raise ValueError(f'Unsupported binary op: {type(node.op).__name__}')
+ return op_fn(_eval_node(node.left, variables), _eval_node(node.right, variables))
+
+ # Comparisons (chained)
+ if isinstance(node, ast.Compare):
+ left = _eval_node(node.left, variables)
+ for op, comparator in zip(node.ops, node.comparators):
+ op_fn = _SAFE_OPS.get(type(op))
+ if op_fn is None:
+ raise ValueError(f'Unsupported comparison: {type(op).__name__}')
+ right = _eval_node(comparator, variables)
+ if not op_fn(left, right):
+ return False
+ left = right
+ return True
+
+ # Boolean operators
+ if isinstance(node, ast.BoolOp):
+ if isinstance(node.op, ast.And):
+ return all(_eval_node(v, variables) for v in node.values)
+ if isinstance(node.op, ast.Or):
+ return any(_eval_node(v, variables) for v in node.values)
+
+ # Ternary
+ if isinstance(node, ast.IfExp):
+ return (
+ _eval_node(node.body, variables) if _eval_node(node.test, variables) else _eval_node(node.orelse, variables)
+ )
+
+ # Tuples / Lists (e.g. ``x in [1, 2, 3]``)
+ if isinstance(node, (ast.Tuple, ast.List)):
+ return [_eval_node(e, variables) for e in node.elts]
+
+ # Dict literals (e.g. ``{"a": 1}``)
+ if isinstance(node, ast.Dict):
+ return {_eval_node(k, variables): _eval_node(v, variables) for k, v in zip(node.keys, node.values)}
+
+ raise ValueError(f'Unsupported expression node: {type(node).__name__}')
diff --git a/src/langbot/pkg/workflow/variable_manager.py b/src/langbot/pkg/workflow/variable_manager.py
new file mode 100644
index 000000000..b005ccf3e
--- /dev/null
+++ b/src/langbot/pkg/workflow/variable_manager.py
@@ -0,0 +1,185 @@
+"""Variable management for workflow execution.
+
+This module provides utilities for managing workflow variables, including:
+- Reserved variable protection
+- Variable namespace validation
+- Variable inheritance between nodes
+"""
+
+from typing import Set, Dict, Any
+
+# 保留变量列表 - 这些变量由系统管理,不能被用户覆盖
+RESERVED_VARIABLES = {
+ 'workflow_id',
+ 'execution_id',
+ 'node_id',
+ 'timestamp',
+ 'launcher_type',
+ 'trigger_type',
+ 'message_id',
+ 'execution_status',
+}
+
+
+def get_reserved_variables() -> Set[str]:
+ """获取保留变量列表
+
+ Returns:
+ 保留变量名称的集合
+ """
+ return RESERVED_VARIABLES.copy()
+
+
+def validate_variable_namespace(
+ variables: Dict[str, Any],
+ namespace: str = 'workflow'
+) -> bool:
+ """验证变量命名空间
+
+ 检查变量是否使用了保留的名称。允许不带前缀的变量,但建议使用命名空间前缀。
+
+ Args:
+ variables: 要验证的变量字典
+ namespace: 命名空间前缀(可选)
+
+ Returns:
+ True 如果验证通过
+
+ Raises:
+ ValueError: 如果变量名称与保留变量冲突
+ """
+ reserved = get_reserved_variables()
+
+ for var_name in variables.keys():
+ if var_name in reserved:
+ raise ValueError(
+ f"Variable '{var_name}' is reserved and cannot be used. "
+ f"Reserved variables: {', '.join(sorted(reserved))}"
+ )
+
+ # 检查命名空间前缀(可选建议)
+ if namespace and not var_name.startswith(f"{namespace}_"):
+ # 允许不带前缀的变量,但建议使用前缀
+ pass
+
+ return True
+
+
+def inherit_variables(
+ parent_variables: Dict[str, Any],
+ child_namespace: str
+) -> Dict[str, Any]:
+ """继承父节点变量到子节点
+
+ 将父节点的变量继承到子节点,跳过保留变量,并添加命名空间前缀。
+
+ Args:
+ parent_variables: 父节点的变量字典
+ child_namespace: 子节点的命名空间前缀
+
+ Returns:
+ 带有命名空间前缀的继承变量字典
+ """
+ inherited = {}
+
+ for key, value in parent_variables.items():
+ # 跳过保留变量
+ if key in RESERVED_VARIABLES:
+ continue
+
+ # 添加命名空间前缀
+ namespaced_key = f"{child_namespace}_{key}"
+ inherited[namespaced_key] = value
+
+ return inherited
+
+
+def merge_variables(
+ base_variables: Dict[str, Any],
+ override_variables: Dict[str, Any],
+ allow_reserved: bool = False
+) -> Dict[str, Any]:
+ """合并变量字典
+
+ 将override_variables合并到base_variables中。
+
+ Args:
+ base_variables: 基础变量字典
+ override_variables: 要合并的变量字典
+ allow_reserved: 是否允许覆盖保留变量(默认False)
+
+ Returns:
+ 合并后的变量字典
+
+ Raises:
+ ValueError: 如果尝试覆盖保留变量且allow_reserved=False
+ """
+ result = base_variables.copy()
+
+ for key, value in override_variables.items():
+ if key in RESERVED_VARIABLES and not allow_reserved:
+ raise ValueError(
+ f"Cannot override reserved variable '{key}'. "
+ f"Set allow_reserved=True to override."
+ )
+ result[key] = value
+
+ return result
+
+
+def extract_namespace_variables(
+ variables: Dict[str, Any],
+ namespace: str
+) -> Dict[str, Any]:
+ """提取特定命名空间的变量
+
+ 从变量字典中提取具有特定命名空间前缀的变量。
+
+ Args:
+ variables: 变量字典
+ namespace: 命名空间前缀
+
+ Returns:
+ 提取的变量字典(不包含命名空间前缀)
+ """
+ prefix = f"{namespace}_"
+ result = {}
+
+ for key, value in variables.items():
+ if key.startswith(prefix):
+ # 移除命名空间前缀
+ clean_key = key[len(prefix):]
+ result[clean_key] = value
+
+ return result
+
+
+def sanitize_variables(
+ variables: Dict[str, Any],
+ allowed_keys: Set[str] | None = None
+) -> Dict[str, Any]:
+ """清理变量字典
+
+ 移除保留变量和不在允许列表中的变量。
+
+ Args:
+ variables: 要清理的变量字典
+ allowed_keys: 允许的键集合(如果为None,则允许所有非保留键)
+
+ Returns:
+ 清理后的变量字典
+ """
+ result = {}
+
+ for key, value in variables.items():
+ # 跳过保留变量
+ if key in RESERVED_VARIABLES:
+ continue
+
+ # 检查允许列表
+ if allowed_keys is not None and key not in allowed_keys:
+ continue
+
+ result[key] = value
+
+ return result
diff --git a/src/langbot/templates/metadata/nodes/call_pipeline.yaml b/src/langbot/templates/metadata/nodes/call_pipeline.yaml
new file mode 100644
index 000000000..b96bffa0b
--- /dev/null
+++ b/src/langbot/templates/metadata/nodes/call_pipeline.yaml
@@ -0,0 +1,79 @@
+# Call Pipeline Node Configuration
+name: call_pipeline
+label:
+ en_US: Call Pipeline
+ zh_Hans: 调用 Pipeline
+category: action
+icon: Workflow
+color: '#ef4444'
+description:
+ en_US: Invoke an existing Pipeline for processing
+ zh_Hans: 调用现有的 Pipeline 进行处理
+
+inputs:
+ - name: query
+ type: string
+ label:
+ en_US: Query
+ zh_Hans: 查询
+ description:
+ en_US: Query to send
+ zh_Hans: 要发送的查询
+ - name: context
+ type: object
+ label:
+ en_US: Context
+ zh_Hans: 上下文
+ description:
+ en_US: Context data
+ zh_Hans: 上下文数据
+ required: false
+
+outputs:
+ - name: response
+ type: string
+ label:
+ en_US: Response
+ zh_Hans: 响应
+ description:
+ en_US: Pipeline response
+ zh_Hans: 流水线响应
+ - name: result
+ type: object
+ label:
+ en_US: Result
+ zh_Hans: 结果
+ description:
+ en_US: Pipeline result
+ zh_Hans: 流水线结果
+
+config:
+ - name: pipeline_uuid
+ type: pipeline-selector
+ required: true
+ label:
+ en_US: Pipeline
+ zh_Hans: 流水线
+ description:
+ en_US: Pipeline to call
+ zh_Hans: 要调用的流水线
+
+ - name: inherit_context
+ type: boolean
+ default: true
+ label:
+ en_US: Inherit Context
+ zh_Hans: 继承上下文
+ description:
+ en_US: Whether to inherit context
+ zh_Hans: 是否继承上下文
+
+ - name: timeout
+ type: integer
+ default: 120
+ label:
+ en_US: Timeout (seconds)
+ zh_Hans: 超时时间(秒)
+ description:
+ en_US: Timeout in seconds
+ zh_Hans: 超时时间(秒)
diff --git a/src/langbot/templates/metadata/nodes/call_workflow.yaml b/src/langbot/templates/metadata/nodes/call_workflow.yaml
new file mode 100644
index 000000000..739bf654f
--- /dev/null
+++ b/src/langbot/templates/metadata/nodes/call_workflow.yaml
@@ -0,0 +1,79 @@
+# Call Workflow Node Configuration
+name: call_workflow
+label:
+ en_US: Call Workflow
+ zh_Hans: 调用 工作流
+category: action
+icon: Workflow
+color: '#8b5cf6'
+description:
+ en_US: Invoke an existing Workflow for processing
+ zh_Hans: 调用现有的工作流进行处理
+
+inputs:
+ - name: variables
+ type: object
+ label:
+ en_US: Variables
+ zh_Hans: 变量
+ description:
+ en_US: Variables to pass to the called workflow
+ zh_Hans: 传递给被调用工作流的变量
+ required: false
+
+outputs:
+ - name: result
+ type: object
+ label:
+ en_US: Result
+ zh_Hans: 结果
+ description:
+ en_US: Workflow execution result (ExecutionContext)
+ zh_Hans: 工作流执行结果(执行上下文)
+ - name: status
+ type: string
+ label:
+ en_US: Status
+ zh_Hans: 状态
+ description:
+ en_US: Workflow execution status
+ zh_Hans: 工作流执行状态
+ - name: error
+ type: string
+ label:
+ en_US: Error
+ zh_Hans: 错误
+ description:
+ en_US: Error message if execution failed
+ zh_Hans: 执行失败时的错误信息
+
+config:
+ - name: workflow_uuid
+ type: workflow-selector
+ required: true
+ label:
+ en_US: Workflow
+ zh_Hans: 工作流
+ description:
+ en_US: Workflow to call
+ zh_Hans: 要调用的工作流
+
+ - name: inherit_variables
+ type: boolean
+ default: true
+ label:
+ en_US: Inherit Variables
+ zh_Hans: 继承变量
+ description:
+ en_US: Whether to inherit current workflow variables
+ zh_Hans: 是否继承当前工作流的变量
+
+ - name: timeout
+ type: integer
+ default: 300
+ label:
+ en_US: Timeout (seconds)
+ zh_Hans: 超时时间(秒)
+ description:
+ en_US: Timeout in seconds
+ zh_Hans: 超时时间(秒)
diff --git a/src/langbot/templates/metadata/nodes/code_executor.yaml b/src/langbot/templates/metadata/nodes/code_executor.yaml
new file mode 100644
index 000000000..9b25f9022
--- /dev/null
+++ b/src/langbot/templates/metadata/nodes/code_executor.yaml
@@ -0,0 +1,78 @@
+# Code Executor Node Configuration
+# This file defines the metadata for the Code Executor workflow node
+# The corresponding Python implementation is in: pkg/workflow/nodes/code_executor.py
+
+name: code_executor
+label:
+ en_US: Code Executor
+ zh_Hans: 代码执行
+category: process
+icon: Code
+color: '#3b82f6'
+description:
+ en_US: Execute custom code to process data
+ zh_Hans: 执行自定义代码处理数据
+
+inputs:
+ - name: input
+ type: any
+ label:
+ en_US: Input
+ zh_Hans: 输入
+ description:
+ en_US: Input data for the code
+ zh_Hans: 代码的输入数据
+
+outputs:
+ - name: output
+ type: any
+ label:
+ en_US: Output
+ zh_Hans: 输出
+ description:
+ en_US: Code execution result
+ zh_Hans: 代码执行结果
+ - name: logs
+ type: array
+ label:
+ en_US: Logs
+ zh_Hans: 日志
+ description:
+ en_US: Console logs from code execution
+ zh_Hans: 代码执行的控制台日志
+
+config:
+ - name: language
+ type: select
+ required: true
+ default: javascript
+ options:
+ - javascript
+ - python
+ label:
+ en_US: Language
+ zh_Hans: 语言
+ description:
+ en_US: Programming language to use
+ zh_Hans: 要使用的编程语言
+
+ - name: code
+ type: textarea
+ required: true
+ default: "return input;"
+ label:
+ en_US: Code
+ zh_Hans: 代码
+ description:
+ en_US: Code to execute
+ zh_Hans: 要执行的代码
+
+ - name: timeout
+ type: integer
+ default: 5000
+ label:
+ en_US: Timeout (ms)
+ zh_Hans: 超时时间 (毫秒)
+ description:
+ en_US: Maximum execution time in milliseconds
+ zh_Hans: 最大执行时间(毫秒)
diff --git a/src/langbot/templates/metadata/nodes/condition.yaml b/src/langbot/templates/metadata/nodes/condition.yaml
new file mode 100644
index 000000000..b13c8ede3
--- /dev/null
+++ b/src/langbot/templates/metadata/nodes/condition.yaml
@@ -0,0 +1,127 @@
+# Condition Node Configuration
+# This file defines the metadata for the Condition workflow node
+# The corresponding Python implementation is in: pkg/workflow/nodes/condition.py
+
+name: condition
+label:
+ en_US: Condition
+ zh_Hans: 条件分支
+category: control
+icon: GitBranch
+color: '#8b5cf6'
+description:
+ en_US: Branch workflow based on a condition
+ zh_Hans: 根据条件分支工作流
+
+inputs:
+ - name: input
+ type: any
+ label:
+ en_US: Input
+ zh_Hans: 输入
+ description:
+ en_US: Input data for condition evaluation
+ zh_Hans: 用于条件评估的输入数据
+
+outputs:
+ - name: "true"
+ type: any
+ label:
+ en_US: True
+ zh_Hans: 真
+ description:
+ en_US: Output when condition is true
+ zh_Hans: 条件为真时的输出
+ - name: "false"
+ type: any
+ label:
+ en_US: False
+ zh_Hans: 假
+ description:
+ en_US: Output when condition is false
+ zh_Hans: 条件为假时的输出
+
+config:
+ - name: condition_type
+ type: select
+ required: true
+ default: expression
+ options:
+ - expression
+ - comparison
+ - exists
+ - type_check
+ label:
+ en_US: Condition Type
+ zh_Hans: 条件类型
+ description:
+ en_US: Type of condition to evaluate
+ zh_Hans: 要评估的条件类型
+
+ - name: expression
+ type: string
+ default: ""
+ label:
+ en_US: Expression
+ zh_Hans: 表达式
+ description:
+ en_US: JavaScript expression that evaluates to true/false
+ zh_Hans: 评估为 true/false 的表达式
+
+ - name: left_value
+ type: string
+ default: "{{input}}"
+ label:
+ en_US: Left Value
+ zh_Hans: 左值
+ description:
+ en_US: Left side of comparison
+ zh_Hans: 比较的左侧
+
+ - name: operator
+ type: select
+ default: eq
+ options:
+ - eq
+ - neq
+ - gt
+ - gte
+ - lt
+ - lte
+ - contains
+ - starts_with
+ - ends_with
+ - matches
+ label:
+ en_US: Operator
+ zh_Hans: 运算符
+ description:
+ en_US: Comparison operator
+ zh_Hans: 比较运算符
+
+ - name: right_value
+ type: string
+ default: ""
+ label:
+ en_US: Right Value
+ zh_Hans: 右值
+ description:
+ en_US: Right side of comparison
+ zh_Hans: 比较的右侧
+
+ - name: expected_type
+ type: select
+ default: string
+ options:
+ - string
+ - number
+ - boolean
+ - object
+ - array
+ - "null"
+ label:
+ en_US: Expected Type
+ zh_Hans: 期望类型
+ description:
+ en_US: The type to check for
+ zh_Hans: 要检查的类型
diff --git a/src/langbot/templates/metadata/nodes/coze_bot.yaml b/src/langbot/templates/metadata/nodes/coze_bot.yaml
new file mode 100644
index 000000000..334e93e72
--- /dev/null
+++ b/src/langbot/templates/metadata/nodes/coze_bot.yaml
@@ -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
diff --git a/src/langbot/templates/metadata/nodes/cron_trigger.yaml b/src/langbot/templates/metadata/nodes/cron_trigger.yaml
new file mode 100644
index 000000000..650f85a4a
--- /dev/null
+++ b/src/langbot/templates/metadata/nodes/cron_trigger.yaml
@@ -0,0 +1,85 @@
+# Cron Trigger Node Configuration
+# This file defines the metadata for the Cron Trigger workflow node
+# The corresponding Python implementation is in: pkg/workflow/nodes/cron_trigger.py
+
+name: cron_trigger
+label:
+ en_US: Scheduled Trigger
+ zh_Hans: 定时触发
+category: trigger
+icon: Timer
+color: '#22c55e'
+description:
+ en_US: Trigger workflow on a scheduled time
+ zh_Hans: 按定时计划触发工作流
+
+inputs: []
+
+outputs:
+ - name: trigger_time
+ type: datetime
+ label:
+ en_US: Trigger Time
+ zh_Hans: 触发时间
+ description:
+ en_US: The time when the trigger fired
+ zh_Hans: 触发器触发的时间
+ - name: context
+ type: object
+ label:
+ en_US: Context
+ zh_Hans: 上下文
+ description:
+ en_US: Trigger context information
+ zh_Hans: 触发上下文信息
+
+config:
+ - name: cron_expression
+ type: string
+ required: true
+ default: "0 9 * * *"
+ label:
+ en_US: Cron Expression
+ zh_Hans: Cron 表达式
+ description:
+ en_US: Standard cron expression
+ zh_Hans: 标准 Cron 表达式
+
+ - name: timezone
+ type: select
+ required: true
+ default: "Asia/Shanghai"
+ options:
+ - UTC
+ - Asia/Shanghai
+ - Asia/Tokyo
+ - America/New_York
+ - America/Los_Angeles
+ - Europe/London
+ - Europe/Berlin
+ label:
+ en_US: Timezone
+ zh_Hans: 时区
+ description:
+ en_US: Timezone for the cron schedule
+ zh_Hans: Cron 计划的时区
+
+ - name: description
+ type: string
+ default: ""
+ label:
+ en_US: Description
+ zh_Hans: 描述
+ description:
+ en_US: Description of this scheduled trigger
+ zh_Hans: 此定时触发器的描述
+
+ - name: enabled
+ type: boolean
+ default: true
+ label:
+ en_US: Enabled
+ zh_Hans: 启用
+ description:
+ en_US: Whether this scheduled trigger is active
+ zh_Hans: 此定时触发器是否激活
diff --git a/src/langbot/templates/metadata/nodes/data_transform.yaml b/src/langbot/templates/metadata/nodes/data_transform.yaml
new file mode 100644
index 000000000..f18881f59
--- /dev/null
+++ b/src/langbot/templates/metadata/nodes/data_transform.yaml
@@ -0,0 +1,72 @@
+# Data Transform Node Configuration
+# This file defines the metadata for the Data Transform workflow node
+# The corresponding Python implementation is in: pkg/workflow/nodes/data_transform.py
+
+name: data_transform
+label:
+ en_US: Data Transform
+ zh_Hans: 数据转换
+category: process
+icon: ArrowRightLeft
+color: '#3b82f6'
+description:
+ en_US: Transform data using templates or JSONPath
+ zh_Hans: 使用模板或 JSONPath 转换数据
+
+inputs:
+ - name: data
+ type: any
+ required: true
+ label:
+ en_US: Data
+ zh_Hans: 数据
+ description:
+ en_US: Input data
+ zh_Hans: 输入数据
+
+outputs:
+ - name: result
+ type: any
+ label:
+ en_US: Result
+ zh_Hans: 结果
+ description:
+ en_US: Transform result
+ zh_Hans: 转换结果
+
+config:
+ - name: transform_type
+ type: select
+ required: true
+ default: template
+ options:
+ - template
+ - jsonpath
+ - jmespath
+ - expression
+ label:
+ en_US: Transform Type
+ zh_Hans: 转换类型
+ description:
+ en_US: Type of transformation to perform
+ zh_Hans: 要执行的转换类型
+
+ - name: template
+ type: textarea
+ default: ""
+ label:
+ en_US: Template
+ zh_Hans: 模板
+ description:
+ en_US: Template with {{variable}} syntax
+ zh_Hans: 支持 {{variable}} 语法的模板
+
+ - name: expression
+ type: string
+ default: ""
+ label:
+ en_US: Expression
+ zh_Hans: 表达式
+ description:
+ en_US: JSONPath/JMESPath expression
+ zh_Hans: JSONPath/JMESPath 表达式
diff --git a/src/langbot/templates/metadata/nodes/database_query.yaml b/src/langbot/templates/metadata/nodes/database_query.yaml
new file mode 100644
index 000000000..3f24d3769
--- /dev/null
+++ b/src/langbot/templates/metadata/nodes/database_query.yaml
@@ -0,0 +1,115 @@
+# Database Query Node Configuration
+# This file defines the metadata for the Database Query workflow node
+# The corresponding Python implementation is in: pkg/workflow/nodes/database_query.py
+
+name: database_query
+label:
+ en_US: Database Query
+ zh_Hans: 数据库查询
+category: integration
+icon: Database
+color: '#ec4899'
+description:
+ en_US: Execute database queries
+ zh_Hans: 执行数据库查询
+
+inputs:
+ - name: parameters
+ type: object
+ required: false
+ label:
+ en_US: Parameters
+ zh_Hans: 参数
+ description:
+ en_US: Query parameters
+ zh_Hans: 查询参数
+
+outputs:
+ - name: results
+ type: array
+ label:
+ en_US: Results
+ zh_Hans: 结果
+ description:
+ en_US: Query results
+ zh_Hans: 查询结果
+ - name: row_count
+ type: number
+ label:
+ en_US: Row Count
+ zh_Hans: 行数
+ description:
+ en_US: Number of rows affected/returned
+ zh_Hans: 影响/返回的行数
+ - name: success
+ type: boolean
+ label:
+ en_US: Success
+ zh_Hans: 成功
+ description:
+ en_US: Whether query was successful
+ zh_Hans: 查询是否成功
+
+config:
+ - name: connection_type
+ type: select
+ required: true
+ default: postgresql
+ options:
+ - postgresql
+ - mysql
+ - sqlite
+ label:
+ en_US: Database Type
+ zh_Hans: 数据库类型
+ description:
+ en_US: Type of database to connect to
+ zh_Hans: 要连接的数据库类型
+
+ - name: connection_string
+ type: string
+ required: true
+ default: ""
+ label:
+ en_US: Connection String
+ zh_Hans: 连接字符串
+ description:
+ en_US: Database connection string
+ zh_Hans: 数据库连接字符串
+
+ - name: query
+ type: textarea
+ required: true
+ default: ""
+ label:
+ en_US: SQL Query
+ zh_Hans: SQL 查询
+ description:
+ en_US: SQL query to execute
+ zh_Hans: 要执行的 SQL 查询
+
+ - name: query_type
+ type: select
+ required: true
+ default: select
+ options:
+ - select
+ - insert
+ - update
+ - delete
+ label:
+ en_US: Query Type
+ zh_Hans: 查询类型
+ description:
+ en_US: Type of query operation
+ zh_Hans: 查询操作的类型
+
+ - name: timeout
+ type: integer
+ default: 30
+ label:
+ en_US: Timeout (seconds)
+ zh_Hans: 超时时间(秒)
+ description:
+ en_US: Query timeout
+ zh_Hans: 查询超时时间
diff --git a/src/langbot/templates/metadata/nodes/dify_knowledge_query.yaml b/src/langbot/templates/metadata/nodes/dify_knowledge_query.yaml
new file mode 100644
index 000000000..94dc0967a
--- /dev/null
+++ b/src/langbot/templates/metadata/nodes/dify_knowledge_query.yaml
@@ -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: ''
diff --git a/src/langbot/templates/metadata/nodes/dify_workflow.yaml b/src/langbot/templates/metadata/nodes/dify_workflow.yaml
new file mode 100644
index 000000000..1786cee2b
--- /dev/null
+++ b/src/langbot/templates/metadata/nodes/dify_workflow.yaml
@@ -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: ''
diff --git a/src/langbot/templates/metadata/nodes/end.yaml b/src/langbot/templates/metadata/nodes/end.yaml
new file mode 100644
index 000000000..05a930e68
--- /dev/null
+++ b/src/langbot/templates/metadata/nodes/end.yaml
@@ -0,0 +1,53 @@
+# End Node Configuration
+# This file defines the metadata for the End workflow node
+# The corresponding Python implementation is in: pkg/workflow/nodes/end.py
+
+name: end
+label:
+ en_US: End
+ zh_Hans: 结束
+category: control
+icon: PauseCircle
+color: '#8b5cf6'
+description:
+ en_US: End the workflow execution
+ zh_Hans: 结束工作流执行
+
+inputs:
+ - name: input
+ type: any
+ required: false
+ label:
+ en_US: Input
+ zh_Hans: 输入
+ description:
+ en_US: Final output data
+ zh_Hans: 最终输出数据
+
+outputs: []
+
+config:
+ - name: status
+ type: select
+ required: true
+ default: success
+ options:
+ - success
+ - failed
+ - cancelled
+ label:
+ en_US: End Status
+ zh_Hans: 结束状态
+ description:
+ en_US: Status to report when workflow ends
+ zh_Hans: 工作流结束时报告的状态
+
+ - name: message
+ type: string
+ default: ""
+ label:
+ en_US: Message
+ zh_Hans: 消息
+ description:
+ en_US: Optional message to include with the end status
+ zh_Hans: 与结束状态一起包含的可选消息
diff --git a/src/langbot/templates/metadata/nodes/event_trigger.yaml b/src/langbot/templates/metadata/nodes/event_trigger.yaml
new file mode 100644
index 000000000..d294af3f1
--- /dev/null
+++ b/src/langbot/templates/metadata/nodes/event_trigger.yaml
@@ -0,0 +1,89 @@
+# Event Trigger Node Configuration
+# This file defines the metadata for the Event Trigger workflow node
+# The corresponding Python implementation is in: pkg/workflow/nodes/event_trigger.py
+
+name: event_trigger
+label:
+ en_US: Event Trigger
+ zh_Hans: 事件触发
+category: trigger
+icon: Bell
+color: '#22c55e'
+description:
+ en_US: Trigger workflow when a system event occurs
+ zh_Hans: 当系统事件发生时触发工作流
+
+inputs: []
+
+outputs:
+ - name: event
+ type: object
+ label:
+ en_US: Event
+ zh_Hans: 事件
+ description:
+ en_US: The event data
+ zh_Hans: 事件数据
+ - name: event_type
+ type: string
+ label:
+ en_US: Event Type
+ zh_Hans: 事件类型
+ description:
+ en_US: Type of the event
+ zh_Hans: 事件类型
+ - name: context
+ type: object
+ label:
+ en_US: Context
+ zh_Hans: 上下文
+ description:
+ en_US: Event context information
+ zh_Hans: 事件上下文信息
+
+config:
+ - name: event_type
+ type: select
+ required: true
+ default: member_join
+ options:
+ - member_join
+ - member_leave
+ - message_recall
+ - group_created
+ - group_disbanded
+ - bot_added
+ - bot_removed
+ - friend_request
+ - group_request
+ label:
+ en_US: Event Type
+ zh_Hans: 事件类型
+ description:
+ en_US: The type of system event to listen for
+ zh_Hans: 要监听的系统事件类型
+
+ - name: source_filter
+ type: select
+ required: true
+ default: all
+ options:
+ - all
+ - group
+ - private
+ label:
+ en_US: Source Filter
+ zh_Hans: 来源筛选
+ description:
+ en_US: Filter events by source
+ zh_Hans: 按来源筛选事件
+
+ - name: platforms
+ type: json
+ default: []
+ label:
+ en_US: Platform Filter
+ zh_Hans: 平台筛选
+ description:
+ en_US: Only trigger for events from these platforms
+ zh_Hans: 仅对来自这些平台的事件触发
diff --git a/src/langbot/templates/metadata/nodes/http_request.yaml b/src/langbot/templates/metadata/nodes/http_request.yaml
new file mode 100644
index 000000000..5ba8b910a
--- /dev/null
+++ b/src/langbot/templates/metadata/nodes/http_request.yaml
@@ -0,0 +1,162 @@
+# HTTP Request Node Configuration
+# This file defines the metadata for the HTTP Request workflow node
+# The corresponding Python implementation is in: pkg/workflow/nodes/http_request.py
+
+name: http_request
+label:
+ en_US: HTTP Request
+ zh_Hans: HTTP 请求
+category: action
+icon: Globe
+color: '#10b981'
+description:
+ en_US: Make HTTP requests to external APIs
+ zh_Hans: 向外部 API 发送 HTTP 请求
+
+inputs:
+ - name: body
+ type: any
+ required: false
+ label:
+ en_US: Body
+ zh_Hans: 请求体
+ description:
+ en_US: Request body data
+ zh_Hans: 请求体数据
+ - name: variables
+ type: object
+ required: false
+ label:
+ en_US: Variables
+ zh_Hans: 变量
+ description:
+ en_US: Variables for URL/header templates
+ zh_Hans: URL/请求头模板的变量
+
+outputs:
+ - name: response
+ type: any
+ label:
+ en_US: Response
+ zh_Hans: 响应
+ description:
+ en_US: Response body
+ zh_Hans: 响应体
+ - name: status_code
+ type: number
+ label:
+ en_US: Status Code
+ zh_Hans: 状态码
+ description:
+ en_US: HTTP status code
+ zh_Hans: HTTP 状态码
+ - name: headers
+ type: object
+ label:
+ en_US: Headers
+ zh_Hans: 响应头
+ description:
+ en_US: Response headers
+ zh_Hans: 响应头
+ - name: success
+ type: boolean
+ label:
+ en_US: Success
+ zh_Hans: 成功
+ description:
+ en_US: Whether request was successful
+ zh_Hans: 请求是否成功
+
+config:
+ - name: method
+ type: select
+ required: true
+ default: GET
+ options:
+ - GET
+ - POST
+ - PUT
+ - PATCH
+ - DELETE
+ label:
+ en_US: Method
+ zh_Hans: 方法
+ description:
+ en_US: HTTP method
+ zh_Hans: HTTP 方法
+
+ - name: url
+ type: string
+ required: true
+ default: ""
+ label:
+ en_US: URL
+ zh_Hans: URL
+ description:
+ en_US: Request URL
+ zh_Hans: 请求 URL
+
+ - name: headers
+ type: json
+ default: "{}"
+ label:
+ en_US: Headers
+ zh_Hans: 请求头
+ description:
+ en_US: Request headers as JSON
+ zh_Hans: 请求头(JSON 格式)
+
+ - name: body_type
+ type: select
+ default: json
+ options:
+ - none
+ - json
+ - form
+ - raw
+ label:
+ en_US: Body Type
+ zh_Hans: 请求体类型
+ description:
+ en_US: Type of request body
+ zh_Hans: 请求体的类型
+
+ - name: body_template
+ type: textarea
+ default: ""
+ label:
+ en_US: Body Template
+ zh_Hans: 请求体模板
+ description:
+ en_US: Request body template
+ zh_Hans: 请求体模板
+
+ - name: timeout
+ type: integer
+ default: 30
+ label:
+ en_US: Timeout (seconds)
+ zh_Hans: 超时时间(秒)
+ description:
+ en_US: Request timeout in seconds
+ zh_Hans: 请求超时时间(秒)
+
+ - name: retry_count
+ type: integer
+ default: 0
+ label:
+ en_US: Retry Count
+ zh_Hans: 重试次数
+ description:
+ en_US: Number of retries on failure
+ zh_Hans: 失败时的重试次数
+
+ - name: ignore_ssl
+ type: boolean
+ default: false
+ label:
+ en_US: Ignore SSL Errors
+ zh_Hans: 忽略 SSL 错误
+ description:
+ en_US: Ignore SSL certificate verification errors
+ zh_Hans: 忽略 SSL 证书验证错误
diff --git a/src/langbot/templates/metadata/nodes/iterator.yaml b/src/langbot/templates/metadata/nodes/iterator.yaml
new file mode 100644
index 000000000..b92d07b95
--- /dev/null
+++ b/src/langbot/templates/metadata/nodes/iterator.yaml
@@ -0,0 +1,80 @@
+# Iterator Node Configuration
+name: iterator
+label:
+ en_US: Iterator
+ zh_Hans: 迭代器
+category: control
+icon: Repeat
+color: '#f59e0b'
+description:
+ en_US: Iterate over array elements one by one
+ zh_Hans: 逐个遍历数组元素
+
+inputs:
+ - name: array
+ type: array
+ label:
+ en_US: Array
+ zh_Hans: 数组
+ description:
+ en_US: Array to iterate
+ zh_Hans: 要迭代的数组
+
+outputs:
+ - name: item
+ type: any
+ label:
+ en_US: Item
+ zh_Hans: 项目
+ description:
+ en_US: Current item
+ zh_Hans: 当前项目
+ - name: index
+ type: integer
+ label:
+ en_US: Index
+ zh_Hans: 索引
+ description:
+ en_US: Current index
+ zh_Hans: 当前索引
+ - name: is_first
+ type: boolean
+ label:
+ en_US: Is First
+ zh_Hans: 是否第一个
+ description:
+ en_US: Whether this is the first item
+ zh_Hans: 是否是第一个项目
+ - name: is_last
+ type: boolean
+ label:
+ en_US: Is Last
+ zh_Hans: 是否最后一个
+ description:
+ en_US: Whether this is the last item
+ zh_Hans: 是否是最后一个项目
+
+config:
+ - name: parallel
+ type: boolean
+ default: false
+ label:
+ en_US: Parallel
+ zh_Hans: 并行
+ description:
+ en_US: Whether to process in parallel
+ zh_Hans: 是否并行处理
+
+ - name: max_concurrency
+ type: integer
+ default: 5
+ min_value: 1
+ max_value: 100
+ label:
+ en_US: Max Concurrency
+ zh_Hans: 最大并发数
+ description:
+ en_US: Maximum concurrent tasks
+ zh_Hans: 最大并发任务数
+ show_if:
+ parallel: true
diff --git a/src/langbot/templates/metadata/nodes/knowledge_retrieval.yaml b/src/langbot/templates/metadata/nodes/knowledge_retrieval.yaml
new file mode 100644
index 000000000..41b188c0c
--- /dev/null
+++ b/src/langbot/templates/metadata/nodes/knowledge_retrieval.yaml
@@ -0,0 +1,118 @@
+# Knowledge Retrieval Node Configuration
+# This file defines the metadata for the Knowledge Retrieval workflow node
+# The corresponding Python implementation is in: pkg/workflow/nodes/knowledge_retrieval.py
+
+name: knowledge_retrieval
+label:
+ en_US: Knowledge Retrieval
+ zh_Hans: 知识库检索
+category: process
+icon: Search
+color: '#8b5cf6'
+description:
+ en_US: Retrieve relevant information from knowledge bases
+ zh_Hans: 从知识库中检索相关信息
+
+inputs:
+ - name: query
+ type: string
+ label:
+ en_US: Query
+ zh_Hans: 查询
+ description:
+ en_US: Query text to search for
+ zh_Hans: 要搜索的查询文本
+
+outputs:
+ - name: results
+ type: array
+ label:
+ en_US: Results
+ zh_Hans: 结果
+ description:
+ en_US: Retrieved documents/chunks
+ zh_Hans: 检索到的文档/块
+ - name: context
+ type: string
+ label:
+ en_US: Context
+ zh_Hans: 上下文
+ description:
+ en_US: Concatenated text from all results
+ zh_Hans: 所有结果的连接文本
+ - name: scores
+ type: array
+ label:
+ en_US: Scores
+ zh_Hans: 分数
+ description:
+ en_US: Similarity scores for each result
+ zh_Hans: 每个结果的相似度分数
+
+config:
+ - name: knowledge_bases
+ type: knowledge-base-multi-selector
+ required: true
+ default: []
+ label:
+ en_US: Knowledge Bases
+ zh_Hans: 知识库
+ description:
+ en_US: Select knowledge bases to search
+ zh_Hans: 选择要搜索的知识库
+
+ - name: top_k
+ type: integer
+ default: 5
+ label:
+ en_US: Top K Results
+ zh_Hans: 返回数量 (Top K)
+ description:
+ en_US: Number of top results to retrieve
+ zh_Hans: 返回的最相关结果数量
+
+ - name: similarity_threshold
+ type: number
+ default: 0.5
+ label:
+ en_US: Similarity Threshold
+ zh_Hans: 相似度阈值
+ description:
+ en_US: Minimum similarity score for results
+ zh_Hans: 结果的最小相似度分数
+
+ - name: retrieval_mode
+ type: select
+ default: vector
+ options:
+ - vector
+ - hybrid
+ - keyword
+ label:
+ en_US: Retrieval Mode
+ zh_Hans: 检索模式
+ description:
+ en_US: Method used for retrieving documents
+ zh_Hans: 用于检索文档的方法
+
+ - name: rerank_enabled
+ type: boolean
+ default: false
+ label:
+ en_US: Enable Reranking
+ zh_Hans: 启用重排序
+ description:
+ en_US: Use a reranking model to improve result relevance
+ zh_Hans: 使用重排序模型提高结果相关性
+
+ - name: rerank_model
+ type: rerank-model-selector
+ default: ""
+ label:
+ en_US: Rerank Model
+ zh_Hans: 重排序模型
+ description:
+ en_US: Model to use for reranking results
+ zh_Hans: 用于结果重排序的模型
+ show_if:
+ rerank_enabled: true
diff --git a/src/langbot/templates/metadata/nodes/langflow_flow.yaml b/src/langbot/templates/metadata/nodes/langflow_flow.yaml
new file mode 100644
index 000000000..f29222b8f
--- /dev/null
+++ b/src/langbot/templates/metadata/nodes/langflow_flow.yaml
@@ -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: '{}'
diff --git a/src/langbot/templates/metadata/nodes/llm_call.yaml b/src/langbot/templates/metadata/nodes/llm_call.yaml
new file mode 100644
index 000000000..06872f0bd
--- /dev/null
+++ b/src/langbot/templates/metadata/nodes/llm_call.yaml
@@ -0,0 +1,238 @@
+# LLM Call Node Configuration
+# This file defines the metadata for the LLM Call workflow node
+# The corresponding Python implementation is in: pkg/workflow/nodes/llm_call.py
+
+name: llm_call
+label:
+ en_US: LLM Call
+ zh_Hans: LLM 调用
+category: process
+icon: Brain
+color: '#8b5cf6'
+description:
+ en_US: Call a large language model to generate responses
+ zh_Hans: 调用大语言模型生成响应
+
+inputs:
+ - name: input
+ type: string
+ required: false
+ label:
+ en_US: Input
+ zh_Hans: 输入
+ description:
+ en_US: Input text to send to the model
+ zh_Hans: 发送到模型的输入文本
+ - name: context
+ type: object
+ required: false
+ label:
+ en_US: Context
+ zh_Hans: 上下文
+ description:
+ en_US: Additional context data
+ zh_Hans: 额外的上下文数据
+
+outputs:
+ - name: response
+ type: string
+ label:
+ en_US: Response
+ zh_Hans: 响应
+ description:
+ en_US: Model response text
+ zh_Hans: 模型响应文本
+ - name: usage
+ type: object
+ label:
+ en_US: Usage
+ zh_Hans: 使用量
+ description:
+ en_US: Token usage information
+ zh_Hans: Token 使用量信息
+ - name: parsed
+ type: object
+ label:
+ en_US: Parsed
+ zh_Hans: 解析结果
+ description:
+ en_US: Parsed output (if output format is JSON)
+ zh_Hans: 解析后的输出(如果输出格式为 JSON)
+
+config:
+ - name: model
+ 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: fallback_models
+ type: model-fallback-selector
+ required: false
+ default: []
+ label:
+ en_US: Fallback Models
+ zh_Hans: 备用模型
+ description:
+ en_US: List of fallback models to try if the primary model fails
+ zh_Hans: 主模型失败时尝试的备用模型列表
+
+ - name: prompt
+ label:
+ en_US: Prompt
+ zh_Hans: 提示词
+ description:
+ en_US: Unless you understand the message structure, please only use a single system prompt
+ zh_Hans: 除非您了解消息结构,否则请只使用 system 单提示词
+ type: prompt-editor
+ required: true
+ default:
+ - role: system
+ content: "You are a helpful assistant."
+
+ - name: max_round
+ type: integer
+ required: false
+ default: 10
+ label:
+ en_US: Max Round
+ zh_Hans: 最大回合数
+ description:
+ en_US: The maximum number of previous messages that the agent can remember
+ zh_Hans: 最大前文消息回合数
+
+ - name: knowledge_bases
+ type: knowledge-base-multi-selector
+ required: false
+ default: []
+ label:
+ en_US: Knowledge Bases
+ zh_Hans: 知识库
+ description:
+ en_US: Configure the knowledge bases to use for the agent, if not selected, the agent will directly use the LLM to reply
+ zh_Hans: 配置用于提升回复质量的知识库,若不选择,则直接使用大模型回复
+
+ - name: rerank_model
+ type: rerank-model-selector
+ required: false
+ default: ''
+ label:
+ en_US: Rerank Model
+ zh_Hans: 重排序模型
+ description:
+ en_US: Optional rerank model to improve retrieval quality by re-scoring retrieved chunks
+ zh_Hans: 可选的重排序模型,通过重新评分检索结果来提升检索质量
+
+ - name: rerank_top_k
+ type: integer
+ required: false
+ default: 5
+ label:
+ en_US: Rerank Top K
+ zh_Hans: 重排序保留数量
+ description:
+ en_US: Number of top results to keep after reranking
+ zh_Hans: 重排序后保留的最相关结果数量
+
+ - name: temperature
+ type: number
+ default: 0.7
+ label:
+ en_US: Temperature
+ zh_Hans: 温度
+ description:
+ en_US: Controls randomness in responses
+ zh_Hans: 控制响应的随机性
+
+ - name: max_tokens
+ type: integer
+ default: 0
+ label:
+ en_US: Max Tokens
+ zh_Hans: 最大令牌数
+ description:
+ en_US: Maximum number of tokens to generate
+ zh_Hans: 生成的最大令牌数
+
+ - name: output_format
+ type: select
+ default: text
+ options:
+ - text
+ - json
+ - markdown
+ label:
+ en_US: Output Format
+ zh_Hans: 输出格式
+ description:
+ en_US: Expected format of the model output
+ zh_Hans: 模型输出的预期格式
+
+ - name: json_schema
+ type: textarea
+ default: ""
+ label:
+ en_US: JSON Schema
+ zh_Hans: JSON Schema
+ description:
+ en_US: JSON schema for structured output validation
+ zh_Hans: 用于结构化输出验证的 JSON Schema
+
+ - name: exception_handling
+ type: select
+ required: true
+ default: show-hint
+ options:
+ - name: show-error
+ label:
+ en_US: Show Full Error
+ zh_Hans: 显示完整报错信息
+ - name: show-hint
+ label:
+ en_US: Show Failure Hint
+ zh_Hans: 仅文字提示
+ - name: hide
+ label:
+ en_US: Hide All
+ zh_Hans: 不显示任何异常信息
+ label:
+ en_US: Exception Handling Strategy
+ zh_Hans: 异常处理策略
+ description:
+ en_US: Controls how error messages are displayed to the user when an AI request fails
+ zh_Hans: 控制 AI 请求失败时向用户展示错误信息的方式
+
+ - name: failure_hint
+ type: string
+ required: false
+ default: 'Request failed.'
+ label:
+ en_US: Failure Hint Text
+ zh_Hans: 失败提示文本
+ description:
+ en_US: The text to display when a request fails. Only effective when Exception Handling Strategy is set to "Show Failure Hint"
+ zh_Hans: 请求失败时显示的提示文本,仅在异常处理策略设置为"仅文字提示"时生效
+
+ - name: remove_think
+ type: boolean
+ default: false
+ label:
+ en_US: Remove CoT
+ zh_Hans: 删除思维链
+ description:
+ en_US: 'If enabled, the model thinking content in the response will be automatically removed. Note: When using streaming response, removing CoT may cause the first token to wait for a long time.'
+ zh_Hans: '如果启用,将自动删除大模型回复中的模型思考内容。注意:当您使用流式响应时,删除思维链可能会导致首个 Token 的等待时间过长'
+
+ - name: track_function_calls
+ type: boolean
+ default: false
+ label:
+ en_US: Track Function Calls
+ zh_Hans: 跟踪函数调用
+ description:
+ en_US: If enabled, the Agent will output a hint to the user each time a tool is called
+ zh_Hans: 启用后,Agent 每次调用工具时都会输出一个提示给用户
diff --git a/src/langbot/templates/metadata/nodes/loop.yaml b/src/langbot/templates/metadata/nodes/loop.yaml
new file mode 100644
index 000000000..64d0599db
--- /dev/null
+++ b/src/langbot/templates/metadata/nodes/loop.yaml
@@ -0,0 +1,117 @@
+# Loop Node Configuration
+# This file defines the metadata for the Loop workflow node
+# The corresponding Python implementation is in: pkg/workflow/nodes/loop.py
+
+name: loop
+label:
+ en_US: Loop
+ zh_Hans: 循环
+category: control
+icon: Repeat
+color: '#8b5cf6'
+description:
+ en_US: Iterate over items or repeat until condition
+ zh_Hans: 遍历项目或重复直到满足条件
+
+inputs:
+ - name: items
+ type: array
+ required: false
+ label:
+ en_US: Items
+ zh_Hans: 项目
+ description:
+ en_US: Items to iterate over
+ zh_Hans: 要遍历的项目
+
+outputs:
+ - name: item
+ type: any
+ label:
+ en_US: Item
+ zh_Hans: 当前项
+ description:
+ en_US: Current item in iteration
+ zh_Hans: 迭代中的当前项
+ - name: index
+ type: number
+ label:
+ en_US: Index
+ zh_Hans: 索引
+ description:
+ en_US: Current iteration index
+ zh_Hans: 当前迭代索引
+ - name: completed
+ type: any
+ label:
+ en_US: Completed
+ zh_Hans: 完成
+ description:
+ en_US: Output after loop completes
+ zh_Hans: 循环完成后的输出
+
+config:
+ - name: loop_type
+ type: select
+ required: true
+ default: foreach
+ options:
+ - foreach
+ - while
+ - count
+ label:
+ en_US: Loop Type
+ zh_Hans: 循环类型
+ description:
+ en_US: Type of loop to execute
+ zh_Hans: 要执行的循环类型
+
+ - name: max_iterations
+ type: integer
+ default: 100
+ label:
+ en_US: Max Iterations
+ zh_Hans: 最大迭代次数
+ description:
+ en_US: Maximum number of iterations
+ zh_Hans: 最大迭代次数
+
+ - name: count
+ type: integer
+ default: 10
+ label:
+ en_US: Count
+ zh_Hans: 计数
+ description:
+ en_US: Number of times to iterate
+ zh_Hans: 迭代次数
+
+ - name: while_condition
+ type: string
+ default: ""
+ label:
+ en_US: While Condition
+ zh_Hans: While 条件
+ description:
+ en_US: Condition expression to continue looping
+ zh_Hans: 继续循环的条件表达式
+
+ - name: parallel
+ type: boolean
+ default: false
+ label:
+ en_US: Parallel Execution
+ zh_Hans: 并行执行
+ description:
+ en_US: Execute iterations in parallel
+ zh_Hans: 并行执行迭代
+
+ - name: parallel_limit
+ type: integer
+ default: 5
+ label:
+ en_US: Parallel Limit
+ zh_Hans: 并行限制
+ description:
+ en_US: Maximum number of parallel executions
+ zh_Hans: 最大并行执行数
diff --git a/src/langbot/templates/metadata/nodes/mcp_tool.yaml b/src/langbot/templates/metadata/nodes/mcp_tool.yaml
new file mode 100644
index 000000000..a6bc007b2
--- /dev/null
+++ b/src/langbot/templates/metadata/nodes/mcp_tool.yaml
@@ -0,0 +1,88 @@
+# Mcp Tool Node Configuration
+# This file defines the metadata for the Mcp Tool workflow node
+# The corresponding Python implementation is in: pkg/workflow/nodes/mcp_tool.py
+
+name: mcp_tool
+label:
+ en_US: MCP Tool
+ zh_Hans: MCP 工具
+category: integration
+icon: Wrench
+color: '#ec4899'
+description:
+ en_US: Invoke an MCP (Model Context Protocol) tool
+ zh_Hans: 调用 MCP 工具
+inputs:
+- name: arguments
+ type: object
+ required: false
+ label:
+ en_US: Arguments
+ zh_Hans: 参数
+ description:
+ en_US: Tool arguments
+ zh_Hans: 工具参数
+outputs:
+- name: result
+ type: any
+ label:
+ en_US: Result
+ zh_Hans: 结果
+ description:
+ en_US: Tool execution result
+ zh_Hans: 工具执行结果
+- name: success
+ type: boolean
+ label:
+ en_US: Success
+ zh_Hans: 成功
+ description:
+ en_US: Whether tool call was successful
+ zh_Hans: 工具调用是否成功
+- name: error
+ type: string
+ label:
+ en_US: Error
+ zh_Hans: 错误
+ description:
+ en_US: Error message if failed
+ zh_Hans: 失败时的错误信息
+config:
+- name: server_name
+ type: string
+ required: true
+ default: ''
+ label:
+ en_US: MCP Server
+ zh_Hans: MCP 服务器
+ description:
+ en_US: Name of the MCP server
+ zh_Hans: MCP 服务器名称
+- name: tool_name
+ type: string
+ required: true
+ default: ''
+ label:
+ en_US: Tool Name
+ zh_Hans: 工具名称
+ description:
+ en_US: Name of the MCP tool to invoke
+ zh_Hans: 要调用的 MCP 工具名称
+- name: arguments_template
+ type: textarea
+ default: ''
+ label:
+ en_US: Arguments Template
+ zh_Hans: 参数模板
+ description:
+ en_US: Tool arguments as JSON
+ zh_Hans: 工具参数(JSON 格式)
+- name: timeout
+ type: integer
+ default: 30
+ label:
+ en_US: Timeout (seconds)
+ zh_Hans: 超时时间(秒)
+ description:
+ en_US: Maximum execution time
+ zh_Hans: 最大执行时间
diff --git a/src/langbot/templates/metadata/nodes/memory_store.yaml b/src/langbot/templates/metadata/nodes/memory_store.yaml
new file mode 100644
index 000000000..f5b130717
--- /dev/null
+++ b/src/langbot/templates/metadata/nodes/memory_store.yaml
@@ -0,0 +1,99 @@
+# Memory Store Node Configuration
+# This file defines the metadata for the Memory Store workflow node
+# The corresponding Python implementation is in: pkg/workflow/nodes/memory_store.py
+
+name: memory_store
+label:
+ en_US: Memory Store
+ zh_Hans: 记忆存储
+category: integration
+icon: HardDrive
+color: '#ec4899'
+description:
+ en_US: Store and retrieve data from workflow memory
+ zh_Hans: 从工作流记忆中存储和检索数据
+
+inputs:
+ - name: value
+ type: any
+ required: false
+ label:
+ en_US: Value
+ zh_Hans: 值
+ description:
+ en_US: Value to store
+ zh_Hans: 要存储的值
+
+outputs:
+ - name: result
+ type: any
+ label:
+ en_US: Result
+ zh_Hans: 结果
+ description:
+ en_US: Retrieved or stored value
+ zh_Hans: 检索到的或存储的值
+ - name: success
+ type: boolean
+ label:
+ en_US: Success
+ zh_Hans: 成功
+ description:
+ en_US: Whether operation was successful
+ zh_Hans: 操作是否成功
+
+config:
+ - name: operation
+ type: select
+ required: true
+ default: get
+ options:
+ - get
+ - set
+ - delete
+ - append
+ - list
+ label:
+ en_US: Operation
+ zh_Hans: 操作
+ description:
+ en_US: Memory operation to perform
+ zh_Hans: 要执行的记忆操作
+
+ - name: key
+ type: string
+ required: true
+ default: ""
+ label:
+ en_US: Key
+ zh_Hans: 键
+ description:
+ en_US: Memory key
+ zh_Hans: 记忆键
+
+ - name: scope
+ type: select
+ required: true
+ default: execution
+ options:
+ - execution
+ - workflow
+ - session
+ - user
+ - global
+ label:
+ en_US: Scope
+ zh_Hans: 作用域
+ description:
+ en_US: Scope of the memory storage
+ zh_Hans: 记忆存储的作用域
+
+ - name: ttl
+ type: integer
+ default: 0
+ label:
+ en_US: TTL (seconds)
+ zh_Hans: TTL(秒)
+ description:
+ en_US: Time to live (0 = no expiry)
+ zh_Hans: 过期时间(0 = 不过期)
diff --git a/src/langbot/templates/metadata/nodes/merge.yaml b/src/langbot/templates/metadata/nodes/merge.yaml
new file mode 100644
index 000000000..9832573f3
--- /dev/null
+++ b/src/langbot/templates/metadata/nodes/merge.yaml
@@ -0,0 +1,77 @@
+# Merge Node Configuration
+name: merge
+label:
+ en_US: Merge
+ zh_Hans: 合并
+category: control
+icon: GitMerge
+color: '#f59e0b'
+description:
+ en_US: Merge multiple branches back together
+ zh_Hans: 将多个分支合并在一起
+
+inputs:
+ - name: input_1
+ type: any
+ label:
+ en_US: Input 1
+ zh_Hans: 输入 1
+ description:
+ en_US: First input
+ zh_Hans: 第一个输入
+ - name: input_2
+ type: any
+ label:
+ en_US: Input 2
+ zh_Hans: 输入 2
+ description:
+ en_US: Second input
+ zh_Hans: 第二个输入
+ - name: input_3
+ type: any
+ label:
+ en_US: Input 3
+ zh_Hans: 输入 3
+ description:
+ en_US: Third input
+ zh_Hans: 第三个输入
+ required: false
+ - name: input_4
+ type: any
+ label:
+ en_US: Input 4
+ zh_Hans: 输入 4
+ description:
+ en_US: Fourth input
+ zh_Hans: 第四个输入
+ required: false
+
+outputs:
+ - name: merged
+ type: object
+ label:
+ en_US: Merged
+ zh_Hans: 合并结果
+ description:
+ en_US: Merged result
+ zh_Hans: 合并后的结果
+ - name: array
+ type: array
+ label:
+ en_US: Array
+ zh_Hans: 数组
+ description:
+ en_US: All inputs as array
+ zh_Hans: 所有输入作为数组
+
+config:
+ - name: merge_strategy
+ type: select
+ options: ["object", "array", "first_non_null", "concat"]
+ default: "object"
+ label:
+ en_US: Merge Strategy
+ zh_Hans: 合并策略
+ description:
+ en_US: Strategy for merging inputs
+ zh_Hans: 合并输入的策略
diff --git a/src/langbot/templates/metadata/nodes/message_trigger.yaml b/src/langbot/templates/metadata/nodes/message_trigger.yaml
new file mode 100644
index 000000000..ea2f86388
--- /dev/null
+++ b/src/langbot/templates/metadata/nodes/message_trigger.yaml
@@ -0,0 +1,105 @@
+# Message Trigger Node Configuration
+# This file defines the metadata for the Message Trigger workflow node
+# The corresponding Python implementation is in: pkg/workflow/nodes/message_trigger.py
+
+name: message_trigger
+label:
+ en_US: Message Trigger
+ zh_Hans: 消息触发
+category: trigger
+icon: MessageSquare
+color: '#22c55e'
+description:
+ en_US: Trigger workflow when a message is received
+ zh_Hans: 当收到消息时触发工作流
+
+inputs: []
+
+outputs:
+ - name: message
+ type: object
+ label:
+ en_US: Message
+ zh_Hans: 消息
+ description:
+ en_US: The received message object
+ zh_Hans: 接收到的消息对象
+ - name: sender
+ type: object
+ label:
+ en_US: Sender
+ zh_Hans: 发送者
+ description:
+ en_US: Message sender information
+ zh_Hans: 消息发送者信息
+ - name: context
+ type: object
+ label:
+ en_US: Context
+ zh_Hans: 上下文
+ description:
+ en_US: Message context information
+ zh_Hans: 消息上下文信息
+
+config:
+ - name: match_type
+ type: select
+ required: true
+ default: all
+ options:
+ - all
+ - prefix
+ - regex
+ - contains
+ - exact
+ label:
+ en_US: Match Type
+ zh_Hans: 匹配类型
+ description:
+ en_US: How to match the incoming message
+ zh_Hans: 如何匹配收到的消息
+
+ - name: match_pattern
+ type: string
+ default: ""
+ label:
+ en_US: Match Pattern
+ zh_Hans: 匹配模式
+ description:
+ en_US: The pattern to match against the message
+ zh_Hans: 用于匹配消息的模式
+
+ - name: message_source
+ type: select
+ required: true
+ default: all
+ options:
+ - all
+ - group
+ - private
+ label:
+ en_US: Message Source
+ zh_Hans: 消息来源
+ description:
+ en_US: Filter messages by source type
+ zh_Hans: 按来源类型筛选消息
+
+ - name: platforms
+ type: json
+ default: []
+ label:
+ en_US: Platform Filter
+ zh_Hans: 平台筛选
+ description:
+ en_US: Only trigger for messages from these platforms
+ zh_Hans: 仅对来自这些平台的消息触发
+
+ - name: ignore_bot_messages
+ type: boolean
+ default: true
+ label:
+ en_US: Ignore Bot Messages
+ zh_Hans: 忽略机器人消息
+ description:
+ en_US: Do not trigger for messages sent by bots
+ zh_Hans: 不对机器人发送的消息触发
diff --git a/src/langbot/templates/metadata/nodes/n8n_workflow.yaml b/src/langbot/templates/metadata/nodes/n8n_workflow.yaml
new file mode 100644
index 000000000..717fe8284
--- /dev/null
+++ b/src/langbot/templates/metadata/nodes/n8n_workflow.yaml
@@ -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
diff --git a/src/langbot/templates/metadata/nodes/opening_statement.yaml b/src/langbot/templates/metadata/nodes/opening_statement.yaml
new file mode 100644
index 000000000..a9cd2acec
--- /dev/null
+++ b/src/langbot/templates/metadata/nodes/opening_statement.yaml
@@ -0,0 +1,62 @@
+# Opening Statement Node Configuration
+name: opening_statement
+label:
+ en_US: Opening Statement
+ zh_Hans: 对话开场白
+category: action
+icon: MessageSquare
+color: '#ef4444'
+description:
+ en_US: Provide conversation opener and suggested questions
+ zh_Hans: 提供对话开场白和建议问题
+
+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: 是否显示建议
diff --git a/src/langbot/templates/metadata/nodes/parallel.yaml b/src/langbot/templates/metadata/nodes/parallel.yaml
new file mode 100644
index 000000000..b88fcda70
--- /dev/null
+++ b/src/langbot/templates/metadata/nodes/parallel.yaml
@@ -0,0 +1,82 @@
+# 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
+label:
+ en_US: Parallel
+ zh_Hans: 并行执行
+category: control
+icon: Layers
+color: '#8b5cf6'
+description:
+ en_US: Execute multiple branches in parallel
+ zh_Hans: 并行执行多个分支
+
+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: 如果任何一个分支失败则停止所有分支
diff --git a/src/langbot/templates/metadata/nodes/parameter_extractor.yaml b/src/langbot/templates/metadata/nodes/parameter_extractor.yaml
new file mode 100644
index 000000000..7b9ba4bc5
--- /dev/null
+++ b/src/langbot/templates/metadata/nodes/parameter_extractor.yaml
@@ -0,0 +1,92 @@
+# 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
+label:
+ en_US: Parameter Extractor
+ zh_Hans: 参数提取器
+category: process
+icon: Variable
+color: '#8b5cf6'
+description:
+ en_US: Extract structured parameters from text using AI
+ zh_Hans: 使用 AI 从文本中提取结构化参数
+
+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: 如果任何必需参数无法提取则失败
diff --git a/src/langbot/templates/metadata/nodes/question_classifier.yaml b/src/langbot/templates/metadata/nodes/question_classifier.yaml
new file mode 100644
index 000000000..ed0a6b7fe
--- /dev/null
+++ b/src/langbot/templates/metadata/nodes/question_classifier.yaml
@@ -0,0 +1,92 @@
+# 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
+label:
+ en_US: Question Classifier
+ zh_Hans: 问题分类器
+category: process
+icon: ListFilter
+color: '#8b5cf6'
+description:
+ en_US: Classify questions into predefined categories using AI
+ zh_Hans: 使用 AI 将问题分类到预定义类别
+
+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: 当置信度低于阈值时使用的分类
diff --git a/src/langbot/templates/metadata/nodes/redis_operation.yaml b/src/langbot/templates/metadata/nodes/redis_operation.yaml
new file mode 100644
index 000000000..4790dc1d7
--- /dev/null
+++ b/src/langbot/templates/metadata/nodes/redis_operation.yaml
@@ -0,0 +1,118 @@
+# 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
+label:
+ en_US: Redis Operation
+ zh_Hans: Redis 操作
+category: integration
+icon: Server
+color: '#ec4899'
+description:
+ en_US: Perform Redis cache operations
+ zh_Hans: 执行 Redis 缓存操作
+
+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 操作的过期时间
diff --git a/src/langbot/templates/metadata/nodes/reply_message.yaml b/src/langbot/templates/metadata/nodes/reply_message.yaml
new file mode 100644
index 000000000..ba6662228
--- /dev/null
+++ b/src/langbot/templates/metadata/nodes/reply_message.yaml
@@ -0,0 +1,81 @@
+# Reply Message Node Configuration
+name: reply_message
+label:
+ en_US: Reply Message
+ zh_Hans: 回复消息
+category: action
+icon: Send
+color: '#ef4444'
+description:
+ en_US: Reply to the message that triggered the workflow
+ zh_Hans: 回复触发工作流的消息
+
+inputs:
+ - name: message
+ type: string
+ required: false
+ 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"
diff --git a/src/langbot/templates/metadata/nodes/send_message.yaml b/src/langbot/templates/metadata/nodes/send_message.yaml
new file mode 100644
index 000000000..5936e5d0a
--- /dev/null
+++ b/src/langbot/templates/metadata/nodes/send_message.yaml
@@ -0,0 +1,100 @@
+# 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
+label:
+ en_US: Send Message
+ zh_Hans: 发送消息
+category: action
+icon: MessageCircle
+color: '#10b981'
+description:
+ en_US: Send a message to a chat or user
+ zh_Hans: 向聊天或用户发送消息
+
+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: 提及原始发送者
diff --git a/src/langbot/templates/metadata/nodes/set_variable.yaml b/src/langbot/templates/metadata/nodes/set_variable.yaml
new file mode 100644
index 000000000..07e656dfb
--- /dev/null
+++ b/src/langbot/templates/metadata/nodes/set_variable.yaml
@@ -0,0 +1,64 @@
+# Set Variable Node Configuration
+name: set_variable
+label:
+ en_US: Set Variable
+ zh_Hans: 设置变量
+category: action
+icon: Variable
+color: '#ef4444'
+description:
+ en_US: Set a context variable value
+ zh_Hans: 设置上下文变量值
+
+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: 要执行的操作
diff --git a/src/langbot/templates/metadata/nodes/store_data.yaml b/src/langbot/templates/metadata/nodes/store_data.yaml
new file mode 100644
index 000000000..b95941c0c
--- /dev/null
+++ b/src/langbot/templates/metadata/nodes/store_data.yaml
@@ -0,0 +1,70 @@
+# Store Data Node Configuration
+name: store_data
+label:
+ en_US: Store Data
+ zh_Hans: 存储数据
+category: action
+icon: Database
+color: '#ef4444'
+description:
+ en_US: Store data to persistent storage
+ zh_Hans: 将数据存储到持久化存储
+
+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: 键前缀
diff --git a/src/langbot/templates/metadata/nodes/switch.yaml b/src/langbot/templates/metadata/nodes/switch.yaml
new file mode 100644
index 000000000..37edabdaf
--- /dev/null
+++ b/src/langbot/templates/metadata/nodes/switch.yaml
@@ -0,0 +1,83 @@
+# 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
+label:
+ en_US: Switch
+ zh_Hans: 多路分支
+category: control
+icon: Split
+color: '#8b5cf6'
+description:
+ en_US: Branch workflow based on multiple cases
+ zh_Hans: 根据多个条件分支工作流
+
+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: 字符串比较是否区分大小写
diff --git a/src/langbot/templates/metadata/nodes/variable_aggregator.yaml b/src/langbot/templates/metadata/nodes/variable_aggregator.yaml
new file mode 100644
index 000000000..6cbea1aeb
--- /dev/null
+++ b/src/langbot/templates/metadata/nodes/variable_aggregator.yaml
@@ -0,0 +1,53 @@
+# Variable Aggregator Node Configuration
+name: variable_aggregator
+label:
+ en_US: Variable Aggregator
+ zh_Hans: 变量聚合器
+category: control
+icon: GitMerge
+color: '#f59e0b'
+description:
+ en_US: Aggregate variable outputs from multiple branches
+ zh_Hans: 聚合多个分支的变量输出
+
+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: 变量聚合模式
diff --git a/src/langbot/templates/metadata/nodes/wait.yaml b/src/langbot/templates/metadata/nodes/wait.yaml
new file mode 100644
index 000000000..a402d49c3
--- /dev/null
+++ b/src/langbot/templates/metadata/nodes/wait.yaml
@@ -0,0 +1,70 @@
+# 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
+label:
+ en_US: Wait
+ zh_Hans: 等待
+category: control
+icon: Clock
+color: '#8b5cf6'
+description:
+ en_US: Pause workflow execution for a specified duration
+ zh_Hans: 暂停工作流执行指定时间
+
+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: 等待直到此时间
diff --git a/src/langbot/templates/metadata/nodes/webhook_trigger.yaml b/src/langbot/templates/metadata/nodes/webhook_trigger.yaml
new file mode 100644
index 000000000..38f9c1be6
--- /dev/null
+++ b/src/langbot/templates/metadata/nodes/webhook_trigger.yaml
@@ -0,0 +1,143 @@
+# 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
+label:
+ en_US: Webhook Trigger
+ zh_Hans: Webhook 触发
+category: trigger
+icon: Webhook
+color: '#22c55e'
+description:
+ en_US: Trigger workflow via HTTP webhook
+ zh_Hans: 通过 HTTP 请求触发工作流
+
+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: 请求超时时间(秒)
diff --git a/tests/unit_tests/platform/test_routing_rules.py b/tests/unit_tests/platform/test_routing_rules.py
index 3928f6f11..ca56024b1 100644
--- a/tests/unit_tests/platform/test_routing_rules.py
+++ b/tests/unit_tests/platform/test_routing_rules.py
@@ -1,10 +1,19 @@
"""
RuntimeBot.resolve_pipeline_uuid and _match_operator unit tests
+
+NOTE: These tests are for the deprecated routing rules feature.
+Routing rules have been removed in favor of direct Pipeline/Workflow binding.
+These tests are kept for backward compatibility but are skipped.
"""
+import pytest
from unittest.mock import Mock
+# Mark all tests in this module as skipped since routing rules are deprecated
+pytestmark = pytest.mark.skip(reason="Routing rules feature has been removed. Bots now directly bind to Pipeline/Workflow.")
+
+
class TestMatchOperator:
"""Test the _match_operator static method."""
diff --git a/tests/unit_tests/workflow/__init__.py b/tests/unit_tests/workflow/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/unit_tests/workflow/test_executor.py b/tests/unit_tests/workflow/test_executor.py
new file mode 100644
index 000000000..2b5cf12c2
--- /dev/null
+++ b/tests/unit_tests/workflow/test_executor.py
@@ -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
+from langbot.pkg.workflow.node import WorkflowNode
+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"
diff --git a/tests/unit_tests/workflow/test_registry.py b/tests/unit_tests/workflow/test_registry.py
new file mode 100644
index 000000000..a245d3fd0
--- /dev/null
+++ b/tests/unit_tests/workflow/test_registry.py
@@ -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
+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
diff --git a/tests/unit_tests/workflow/test_safe_eval.py b/tests/unit_tests/workflow/test_safe_eval.py
new file mode 100644
index 000000000..93b0bffa1
--- /dev/null
+++ b/tests/unit_tests/workflow/test_safe_eval.py
@@ -0,0 +1,191 @@
+"""Tests for the safe expression evaluator that replaced eval()."""
+
+import sys
+import os
+import pytest
+
+# Add project root to path
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..', 'src'))
+
+from langbot.pkg.workflow.executor import _safe_eval
+
+
+class TestSafeEvalLiterals:
+ """Test literal value evaluation."""
+
+ def test_integer(self):
+ assert _safe_eval("42") == 42
+
+ def test_negative_integer(self):
+ assert _safe_eval("-5") == -5
+
+ def test_float(self):
+ assert _safe_eval("3.14") == pytest.approx(3.14)
+
+ def test_string(self):
+ assert _safe_eval('"hello"') == "hello"
+
+ def test_single_quoted_string(self):
+ assert _safe_eval("'world'") == "world"
+
+ def test_true(self):
+ assert _safe_eval("True") is True
+
+ def test_false(self):
+ assert _safe_eval("False") is False
+
+ def test_none(self):
+ assert _safe_eval("None") is None
+
+
+class TestSafeEvalComparisons:
+ """Test comparison operators."""
+
+ def test_eq_true(self):
+ assert _safe_eval("1 == 1") is True
+
+ def test_eq_false(self):
+ assert _safe_eval("1 == 2") is False
+
+ def test_neq(self):
+ assert _safe_eval("1 != 2") is True
+
+ def test_gt(self):
+ assert _safe_eval("3 > 2") is True
+
+ def test_gte(self):
+ assert _safe_eval("3 >= 3") is True
+
+ def test_lt(self):
+ assert _safe_eval("1 < 2") is True
+
+ def test_lte(self):
+ assert _safe_eval("2 <= 2") is True
+
+ def test_string_eq(self):
+ assert _safe_eval('"hello" == "hello"') is True
+
+ def test_string_neq(self):
+ assert _safe_eval('"a" != "b"') is True
+
+ def test_chained_comparison(self):
+ assert _safe_eval("1 < 2 < 3") is True
+
+ def test_chained_comparison_false(self):
+ assert _safe_eval("1 < 2 > 3") is False
+
+ def test_is_none(self):
+ assert _safe_eval("None is None") is True
+
+ def test_is_not_none(self):
+ assert _safe_eval("1 is not None") is True
+
+
+class TestSafeEvalIn:
+ """Test 'in' / 'not in' operators."""
+
+ def test_in_list(self):
+ assert _safe_eval('"abc" in ["abc", "def"]') is True
+
+ def test_not_in_list(self):
+ assert _safe_eval('"x" not in ["a", "b"]') is True
+
+ def test_int_in_list(self):
+ assert _safe_eval("2 in [1, 2, 3]") is True
+
+ def test_in_string(self):
+ assert _safe_eval('"lo" in "hello"') is True
+
+
+class TestSafeEvalBooleanLogic:
+ """Test and / or / not operators."""
+
+ def test_and_true(self):
+ assert _safe_eval("True and True") is True
+
+ def test_and_false(self):
+ assert _safe_eval("True and False") is False
+
+ def test_or_true(self):
+ assert _safe_eval("False or True") is True
+
+ def test_or_false(self):
+ assert _safe_eval("False or False") is False
+
+ def test_not_true(self):
+ assert _safe_eval("not False") is True
+
+ def test_not_false(self):
+ assert _safe_eval("not True") is False
+
+ def test_complex_boolean(self):
+ assert _safe_eval("(1 == 1) and (2 > 1) or False") is True
+
+
+class TestSafeEvalArithmetic:
+ """Test arithmetic operators."""
+
+ def test_add(self):
+ assert _safe_eval("1 + 2") == 3
+
+ def test_sub(self):
+ assert _safe_eval("5 - 3") == 2
+
+ def test_mul(self):
+ assert _safe_eval("3 * 4") == 12
+
+ def test_div(self):
+ assert _safe_eval("10 / 3") == pytest.approx(3.333, abs=0.01)
+
+ def test_floor_div(self):
+ assert _safe_eval("10 // 3") == 3
+
+ def test_mod(self):
+ assert _safe_eval("10 % 3") == 1
+
+ def test_combined_arithmetic_comparison(self):
+ assert _safe_eval("1 + 2 == 3") is True
+
+
+class TestSafeEvalSecurity:
+ """Ensure dangerous constructs are rejected."""
+
+ def test_import_blocked(self):
+ with pytest.raises((ValueError, SyntaxError)):
+ _safe_eval('__import__("os")')
+
+ def test_function_call_blocked(self):
+ with pytest.raises(ValueError):
+ _safe_eval('print("hello")')
+
+ def test_open_blocked(self):
+ with pytest.raises(ValueError):
+ _safe_eval('open("/etc/passwd")')
+
+ def test_attribute_access_blocked(self):
+ with pytest.raises(ValueError):
+ _safe_eval('"hello".__class__')
+
+ def test_subscript_blocked(self):
+ with pytest.raises(ValueError):
+ _safe_eval('[1,2,3][0]')
+
+ def test_class_subclasses_blocked(self):
+ with pytest.raises((ValueError, SyntaxError)):
+ _safe_eval('().__class__.__subclasses__()')
+
+ def test_exec_blocked(self):
+ with pytest.raises(ValueError):
+ _safe_eval('exec("import os")')
+
+ def test_eval_blocked(self):
+ with pytest.raises(ValueError):
+ _safe_eval('eval("1+1")')
+
+ def test_lambda_blocked(self):
+ with pytest.raises((ValueError, SyntaxError)):
+ _safe_eval('lambda: 1')
+
+ def test_variable_reference_blocked(self):
+ with pytest.raises(ValueError):
+ _safe_eval('x + 1')
diff --git a/uv.lock b/uv.lock
index fc56bbbc0..72145a334 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1973,7 +1973,7 @@ requires-dist = [
{ name = "ebooklib", specifier = ">=0.18" },
{ name = "gewechat-client", specifier = ">=0.1.5" },
{ name = "html2text", specifier = ">=2024.2.26" },
- { name = "langbot-plugin", specifier = "==0.3.11" },
+ { name = "langbot-plugin", directory = "/home/typer/Desktop/langbot-plugin-sdk" },
{ name = "langchain", specifier = ">=0.2.0" },
{ name = "langchain-core", specifier = ">=1.2.28" },
{ name = "langchain-text-splitters", specifier = ">=1.1.2" },
@@ -2037,7 +2037,7 @@ dev = [
[[package]]
name = "langbot-plugin"
version = "0.3.11"
-source = { registry = "https://pypi.org/simple" }
+source = { directory = "/home/typer/Desktop/langbot-plugin-sdk" }
dependencies = [
{ name = "aiofiles" },
{ name = "dotenv" },
@@ -2054,9 +2054,31 @@ dependencies = [
{ name = "watchdog" },
{ name = "websockets" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/91/83/93b86bcdbfe51d820fa59232aaa73cc802d6ce614f67d8f8b33957419538/langbot_plugin-0.3.11.tar.gz", hash = "sha256:8d10c98c771b468b2d35cc007778439c39922a88265fcc16a5881234bc7c1b19", size = 190315, upload-time = "2026-05-12T15:45:24.262Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/8f/22/de7977a6a5cbf557b80043eb3ed39e5feff24033a5d6db4ab88d48ccb6ea/langbot_plugin-0.3.11-py3-none-any.whl", hash = "sha256:c1d2e84eda1584902d99efa316b850c08c1c04fcc199306ff4af1dca1431304a", size = 165574, upload-time = "2026-05-12T15:45:22.908Z" },
+
+[package.metadata]
+requires-dist = [
+ { name = "aiofiles", specifier = ">=24.1.0" },
+ { name = "dotenv", specifier = ">=0.9.9" },
+ { name = "httpx", specifier = ">=0.28.1" },
+ { name = "jinja2", specifier = ">=3.1.6" },
+ { name = "pip", specifier = ">=25.2" },
+ { name = "pydantic", specifier = ">=2.11.5" },
+ { name = "pydantic-settings", specifier = ">=2.10.1" },
+ { name = "pytest", specifier = ">=8.4.0" },
+ { name = "pyyaml", specifier = ">=6.0.2" },
+ { name = "textual", specifier = ">=3.2.0" },
+ { name = "types-aiofiles", specifier = ">=24.1.0.20250516" },
+ { name = "types-pyyaml", specifier = ">=6.0.12.20250516" },
+ { name = "watchdog", specifier = ">=6.0.0" },
+ { name = "websockets", specifier = ">=15.0.1" },
+]
+
+[package.metadata.requires-dev]
+dev = [
+ { name = "mypy", specifier = ">=1.16.0" },
+ { name = "pytest-asyncio", specifier = ">=1.3.0" },
+ { name = "pytest-cov", specifier = ">=7.0.0" },
+ { name = "ruff", specifier = ">=0.11.12" },
]
[[package]]
@@ -2751,7 +2773,7 @@ wheels = [
[[package]]
name = "moto"
version = "5.2.1"
-source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
+source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "boto3" },
{ name = "botocore" },
@@ -2761,9 +2783,9 @@ dependencies = [
{ name = "werkzeug" },
{ name = "xmltodict" },
]
-sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f6/e9/c38202162db2e76623176be9f1dbc9aa41228ffa91ee8da2d3986082c3e3/moto-5.2.1.tar.gz", hash = "sha256:ccb2f3e1dfa82e50e054bda98b0be708d244d2668364dcc1d45e8d3de6091bde", size = 8634437, upload-time = "2026-05-10T19:11:57.286Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/f6/e9/c38202162db2e76623176be9f1dbc9aa41228ffa91ee8da2d3986082c3e3/moto-5.2.1.tar.gz", hash = "sha256:ccb2f3e1dfa82e50e054bda98b0be708d244d2668364dcc1d45e8d3de6091bde", size = 8634437, upload-time = "2026-05-10T19:11:57.286Z" }
wheels = [
- { url = "https://pypi.tuna.tsinghua.edu.cn/packages/15/79/8085b7c1ecd48d0535c3c8444a1d8df2926e457dce8e55fabc332a382c9c/moto-5.2.1-py3-none-any.whl", hash = "sha256:19d2fbd6e613aa5b4e364c52cd5d3cea371643a0f4210689a703227bd2924c5c", size = 6671379, upload-time = "2026-05-10T19:11:53.543Z" },
+ { url = "https://files.pythonhosted.org/packages/15/79/8085b7c1ecd48d0535c3c8444a1d8df2926e457dce8e55fabc332a382c9c/moto-5.2.1-py3-none-any.whl", hash = "sha256:19d2fbd6e613aa5b4e364c52cd5d3cea371643a0f4210689a703227bd2924c5c", size = 6671379, upload-time = "2026-05-10T19:11:53.543Z" },
]
[[package]]
@@ -4767,15 +4789,15 @@ wheels = [
[[package]]
name = "responses"
version = "0.26.0"
-source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
+source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyyaml" },
{ name = "requests" },
{ name = "urllib3" },
]
-sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9f/b4/b7e040379838cc71bf5aabdb26998dfbe5ee73904c92c1c161faf5de8866/responses-0.26.0.tar.gz", hash = "sha256:c7f6923e6343ef3682816ba421c006626777893cb0d5e1434f674b649bac9eb4", size = 81303, upload-time = "2026-02-19T14:38:05.574Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/9f/b4/b7e040379838cc71bf5aabdb26998dfbe5ee73904c92c1c161faf5de8866/responses-0.26.0.tar.gz", hash = "sha256:c7f6923e6343ef3682816ba421c006626777893cb0d5e1434f674b649bac9eb4", size = 81303, upload-time = "2026-02-19T14:38:05.574Z" }
wheels = [
- { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ce/04/7f73d05b556da048923e31a0cc878f03be7c5425ed1f268082255c75d872/responses-0.26.0-py3-none-any.whl", hash = "sha256:03ec4409088cd5c66b71ecbbbd27fe2c58ddfad801c66203457b3e6a04868c37", size = 35099, upload-time = "2026-02-19T14:38:03.847Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/04/7f73d05b556da048923e31a0cc878f03be7c5425ed1f268082255c75d872/responses-0.26.0-py3-none-any.whl", hash = "sha256:03ec4409088cd5c66b71ecbbbd27fe2c58ddfad801c66203457b3e6a04868c37", size = 35099, upload-time = "2026-02-19T14:38:03.847Z" },
]
[[package]]
@@ -6072,10 +6094,10 @@ wheels = [
[[package]]
name = "xmltodict"
version = "1.0.4"
-source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
-sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/19/70/80f3b7c10d2630aa66414bf23d210386700aa390547278c789afa994fd7e/xmltodict-1.0.4.tar.gz", hash = "sha256:6d94c9f834dd9e44514162799d344d815a3a4faec913717a9ecbfa5be1bb8e61", size = 26124, upload-time = "2026-02-22T02:21:22.074Z" }
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/19/70/80f3b7c10d2630aa66414bf23d210386700aa390547278c789afa994fd7e/xmltodict-1.0.4.tar.gz", hash = "sha256:6d94c9f834dd9e44514162799d344d815a3a4faec913717a9ecbfa5be1bb8e61", size = 26124, upload-time = "2026-02-22T02:21:22.074Z" }
wheels = [
- { url = "https://pypi.tuna.tsinghua.edu.cn/packages/38/34/98a2f52245f4d47be93b580dae5f9861ef58977d73a79eb47c58f1ad1f3a/xmltodict-1.0.4-py3-none-any.whl", hash = "sha256:a4a00d300b0e1c59fc2bfccb53d7b2e88c32f200df138a0dd2229f842497026a", size = 13580, upload-time = "2026-02-22T02:21:21.039Z" },
+ { url = "https://files.pythonhosted.org/packages/38/34/98a2f52245f4d47be93b580dae5f9861ef58977d73a79eb47c58f1ad1f3a/xmltodict-1.0.4-py3-none-any.whl", hash = "sha256:a4a00d300b0e1c59fc2bfccb53d7b2e88c32f200df138a0dd2229f842497026a", size = 13580, upload-time = "2026-02-22T02:21:21.039Z" },
]
[[package]]
diff --git a/web/package.json b/web/package.json
index 23a3c0ece..7a236c7a7 100644
--- a/web/package.json
+++ b/web/package.json
@@ -46,6 +46,7 @@
"@tailwindcss/postcss": "^4.1.5",
"@tanstack/react-table": "^8.21.3",
"@vitejs/plugin-react": "^6.0.1",
+ "@xyflow/react": "^12.10.2",
"axios": "^1.15.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -77,7 +78,8 @@
"tailwindcss": "^4.1.5",
"uuidjs": "^5.1.0",
"vite": "^8.0.5",
- "zod": "^3.24.4"
+ "zod": "^3.24.4",
+ "zustand": "^5.0.12"
},
"devDependencies": {
"@types/debug": "^4.1.12",
diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml
index 259d537be..6cc3606ce 100644
--- a/web/pnpm-lock.yaml
+++ b/web/pnpm-lock.yaml
@@ -89,6 +89,9 @@ dependencies:
'@vitejs/plugin-react':
specifier: ^6.0.1
version: 6.0.1(vite@8.0.8)
+ '@xyflow/react':
+ specifier: ^12.10.2
+ version: 12.10.2(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1)
axios:
specifier: ^1.15.0
version: 1.15.0
@@ -185,6 +188,9 @@ dependencies:
zod:
specifier: ^3.24.4
version: 3.25.76
+ zustand:
+ specifier: ^5.0.12
+ version: 5.0.12(@types/react@19.2.10)(react@19.2.1)
devDependencies:
'@types/debug':
@@ -1903,6 +1909,12 @@ packages:
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
dev: false
+ /@types/d3-drag@3.0.7:
+ resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==}
+ dependencies:
+ '@types/d3-selection': 3.0.11
+ dev: false
+
/@types/d3-ease@3.0.2:
resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
dev: false
@@ -1923,6 +1935,10 @@ packages:
'@types/d3-time': 3.0.4
dev: false
+ /@types/d3-selection@3.0.11:
+ resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==}
+ dev: false
+
/@types/d3-shape@3.1.8:
resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==}
dependencies:
@@ -1937,6 +1953,19 @@ packages:
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
dev: false
+ /@types/d3-transition@3.0.9:
+ resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==}
+ dependencies:
+ '@types/d3-selection': 3.0.11
+ dev: false
+
+ /@types/d3-zoom@3.0.8:
+ resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==}
+ dependencies:
+ '@types/d3-interpolate': 3.0.4
+ '@types/d3-selection': 3.0.11
+ dev: false
+
/@types/debug@4.1.12:
resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
dependencies:
@@ -2172,6 +2201,36 @@ packages:
vite: 8.0.8(@types/node@20.19.30)
dev: false
+ /@xyflow/react@12.10.2(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1):
+ resolution: {integrity: sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ==}
+ peerDependencies:
+ react: '>=17'
+ react-dom: '>=17'
+ dependencies:
+ '@xyflow/system': 0.0.76
+ classcat: 5.0.5
+ react: 19.2.1
+ react-dom: 19.2.1(react@19.2.1)
+ zustand: 4.5.7(@types/react@19.2.10)(react@19.2.1)
+ transitivePeerDependencies:
+ - '@types/react'
+ - immer
+ dev: false
+
+ /@xyflow/system@0.0.76:
+ resolution: {integrity: sha512-hvwvnRS1B3REwVDlWexsq7YQaPZeG3/mKo1jv38UmnpWmxihp14bW6VtEOuHEwJX2FvzFw8k77LyKSk/wiZVNA==}
+ dependencies:
+ '@types/d3-drag': 3.0.7
+ '@types/d3-interpolate': 3.0.4
+ '@types/d3-selection': 3.0.11
+ '@types/d3-transition': 3.0.9
+ '@types/d3-zoom': 3.0.8
+ d3-drag: 3.0.0
+ d3-interpolate: 3.0.1
+ d3-selection: 3.0.0
+ d3-zoom: 3.0.0
+ dev: false
+
/acorn-jsx@5.3.2(acorn@8.15.0):
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies:
@@ -2434,6 +2493,10 @@ packages:
clsx: 2.1.1
dev: false
+ /classcat@5.0.5:
+ resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==}
+ dev: false
+
/cli-cursor@5.0.0:
resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==}
engines: {node: '>=18'}
@@ -2524,6 +2587,19 @@ packages:
engines: {node: '>=12'}
dev: false
+ /d3-dispatch@3.0.1:
+ resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
+ engines: {node: '>=12'}
+ dev: false
+
+ /d3-drag@3.0.0:
+ resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==}
+ engines: {node: '>=12'}
+ dependencies:
+ d3-dispatch: 3.0.1
+ d3-selection: 3.0.0
+ dev: false
+
/d3-ease@3.0.1:
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
engines: {node: '>=12'}
@@ -2557,6 +2633,11 @@ packages:
d3-time-format: 4.1.0
dev: false
+ /d3-selection@3.0.0:
+ resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==}
+ engines: {node: '>=12'}
+ dev: false
+
/d3-shape@3.2.0:
resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
engines: {node: '>=12'}
@@ -2583,6 +2664,31 @@ packages:
engines: {node: '>=12'}
dev: false
+ /d3-transition@3.0.1(d3-selection@3.0.0):
+ resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==}
+ engines: {node: '>=12'}
+ peerDependencies:
+ d3-selection: 2 - 3
+ dependencies:
+ d3-color: 3.1.0
+ d3-dispatch: 3.0.1
+ d3-ease: 3.0.1
+ d3-interpolate: 3.0.1
+ d3-selection: 3.0.0
+ d3-timer: 3.0.1
+ dev: false
+
+ /d3-zoom@3.0.0:
+ resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==}
+ engines: {node: '>=12'}
+ dependencies:
+ d3-dispatch: 3.0.1
+ d3-drag: 3.0.0
+ d3-interpolate: 3.0.1
+ d3-selection: 3.0.0
+ d3-transition: 3.0.1(d3-selection@3.0.0)
+ dev: false
+
/data-view-buffer@1.0.2:
resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==}
engines: {node: '>= 0.4'}
@@ -6201,6 +6307,48 @@ packages:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
dev: false
+ /zustand@4.5.7(@types/react@19.2.10)(react@19.2.1):
+ resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==}
+ engines: {node: '>=12.7.0'}
+ peerDependencies:
+ '@types/react': '>=16.8'
+ immer: '>=9.0.6'
+ react: '>=16.8'
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ immer:
+ optional: true
+ react:
+ optional: true
+ dependencies:
+ '@types/react': 19.2.10
+ react: 19.2.1
+ use-sync-external-store: 1.6.0(react@19.2.1)
+ dev: false
+
+ /zustand@5.0.12(@types/react@19.2.10)(react@19.2.1):
+ resolution: {integrity: sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==}
+ engines: {node: '>=12.20.0'}
+ peerDependencies:
+ '@types/react': '>=18.0.0'
+ immer: '>=9.0.6'
+ react: '>=18.0.0'
+ use-sync-external-store: '>=1.2.0'
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ immer:
+ optional: true
+ react:
+ optional: true
+ use-sync-external-store:
+ optional: true
+ dependencies:
+ '@types/react': 19.2.10
+ react: 19.2.1
+ dev: false
+
/zwitch@2.0.4:
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
dev: false
diff --git a/web/src/app/home/bots/components/bot-form/BotForm.tsx b/web/src/app/home/bots/components/bot-form/BotForm.tsx
index c81225dee..62bb157a3 100644
--- a/web/src/app/home/bots/components/bot-form/BotForm.tsx
+++ b/web/src/app/home/bots/components/bot-form/BotForm.tsx
@@ -16,7 +16,7 @@ import { httpClient } from '@/app/infra/http/HttpClient';
import { Bot } from '@/app/infra/entities/api';
import { getAdapterDocUrl } from '@/app/infra/entities/adapter-docs';
import { ExternalLink } from 'lucide-react';
-import RoutingRulesEditor from './RoutingRulesEditor';
+import { UnifiedBindingSelector } from '@/app/home/components/unified-binding-selector';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
@@ -43,6 +43,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
+// SelectGroup used in adapter selector
import {
Card,
CardContent,
@@ -64,29 +65,11 @@ const getFormSchema = (t: (key: string) => string) =>
adapter: z.string().min(1, { message: t('bots.adapterRequired') }),
adapter_config: z.record(z.string(), z.any()),
enable: z.boolean().optional(),
+ // New unified binding fields
+ binding_type: z.enum(['pipeline', 'workflow']).optional(),
+ binding_uuid: z.string().optional(),
+ // Legacy fields (kept for backward compatibility, but not used in UI)
use_pipeline_uuid: z.string().optional(),
- pipeline_routing_rules: z
- .array(
- z.object({
- type: z.enum([
- 'launcher_type',
- 'launcher_id',
- 'message_content',
- 'message_has_element',
- ]),
- operator: z.enum([
- 'eq',
- 'neq',
- 'contains',
- 'not_contains',
- 'starts_with',
- 'regex',
- ]),
- value: z.string(),
- pipeline_uuid: z.string(),
- }),
- )
- .optional(),
});
export default function BotForm({
@@ -111,8 +94,9 @@ export default function BotForm({
adapter: '',
adapter_config: {},
enable: true,
+ binding_type: 'pipeline',
+ binding_uuid: '',
use_pipeline_uuid: '',
- pipeline_routing_rules: [],
},
});
@@ -178,8 +162,9 @@ export default function BotForm({
adapter: val.adapter,
adapter_config: val.adapter_config,
enable: val.enable,
+ binding_type: val.binding_type || 'pipeline',
+ binding_uuid: val.binding_uuid || val.use_pipeline_uuid || '',
use_pipeline_uuid: val.use_pipeline_uuid || '',
- pipeline_routing_rules: val.pipeline_routing_rules || [],
});
handleAdapterSelect(val.adapter);
@@ -295,8 +280,9 @@ export default function BotForm({
name: bot.name,
adapter_config: bot.adapter_config,
enable: bot.enable ?? true,
+ binding_type: bot.binding_type ?? 'pipeline',
+ binding_uuid: bot.binding_uuid ?? bot.use_pipeline_uuid ?? '',
use_pipeline_uuid: bot.use_pipeline_uuid ?? '',
- pipeline_routing_rules: bot.pipeline_routing_rules ?? [],
webhook_full_url: runtimeValues?.webhook_full_url as
| string
| undefined,
@@ -333,15 +319,21 @@ export default function BotForm({
function onDynamicFormSubmit() {
setIsLoading(true);
if (initBotId) {
+ const formValues = form.getValues();
const updateBot: Bot = {
uuid: initBotId,
- name: form.getValues().name,
- description: form.getValues().description ?? '',
- adapter: form.getValues().adapter,
- adapter_config: form.getValues().adapter_config,
- enable: form.getValues().enable,
- use_pipeline_uuid: form.getValues().use_pipeline_uuid,
- pipeline_routing_rules: form.getValues().pipeline_routing_rules ?? [],
+ name: formValues.name,
+ description: formValues.description ?? '',
+ adapter: formValues.adapter,
+ adapter_config: formValues.adapter_config,
+ enable: formValues.enable,
+ binding_type: formValues.binding_type ?? 'pipeline',
+ binding_uuid: formValues.binding_uuid ?? '',
+ // Sync use_pipeline_uuid for backward compatibility when binding_type is 'pipeline'
+ use_pipeline_uuid:
+ formValues.binding_type === 'pipeline'
+ ? formValues.binding_uuid
+ : formValues.use_pipeline_uuid,
};
httpClient
.updateBot(initBotId, updateBot)
@@ -430,74 +422,40 @@ export default function BotForm({
- {/* Card 2: Pipeline Binding (edit mode only) */}
+ {/* Card 2: Unified Binding (edit mode only) */}
{initBotId && (
- {t('bots.routingConnection')}
+ {t('bots.bindTarget')}
- {t('bots.routingConnectionDescription')}
+ {t('bots.bindTargetDescription')}
(
- {t('bots.bindPipeline')}
-
+ {
+ form.setValue('binding_type', val.type, {
+ shouldDirty: true,
+ });
+ form.setValue('binding_uuid', val.id ?? '', {
+ shouldDirty: true,
+ });
+ }}
+ />
)}
/>
-
- {/* Pipeline Routing Rules */}
-
)}
diff --git a/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx b/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx
index ffea18d6e..cf40e4310 100644
--- a/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx
+++ b/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx
@@ -11,16 +11,33 @@ import {
FormMessage,
} from '@/components/ui/form';
import DynamicFormItemComponent from '@/app/home/components/dynamic-form/DynamicFormItemComponent';
-import QrCodeLoginDialog, {
- QrLoginPlatform,
-} from '@/app/home/components/qrcode-login/QrCodeLoginDialog';
import { useEffect, useMemo, useRef, useState } from 'react';
-import { extractI18nObject } from '@/i18n/I18nProvider';
import { useTranslation } from 'react-i18next';
+import {
+ resolveI18nLabel,
+ maybeTranslateKey,
+} from '@/app/home/workflows/components/workflow-editor/workflow-i18n';
+import { extractI18nObject } from '@/i18n/I18nProvider';
+
+// Helper function to translate i18n key if the value is an i18n key string
+const translateIfKey = (value: string | undefined): string | undefined => {
+ if (!value) return value;
+ const translated = maybeTranslateKey(value);
+ return translated || value;
+};
+
+// Helper to extract i18n label and translate if it's an i18n key
+const extractAndTranslateI18n = (label: any): string => {
+ if (!label) return '';
+ if (typeof label === 'string') {
+ return translateIfKey(label) || label;
+ }
+ return resolveI18nLabel(label) || '';
+};
import { cn } from '@/lib/utils';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
-import { Copy, Check, Globe, QrCode } from 'lucide-react';
+import { Copy, Check, Globe } from 'lucide-react';
import { copyToClipboard } from '@/app/utils/clipboard';
import { systemInfo } from '@/app/infra/http';
@@ -38,6 +55,9 @@ function resolveShowIfValue(
externalDependentValues?: Record,
systemContext?: Record,
): unknown {
+ if (!field || typeof field !== 'string') {
+ return undefined;
+ }
if (field.startsWith('__system.')) {
const key = field.slice('__system.'.length);
return systemContext?.[key];
@@ -198,7 +218,6 @@ export default function DynamicFormComponent({
isEditing,
externalDependentValues,
systemContext,
- onValidate,
}: {
itemConfigList: IDynamicFormItemSchema[];
onSubmit?: (val: object) => unknown;
@@ -209,9 +228,6 @@ export default function DynamicFormComponent({
/** Extra variables accessible via the `__system.*` namespace in show_if conditions.
* e.g. `{ is_wizard: true }` makes `show_if: { field: "__system.is_wizard", ... }` work. */
systemContext?: Record;
- /** Callback to expose validation function to parent component.
- * Parent can call this function to trigger validation and get validity state. */
- onValidate?: (validateFn: () => Promise) => void;
}) {
const isInitialMount = useRef(true);
const previousInitialValues = useRef(initialValues);
@@ -250,6 +266,39 @@ export default function DynamicFormComponent({
// Default to a single empty system prompt entry
return [{ role: 'system', content: '' }];
}
+ if (
+ item.type === 'string' ||
+ item.type === 'text' ||
+ item.type === 'secret' ||
+ item.type === 'select' ||
+ item.type === 'llm-model-selector' ||
+ item.type === 'embedding-model-selector' ||
+ item.type === 'rerank-model-selector' ||
+ item.type === 'pipeline-selector' ||
+ item.type === 'knowledge-base-selector' ||
+ item.type === 'bot-selector'
+ ) {
+ return typeof value === 'string' ? value : '';
+ }
+ if (
+ item.type === 'array[string]' ||
+ item.type === 'knowledge-base-multi-selector' ||
+ item.type === 'tools-selector'
+ ) {
+ return Array.isArray(value)
+ ? value.filter((item): item is string => typeof item === 'string')
+ : [];
+ }
+ if (item.type === 'boolean') {
+ return typeof value === 'boolean' ? value : Boolean(value);
+ }
+ if (item.type === 'integer' || item.type === 'float') {
+ return typeof value === 'number' && !Number.isNaN(value)
+ ? value
+ : typeof item.default === 'number'
+ ? item.default
+ : 0;
+ }
return value;
};
@@ -258,10 +307,7 @@ export default function DynamicFormComponent({
const editableItems = useMemo(
() =>
itemConfigList.filter(
- (item) =>
- item.type !== 'webhook-url' &&
- item.type !== 'embed-code' &&
- item.type !== 'qr-code-login',
+ (item) => item.type !== 'webhook-url' && item.type !== 'embed-code',
),
[itemConfigList],
);
@@ -290,6 +336,9 @@ export default function DynamicFormComponent({
case 'select':
fieldSchema = z.string();
break;
+ case 'pipeline-selector':
+ fieldSchema = z.string();
+ break;
case 'llm-model-selector':
fieldSchema = z.string();
break;
@@ -362,17 +411,6 @@ export default function DynamicFormComponent({
}, {} as FormValues),
});
- // Expose validation function to parent component
- const validate = async (): Promise => {
- // Trigger validation for all fields
- const result = await form.trigger();
- return result;
- };
-
- useEffect(() => {
- onValidate?.(validate);
- }, [onValidate]);
-
// 当 initialValues 变化时更新表单值
// 但要避免因为内部表单更新触发的 onSubmit 导致的 initialValues 变化而重新设置表单
useEffect(() => {
@@ -455,28 +493,9 @@ export default function DynamicFormComponent({
return () => subscription.unsubscribe();
}, [form, editableItems]);
- // State for QR code login dialog
- const [qrDialogOpen, setQrDialogOpen] = useState(false);
- const [qrDialogPlatform, setQrDialogPlatform] =
- useState('feishu');
-
return (