44 changed files with 4551 additions and 391 deletions
@ -0,0 +1,225 @@ |
|||
# PLAN5: Flow 画布节点系统完整性方案 |
|||
|
|||
## 目标 |
|||
对齐前后端功能,实现流中节点配置真实生效,新增循环/代码节点,双渠道(企微+Web)输入输出。 |
|||
|
|||
--- |
|||
|
|||
## 一、当前状态总结 |
|||
|
|||
### 后端已有且正常运行 |
|||
| 能力 | 状态 | |
|||
|------|------| |
|||
| DAG 图执行引擎 (FlowEngine) | ✅ 完整 | |
|||
| 8种节点 Agent: trigger/llm/tool/mcp/wecom_notify/condition/rag/output | ✅ 完整 | |
|||
| 13个工具函数 (文档/企微/任务/管理) | ✅ 完整 | |
|||
| 企微全链路(回调→Agent→回复→通知) | ✅ 完整 | |
|||
| RAG知识库(Qdrant+OpenAI) | ✅ 完整 | |
|||
| MCP外部服务集成 | ✅ 完整 | |
|||
| 流CRUD + 发布/下架 + 执行记录 | ✅ 完整 | |
|||
|
|||
### 前端已有但后端不处理的配置项 (需对齐) |
|||
| 前端配置项 | 后端Schema | 后端Agent | |
|||
|-----------|-----------|----------| |
|||
| LLM: `max_tokens` | ❌ 缺失 | ❌ 未使用 | |
|||
| LLM: `context_length` | ❌ 缺失 | ❌ 未使用 | |
|||
| LLM: `memory_mode` (none/short/long) | ❌ 缺失 | ❌ 未使用 | |
|||
| LLM: `stream` (流式输出) | ❌ 缺失 | ❌ 未使用 | |
|||
| LLM: `tool_call` (函数调用) | ❌ 缺失 | ❌ 未使用 | |
|||
| Tool: `timeout` | ❌ 缺失 | ❌ 未使用 | |
|||
| Tool: `retry_count` | ❌ 缺失 | ❌ 未使用 | |
|||
| Tool: `error_handling` | ❌ 缺失 | ❌ 未使用 | |
|||
| RAG: `search_mode` (vector/keyword/hybrid) | ❌ 缺失 | ❌ 未使用 | |
|||
| RAG: `similarity_threshold` | ❌ 缺失 | ❌ 未使用 | |
|||
| Trigger: `channels` (wecom/web) | ❌ 缺失 | ❌ 未使用 | |
|||
| Notify: `channels` (wecom/web) | ❌ 缺失 | ❌ 未使用 | |
|||
|
|||
### 缺失的关键能力 |
|||
- **循环节点**: 无重试/迭代/批量能力 |
|||
- **代码执行节点**: 无法运行自定义逻辑 |
|||
- **Web Chat入口**: 只能通过企微触发 |
|||
- **Web通知**: 只有企微通知,无Web推送 |
|||
|
|||
--- |
|||
|
|||
## 二、实施计划 |
|||
|
|||
### P0: 前后端配置对齐 (最高优先级) |
|||
**目标**: 前端配置的所有参数在后端Schema和Agent中真实生效 |
|||
|
|||
#### 2.1 后端 Schema 补齐 |
|||
```python |
|||
# 文件: backend/schemas/__init__.py |
|||
|
|||
class TriggerNodeConfig(BaseModel): |
|||
event_type: str = "text_message" |
|||
channels: list[str] = ["wecom"] # 新增: ["wecom", "web_chat"] |
|||
callback_url: str = "" # 新增 |
|||
|
|||
class LLMNodeConfig(BaseModel): |
|||
system_prompt: str = "" |
|||
model: str = "gpt-4o-mini" |
|||
temperature: float = 0.7 |
|||
agent_id: str = "" |
|||
max_tokens: int = 2000 # 新增 |
|||
context_length: int = 5 # 新增 |
|||
memory_mode: str = "short_term" # 新增: none/short_term/long_term |
|||
stream: bool = True # 新增 |
|||
tool_call: bool = False # 新增 |
|||
|
|||
class ToolNodeConfig(BaseModel): |
|||
tool_name: str = "" |
|||
tool_type: str = "" # 新增: wecom_message/task_management/... |
|||
tool_params: dict = {} # 补齐 |
|||
timeout: int = 30 # 新增 |
|||
retry_count: int = 0 # 新增 |
|||
error_handling: str = "throw" # 新增: throw/default/skip |
|||
|
|||
class MCPNodeConfig(BaseModel): |
|||
mcp_server: str = "" |
|||
tool_name: str = "" |
|||
input_params: dict = {} # 新增 |
|||
timeout: int = 30 # 新增 |
|||
response_parser: str = "json" # 新增 |
|||
error_handling: str = "throw" # 新增 |
|||
|
|||
class NotifyNodeConfig(BaseModel): # 重命名: WeComNotifyNodeConfig -> NotifyNodeConfig |
|||
channels: dict = {"wecom": True, "web": False} # 新增 |
|||
message_template: str = "" |
|||
web_template: str = "" # 新增 |
|||
target: str = "" |
|||
message_type: str = "text" # 新增: text/markdown/card |
|||
async_send: bool = False # 新增 |
|||
error_handling: str = "throw" # 新增 |
|||
|
|||
class ConditionNodeConfig(BaseModel): |
|||
condition: str = "" |
|||
condition_type: str = "expression" # 新增 |
|||
true_label: str = "是" # 新增 |
|||
false_label: str = "否" # 新增 |
|||
default_branch: str = "false" # 新增 |
|||
|
|||
class RAGNodeConfig(BaseModel): |
|||
knowledge_base: str = "" |
|||
top_k: int = 5 |
|||
search_mode: str = "hybrid" # 新增: vector/keyword/hybrid |
|||
similarity_threshold: float = 0.7 # 新增 |
|||
result_sort: str = "similarity" # 新增 |
|||
include_metadata: bool = True # 新增 |
|||
|
|||
class OutputNodeConfig(BaseModel): |
|||
format: str = "text" |
|||
output_template: str = "" # 新增 |
|||
indent: int = 2 # 新增 |
|||
encoding: str = "utf-8" # 新增 |
|||
truncate: bool = False # 新增 |
|||
max_length: int = 2000 # 新增 |
|||
|
|||
class LoopNodeConfig(BaseModel): # 新增节点 |
|||
loop_type: str = "fixed" # fixed/count/list |
|||
max_iterations: int = 10 |
|||
count: int = 3 |
|||
iterator_variable: str = "item" |
|||
|
|||
class CodeNodeConfig(BaseModel): # 新增节点 |
|||
language: str = "python" # python/javascript |
|||
code: str = "" |
|||
timeout: int = 30 |
|||
sandbox: bool = True |
|||
``` |
|||
|
|||
#### 2.2 后端 Agent 补齐 |
|||
``` |
|||
文件: backend/modules/flow_engine/engine.py |
|||
|
|||
LLMNodeAgent: 使用 max_tokens, stream, tool_call |
|||
ToolNodeAgent: 使用 timeout, retry_count, error_handling |
|||
RAGNodeAgent: 使用 search_mode, similarity_threshold |
|||
NotifyAgent: 检测 channels.web 做 WebSocket 推送 |
|||
LoopNodeAgent: 新增 |
|||
CodeNodeAgent: 新增 |
|||
``` |
|||
|
|||
### P1: 双渠道支持 |
|||
**目标**: 流同时支持企业微信和网页聊天触发,通知也支持双渠道 |
|||
|
|||
#### 3.1 Web Chat API |
|||
``` |
|||
POST /api/chat/sessions/{session_id}/message |
|||
POST /api/chat/sessions (创建会话) |
|||
GET /api/chat/sessions (会话列表) |
|||
``` |
|||
|
|||
#### 3.2 WebSocket 通知推送 |
|||
``` |
|||
backend/websocket_manager.py: 新增 |
|||
- 用户连接管理 |
|||
- 按用户推送通知 |
|||
``` |
|||
|
|||
#### 3.3 前端 Web Chat 页面 |
|||
``` |
|||
frontend/src/views/chat/ChatWidget.vue: 新增 |
|||
- 浮动聊天窗口 |
|||
- WebSocket 实时接收 |
|||
- 流选择 |
|||
``` |
|||
|
|||
### P2: 新增节点类型 |
|||
**目标**: 新增循环节点和代码执行节点 |
|||
|
|||
#### 4.1 循环节点 (Loop) |
|||
- 固定次数循环、条件循环、遍历列表 |
|||
- 两个出口: loop_body(继续), loop_done(完成) |
|||
- 安全上限: max_iterations 防止死循环 |
|||
- 引擎需要支持回边 |
|||
|
|||
#### 4.2 代码执行节点 (Code) |
|||
- Python/JavaScript 沙箱执行 |
|||
- subprocess 隔离 + 超时控制 |
|||
- stdin/stdout 输入输出 |
|||
|
|||
### P3: FlowEngine 改造 |
|||
**目标**: 支持循环节点回边 |
|||
|
|||
1. `traverse()` 中 visited 集合改为 per-branch 而非全局 |
|||
2. 循环节点特殊处理: 检测 loop_done 条件 |
|||
3. 执行超时和安全限制 |
|||
|
|||
--- |
|||
|
|||
## 三、实施顺序 |
|||
|
|||
1. **P0-1**: 后端 Schema 补齐 (schemas/__init__.py) — 10分钟 |
|||
2. **P0-2**: 后端 Agent 补齐 (engine.py) — 15分钟 |
|||
3. **P0-3**: 路由注册新节点类型 (router.py) — 5分钟 |
|||
4. **P1-1**: Notify 节点双渠道改造 + WebSocket — 15分钟 |
|||
5. **P1-2**: Web Chat API + 路由 — 10分钟 |
|||
6. **P1-3**: 前端 ChatWidget + 通知接收 — 10分钟 |
|||
7. **P2-1**: Loop Node (前端配置+后端Agent) — 10分钟 |
|||
8. **P2-2**: Code Node (前端配置+后端Agent) — 10分钟 |
|||
9. **P3**: FlowEngine 循环回边支持 — 10分钟 |
|||
10. **更新前端 FlowEditor**: 新节点类型 + 配置对齐 — 5分钟 |
|||
|
|||
--- |
|||
|
|||
## 四、前端文件清单 |
|||
|
|||
| 文件 | 内容 | |
|||
|------|------| |
|||
| FlowEditor.vue | 新增 loop/code 节点类型、trigger 改 channels | |
|||
| node-configs/LoopConfig.vue | 循环配置 | |
|||
| node-configs/CodeConfig.vue | 代码执行配置 | |
|||
| node-configs/NotifyConfig.vue | 双渠道通知配置 | |
|||
| node-configs/TriggerConfig.vue | 双渠道触发配置 | |
|||
| chat/ChatWidget.vue | Web Chat 入口 | |
|||
|
|||
## 五、后端文件清单 |
|||
|
|||
| 文件 | 内容 | |
|||
|------|------| |
|||
| schemas/__init__.py | 补齐所有Config Schema + 新增Loop/Code | |
|||
| flow_engine/engine.py | 补齐Agent实现 + LoopNodeAgent + CodeNodeAgent + 引擎回边 | |
|||
| flow_engine/router.py | 注册新节点类型 | |
|||
| chat/router.py | Web Chat API (新建) | |
|||
| websocket_manager.py | WebSocket管理 (新建) | |
|||
@ -0,0 +1,328 @@ |
|||
# PLAN6 — 对标 Dify 无代码发布架构:差距分析与升级路线 |
|||
|
|||
## 一、核心结论 |
|||
|
|||
**我们的流发布逻辑与 Dify 的底层思路高度一致(配置即数据 + 动态引擎),但在 7 个关键维度存在显著差距,需要补齐才能真正实现"无代码秒级发布,即刻可用"。** |
|||
|
|||
### 已对齐的架构思路 |
|||
|
|||
| Dify 核心思路 | 我们的实现 | 对齐度 | |
|||
|--------------|-----------|--------| |
|||
| 配置即数据:前端生成 JSON,存入数据库 | ✅ FlowEditor 生成 nodes+edges JSON,存入 `FlowDefinition.definition_json` | 完全对齐 | |
|||
| 零部署:发布 = 数据库状态变更,不启动新服务 | ✅ publish 仅修改 status 字段,执行时动态加载 JSON | 完全对齐 | |
|||
| 动态编排引擎:解析 JSON → 执行 | ✅ `FlowEngine` 解析 JSON → 构建图 → traverse 执行 | 基本对齐 | |
|||
| DAG 拓扑排序执行 | ✅ `_build_graph()` + `traverse()` 支持条件分支和循环 | 基本对齐 | |
|||
| 多种节点类型 | ✅ 9 种节点:trigger/llm/tool/mcp/condition/rag/output/loop/code | 基本对齐 | |
|||
| 双渠道发布 | ✅ 企微 + Web 双渠道发布状态管理 | 额外优势 | |
|||
|
|||
### 存在差距的关键维度 |
|||
|
|||
| # | 维度 | 差距等级 | 影响 | |
|||
|---|------|---------|------| |
|||
| 1 | 版本快照 / 发布不可变 | 🔴 严重 | 发布后编辑直接影响线上服务 | |
|||
| 2 | 流式输出 (SSE) | 🔴 严重 | 长流程用户体验极差,无法实时看到结果 | |
|||
| 3 | 统一 API 网关 + App API Key | 🟠 高 | 无法被外部系统调用,无法做 API 市场 | |
|||
| 4 | 工具 Schema 标准化 | 🟠 高 | 无法运行时扩展工具,无参数校验 | |
|||
| 5 | Flow 节点 Memory | 🟠 高 | LLM 节点无上下文记忆,无法多轮对话 | |
|||
| 6 | 变量类型系统 | 🟡 中 | 复杂业务逻辑难以表达 | |
|||
| 7 | 执行监控与可观测性 | 🟡 中 | 无法追溯执行版本,缺少 token/延迟指标 | |
|||
|
|||
--- |
|||
|
|||
## 二、逐维度详细对比 |
|||
|
|||
### 1. 版本快照 / 发布不可变(🔴 严重) |
|||
|
|||
**Dify 的做法:** |
|||
- 点击"发布"时,将当前草稿 JSON 创建一份**版本快照**(snapshot),存入独立的 `workflow_versions` 表 |
|||
- `FlowDefinition` 有 `published_version` 字段,指向当前生效的版本 |
|||
- 执行引擎加载的是 `published_version` 对应的 JSON,而非草稿 |
|||
- 编辑草稿不影响已发布版本,回滚只需切换 `published_version` 指针 |
|||
|
|||
**我们的现状:** |
|||
- `FlowDefinition` 只有 `version` 计数器(int),没有 `published_version` 字段 |
|||
- 发布仅修改 `status="published"`,不创建快照 |
|||
- **编辑草稿直接修改 `definition_json`,已发布的服务立即受影响** |
|||
- `FlowExecution` 不记录执行时的版本号,无法追溯 |
|||
|
|||
**需要补齐:** |
|||
``` |
|||
新增模型:FlowVersion |
|||
- id: UUID |
|||
- flow_id: FK → FlowDefinition |
|||
- version: int |
|||
- definition_json: JSON(快照) |
|||
- created_by: UUID |
|||
- created_at: datetime |
|||
|
|||
修改模型:FlowDefinition |
|||
- 新增 published_version_id: FK → FlowVersion(nullable) |
|||
- 新增 draft_version: int(草稿版本号) |
|||
|
|||
发布逻辑改造: |
|||
- publish → 创建 FlowVersion 快照 → 设置 published_version_id |
|||
- execute → 加载 published_version.definition_json(而非草稿) |
|||
- 编辑 → 只修改草稿,不影响 published_version |
|||
- 回滚 → 切换 published_version_id 指针 |
|||
``` |
|||
|
|||
--- |
|||
|
|||
### 2. 流式输出 SSE(🔴 严重) |
|||
|
|||
**Dify 的做法:** |
|||
- 统一 API 支持 `response_mode: "streaming"`,返回 SSE 事件流 |
|||
- 事件类型:`workflow_started` → `node_started` → `node_finished` → `workflow_finished` |
|||
- LLM 节点支持 token-by-token 实时推送(`text_chunk` 事件) |
|||
- 前端通过 EventSource 实时渲染 |
|||
|
|||
**我们的现状:** |
|||
- `FlowEngine.execute()` 返回最终 `Msg`,无中间状态 |
|||
- `LLMNodeAgent` 虽然配置了 `stream=True`,但 `model(prompt)` 等待完整响应 |
|||
- WebSocket 端点仅 echo,未与 Flow 引擎集成 |
|||
- 没有 SSE 端点 |
|||
|
|||
**需要补齐:** |
|||
``` |
|||
新增 SSE 端点:GET /api/chat/stream/{flow_id} |
|||
- 接收 query 参数:message, session_id |
|||
- 返回 text/event-stream |
|||
- 事件格式: |
|||
event: node_started |
|||
data: {"node_id": "xxx", "node_type": "llm", "label": "生成摘要"} |
|||
|
|||
event: text_chunk |
|||
data: {"node_id": "xxx", "content": "根据"} |
|||
|
|||
event: node_finished |
|||
data: {"node_id": "xxx", "output": "..."} |
|||
|
|||
event: workflow_finished |
|||
data: {"output": "最终结果"} |
|||
|
|||
FlowEngine 改造: |
|||
- execute() 接受可选的 callback: Callable[[str, dict], None] |
|||
- 每个节点执行前后调用 callback("node_started"/"node_finished", data) |
|||
- LLMNodeAgent.reply() 改为 async generator,yield token |
|||
``` |
|||
|
|||
--- |
|||
|
|||
### 3. 统一 API 网关 + App API Key(🟠 高) |
|||
|
|||
**Dify 的做法:** |
|||
- 每个 App 有独立的 API Key(`app-xxxxxxxx`) |
|||
- 统一入口:`POST /v1/chat-messages`(对话型)、`POST /v1/workflows/run`(工作流型) |
|||
- 请求格式标准化:`{inputs: {}, query: "", response_mode: "blocking|streaming", user: "user-id"}` |
|||
- 无需用户登录,API Key 即认证 |
|||
|
|||
**我们的现状:** |
|||
- 所有 API 依赖 JWT 用户认证,无 App-level API Key |
|||
- 执行分散在 `/api/flow/definitions/{id}/execute` 和 `/api/chat/message/{id}` |
|||
- 无法被外部系统(如企微回调、第三方应用)直接调用 |
|||
|
|||
**需要补齐:** |
|||
``` |
|||
新增模型:FlowApiKey |
|||
- id: UUID |
|||
- flow_id: FK → FlowDefinition |
|||
- key_hash: str(sha256) |
|||
- key_prefix: str(前8位,用于展示) |
|||
- name: str |
|||
- created_by: UUID |
|||
- created_at: datetime |
|||
- last_used_at: datetime(nullable) |
|||
|
|||
新增统一网关端点: |
|||
POST /v1/chat-messages → 对话型 Flow(自动找 trigger → llm → output 路径) |
|||
POST /v1/workflows/run → 工作流型 Flow(完整 DAG 执行) |
|||
|
|||
认证方式: |
|||
Header: Authorization: Bearer app-xxxxxxxx |
|||
→ 查 FlowApiKey 表 → 获取 flow_id → 加载 published_version → 执行 |
|||
``` |
|||
|
|||
--- |
|||
|
|||
### 4. 工具 Schema 标准化(🟠 高) |
|||
|
|||
**Dify 的做法:** |
|||
- 所有工具(内置/自定义 API/MCP)统一转换为 OpenAI Function Calling 的 JSON Schema |
|||
- Schema 包含:name, description, parameters(JSON Schema 格式,含 type/enum/description) |
|||
- 注入 LLM 时,工具 Schema 作为 `tools` 参数传入 |
|||
- 用户可在前端自定义 API 工具(填 URL、Method、参数结构) |
|||
|
|||
**我们的现状:** |
|||
- `ToolNodeAgent._TOOL_REGISTRY` 硬编码 12 个工具函数 |
|||
- 工具函数只有 Python 签名,无结构化 Schema 描述 |
|||
- `tool_params: dict = {}` 无校验 |
|||
- 无法运行时扩展工具 |
|||
|
|||
**需要补齐:** |
|||
``` |
|||
工具 Schema 标准化格式: |
|||
{ |
|||
"name": "send_notification", |
|||
"description": "发送企业微信通知给指定用户", |
|||
"parameters": { |
|||
"type": "object", |
|||
"properties": { |
|||
"to_user": {"type": "string", "description": "接收人用户ID"}, |
|||
"message": {"type": "string", "description": "消息内容"} |
|||
}, |
|||
"required": ["to_user", "message"] |
|||
} |
|||
} |
|||
|
|||
改造 ToolNodeAgent: |
|||
- _TOOL_REGISTRY 改为 _TOOL_SCHEMA_REGISTRY: dict[str, dict] |
|||
- 每个工具注册时同时注册 Schema |
|||
- 调用前基于 Schema 校验 tool_params |
|||
- LLM 调用时将 Schema 作为 tools 参数传入 |
|||
|
|||
新增自定义 API 工具: |
|||
- 用户可填入 OpenAPI/Swagger URL |
|||
- 系统自动解析为标准 Schema 并注册 |
|||
- 执行时通过 httpx 调用 |
|||
``` |
|||
|
|||
--- |
|||
|
|||
### 5. Flow 节点 Memory(🟠 高) |
|||
|
|||
**Dify 的做法:** |
|||
- 每个 App 有独立的对话记忆(窗口记忆/摘要记忆) |
|||
- 记忆在多次调用间持久化(Redis/数据库) |
|||
- LLM 节点自动注入历史对话上下文 |
|||
|
|||
**我们的现状:** |
|||
- `UserIsolatedMemory` 存在但**未在 Flow 节点中使用** |
|||
- Flow 中的 LLM 节点每次调用都是无状态的 |
|||
- `ChatMessage` 表存储了历史消息,但 Flow 执行时不读取 |
|||
|
|||
**需要补齐:** |
|||
``` |
|||
FlowEngine 改造: |
|||
- execute() 接受 session_id 参数 |
|||
- 创建 FlowSessionMemory(session_id, user_id) |
|||
- LLM 节点执行前注入历史消息 |
|||
|
|||
新增 FlowSessionMemory: |
|||
- 基于 ChatMessage 表持久化 |
|||
- 按session_id + user_id 隔离 |
|||
- 支持窗口大小配置(最近 N 条) |
|||
- 支持摘要模式(超过窗口时调用 LLM 生成摘要) |
|||
``` |
|||
|
|||
--- |
|||
|
|||
### 6. 变量类型系统(🟡 中) |
|||
|
|||
**Dify 的做法:** |
|||
- 完整的变量面板:输入变量、环境变量、会话变量、上游节点变量 |
|||
- 变量类型:string/number/array/object/file |
|||
- 支持 Jinja2 模板、类型转换、默认值 |
|||
- "变量聚合"节点:汇聚并行分支输出 |
|||
- "迭代"节点:对列表逐项处理 |
|||
|
|||
**我们的现状:** |
|||
- 仅有 `{{node_id.output}}` 和 `{{trigger.field}}` 模板 |
|||
- 所有值都是 str,无类型系统 |
|||
- 无并行汇聚、无迭代节点 |
|||
|
|||
**需要补齐:** |
|||
``` |
|||
变量系统升级: |
|||
- 节点输出增加类型标注(string/number/array/object) |
|||
- 模板解析支持类型转换和默认值 |
|||
- 新增"变量聚合"节点(ParallelMergeNode) |
|||
- Loop 节点支持迭代数组模式 |
|||
- 输入变量面板(Flow 级别的入参定义) |
|||
``` |
|||
|
|||
--- |
|||
|
|||
### 7. 执行监控与可观测性(🟡 中) |
|||
|
|||
**Dify 的做法:** |
|||
- FlowExecution 记录执行时的版本号 |
|||
- 统计 token 用量、延迟、费用 |
|||
- 执行日志可按 App/时间/状态筛选 |
|||
- 失败重试机制 |
|||
|
|||
**我们的现状:** |
|||
- `FlowExecution` 不记录版本号 |
|||
- 无 token/延迟统计 |
|||
- 无失败重试 |
|||
|
|||
**需要补齐:** |
|||
``` |
|||
FlowExecution 增加字段: |
|||
- version: int(执行时的版本号) |
|||
- token_usage: JSON(prompt_tokens, completion_tokens, total_tokens) |
|||
- latency_ms: int |
|||
- error_message: str(nullable) |
|||
|
|||
执行引擎改造: |
|||
- 记录每个节点的 token 用量和耗时 |
|||
- 汇总到 FlowExecution |
|||
- 失败节点支持重试配置 |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## 三、升级路线图 |
|||
|
|||
### Phase 1 — 发布安全基础(P0,1-2周) |
|||
|
|||
| 任务 | 改动范围 | |
|||
|------|---------| |
|||
| 新增 FlowVersion 模型 + 迁移 | models, database | |
|||
| FlowDefinition 增加 published_version_id | models, schemas | |
|||
| 发布逻辑改造:创建快照 | flow_engine/router.py | |
|||
| 执行逻辑改造:加载 published_version | flow_engine/engine.py, chat/router.py | |
|||
| FlowExecution 记录版本号 | models, flow_engine/router.py | |
|||
|
|||
### Phase 2 — 用户体验核心(P0,2-3周) |
|||
|
|||
| 任务 | 改动范围 | |
|||
|------|---------| |
|||
| SSE 流式输出端点 | chat/router.py(新增) | |
|||
| FlowEngine callback 机制 | flow_engine/engine.py | |
|||
| LLMNodeAgent async generator 改造 | flow_engine/engine.py | |
|||
| 前端 EventSource 集成 | FlowChat.vue(新增) | |
|||
| Flow 节点 Memory 集成 | flow_engine/engine.py, 新增 FlowSessionMemory | |
|||
|
|||
### Phase 3 — 服务化能力(P1,2-3周) |
|||
|
|||
| 任务 | 改动范围 | |
|||
|------|---------| |
|||
| FlowApiKey 模型 + CRUD | models, schemas, 新增 router | |
|||
| 统一 API 网关 `/v1/chat-messages`, `/v1/workflows/run` | 新增 gateway router | |
|||
| API Key 认证中间件 | middleware | |
|||
| 工具 Schema 标准化 | tools/*.py, ToolNodeAgent | |
|||
| 自定义 API 工具(OpenAPI 导入) | 新增 custom_tool 模块 | |
|||
|
|||
### Phase 4 — 高级能力(P2,2-3周) |
|||
|
|||
| 任务 | 改动范围 | |
|||
|------|---------| |
|||
| 变量类型系统 | schemas, engine.py | |
|||
| 变量聚合节点 | 新增 ParallelMergeNodeAgent | |
|||
| Loop 迭代数组模式 | LoopNodeAgent | |
|||
| 执行监控指标 | FlowExecution, engine.py | |
|||
| 工具认证改造(去掉硬编码) | tools/*.py | |
|||
|
|||
--- |
|||
|
|||
## 四、架构哲学对齐度总结 |
|||
|
|||
| Dify 架构哲学 | 我们的现状 | 对齐度 | |
|||
|--------------|-----------|--------| |
|||
| **数据驱动**:复杂 AI 逻辑抽象为可配置参数 | ✅ 已实现。9 种节点类型,每种有独立 config | 90% | |
|||
| **统一 Runner**:一套引擎解析千种 JSON 组合 | ⚠️ 部分实现。引擎存在但缺少流式/Memory/版本快照 | 60% | |
|||
| **插件化架构**:Tool/Model 实现高度抽象接口 | ❌ 未实现。工具硬编码,无标准 Schema,无自动发现 | 20% | |
|||
|
|||
**核心差距一句话总结:我们的"配置即数据"和"零部署"思路与 Dify 完全一致,但缺少"发布不可变"(版本快照)、"实时反馈"(SSE 流式)、"开放接入"(统一网关+API Key)和"插件化工具"(标准 Schema)四大关键能力,导致无法真正实现"无代码秒级发布,即刻可用"的完整体验。** |
|||
|
|||
补齐 Phase 1 + Phase 2 后,即可达到 Dify 约 80% 的核心能力。 |
|||
@ -0,0 +1,556 @@ |
|||
# PLAN7 — 自定义 API 工具导入 + 前端 EventSource 流式聊天组件 |
|||
|
|||
## 一、现状与差距 |
|||
|
|||
PLAN6 完成后,系统已具备: |
|||
- ✅ 版本快照(FlowVersion) |
|||
- ✅ SSE 流式输出(后端) |
|||
- ✅ 统一 API 网关(/v1/chat-messages, /v1/workflows/run) |
|||
- ✅ API Key 认证 |
|||
- ✅ 工具 Schema 标准化(内置工具) |
|||
- ✅ Flow 节点 Memory |
|||
- ✅ ParallelMergeNodeAgent + Loop 数组迭代 |
|||
|
|||
**剩余 15% 差距:** |
|||
1. **自定义 API 工具导入**:用户无法在前端填入第三方 OpenAPI/Swagger URL,系统自动解析为工具 Schema 并注册到 ToolNodeAgent |
|||
2. **前端 EventSource 聊天组件**:前端没有支持 SSE 的聊天界面,无法实时看到流式输出 |
|||
|
|||
--- |
|||
|
|||
## 二、功能 1:自定义 API 工具导入(OpenAPI/Swagger 解析) |
|||
|
|||
### 2.1 目标 |
|||
用户在前端输入第三方 API 的 OpenAPI/Swagger URL(如 `https://api.example.com/openapi.json`),后端自动: |
|||
1. 下载并解析 OpenAPI 文档 |
|||
2. 提取每个 endpoint 的 method、path、parameters、description |
|||
3. 转换为 OpenAI Function Calling Schema 格式 |
|||
4. 注册为 Flow 可用的自定义工具 |
|||
5. 执行时通过 httpx 动态调用 |
|||
|
|||
### 2.2 数据模型 |
|||
|
|||
```python |
|||
# models/__init__.py 新增 |
|||
class CustomTool(Base): |
|||
__tablename__ = "custom_tools" |
|||
|
|||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) |
|||
name = Column(String(100), nullable=False) # 工具名称 |
|||
description = Column(Text) # 工具描述 |
|||
schema_json = Column(JSON, nullable=False) # OpenAI Function Calling Schema |
|||
endpoint_url = Column(String(500), nullable=False) # 基础 URL |
|||
method = Column(String(10), default="GET") # HTTP 方法 |
|||
path = Column(String(500)) # API 路径 |
|||
headers_json = Column(JSON, default=dict) # 固定请求头 |
|||
auth_type = Column(String(20), default="none") # none/api_key/oauth |
|||
auth_config = Column(JSON, default=dict) # 认证配置 |
|||
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id")) |
|||
is_active = Column(Boolean, default=True) |
|||
created_at = Column(DateTime, default=datetime.utcnow) |
|||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) |
|||
``` |
|||
|
|||
### 2.3 Schema |
|||
|
|||
```python |
|||
# schemas/__init__.py 新增 |
|||
class CustomToolCreate(BaseModel): |
|||
name: str |
|||
description: str | None = None |
|||
openapi_url: str | None = None # 二选一:URL 或手动配置 |
|||
endpoint_url: str | None = None |
|||
method: str = "GET" |
|||
path: str = "" |
|||
headers: dict = {} |
|||
auth_type: str = "none" |
|||
auth_config: dict = {} |
|||
schema_json: dict | None = None # 手动传入 Schema |
|||
|
|||
class CustomToolOut(BaseModel): |
|||
id: uuid.UUID |
|||
name: str |
|||
description: str | None = None |
|||
schema_json: dict |
|||
endpoint_url: str |
|||
method: str |
|||
path: str |
|||
auth_type: str |
|||
is_active: bool |
|||
created_at: datetime | None = None |
|||
|
|||
class OpenAPIImportRequest(BaseModel): |
|||
openapi_url: str |
|||
base_url_override: str | None = None # 可选覆盖 base_url |
|||
``` |
|||
|
|||
### 2.4 后端实现 |
|||
|
|||
#### 模块:`backend/modules/custom_tool/` |
|||
|
|||
**`parser.py`** — OpenAPI 解析器 |
|||
```python |
|||
import json |
|||
import httpx |
|||
from typing import Any |
|||
|
|||
class OpenAPIParser: |
|||
def __init__(self, spec: dict): |
|||
self.spec = spec |
|||
self.base_url = spec.get("servers", [{}])[0].get("url", "") |
|||
|
|||
def parse_tools(self) -> list[dict]: |
|||
tools = [] |
|||
paths = self.spec.get("paths", {}) |
|||
for path, methods in paths.items(): |
|||
for method, operation in methods.items(): |
|||
if method in ("get", "post", "put", "delete", "patch"): |
|||
tool = self._parse_endpoint(path, method, operation) |
|||
if tool: |
|||
tools.append(tool) |
|||
return tools |
|||
|
|||
def _parse_endpoint(self, path: str, method: str, operation: dict) -> dict | None: |
|||
name = operation.get("operationId", f"{method}_{path.replace('/', '_').strip('_')}") |
|||
description = operation.get("summary", operation.get("description", f"{method.upper()} {path}")) |
|||
parameters = self._parse_parameters(operation) |
|||
return { |
|||
"name": name, |
|||
"description": description, |
|||
"parameters": { |
|||
"type": "object", |
|||
"properties": parameters, |
|||
"required": [p["name"] for p in operation.get("parameters", []) if p.get("required")], |
|||
}, |
|||
"path": path, |
|||
"method": method.upper(), |
|||
} |
|||
|
|||
def _parse_parameters(self, operation: dict) -> dict[str, Any]: |
|||
props = {} |
|||
for param in operation.get("parameters", []): |
|||
schema = param.get("schema", {}) |
|||
props[param["name"]] = { |
|||
"type": schema.get("type", "string"), |
|||
"description": param.get("description", ""), |
|||
} |
|||
if "enum" in schema: |
|||
props[param["name"]]["enum"] = schema["enum"] |
|||
# requestBody |
|||
body = operation.get("requestBody", {}).get("content", {}).get("application/json", {}).get("schema", {}) |
|||
if body: |
|||
for name, prop in body.get("properties", {}).items(): |
|||
props[name] = {"type": prop.get("type", "string"), "description": prop.get("description", "")} |
|||
return props |
|||
``` |
|||
|
|||
**`executor.py`** — 动态执行器 |
|||
```python |
|||
import httpx |
|||
import json |
|||
from typing import Any |
|||
|
|||
class CustomToolExecutor: |
|||
def __init__(self, tool: dict): |
|||
self.tool = tool |
|||
self.endpoint_url = tool["endpoint_url"] |
|||
self.method = tool["method"] |
|||
self.path = tool["path"] |
|||
self.headers = tool.get("headers_json", {}) |
|||
self.auth_type = tool.get("auth_type", "none") |
|||
self.auth_config = tool.get("auth_config", {}) |
|||
|
|||
async def execute(self, params: dict) -> str: |
|||
url = f"{self.endpoint_url.rstrip('/')}/{self.path.lstrip('/')}" |
|||
headers = dict(self.headers) |
|||
|
|||
if self.auth_type == "api_key": |
|||
key = self.auth_config.get("key", "") |
|||
loc = self.auth_config.get("location", "header") # header / query |
|||
name = self.auth_config.get("name", "X-API-Key") |
|||
if loc == "header": |
|||
headers[name] = key |
|||
else: |
|||
params[name] = key |
|||
|
|||
elif self.auth_type == "bearer": |
|||
headers["Authorization"] = f"Bearer {self.auth_config.get('token', '')}" |
|||
|
|||
async with httpx.AsyncClient(timeout=30) as client: |
|||
if self.method == "GET": |
|||
resp = await client.get(url, params=params, headers=headers) |
|||
else: |
|||
resp = await client.request(self.method, url, json=params, headers=headers) |
|||
|
|||
try: |
|||
data = resp.json() |
|||
return json.dumps(data, ensure_ascii=False, indent=2)[:2000] |
|||
except: |
|||
return resp.text[:2000] |
|||
``` |
|||
|
|||
**`router.py`** — CRUD + 导入端点 |
|||
```python |
|||
from fastapi import APIRouter, Depends, HTTPException, Request |
|||
from sqlalchemy import select |
|||
from sqlalchemy.ext.asyncio import AsyncSession |
|||
from database import get_db |
|||
from models import CustomTool |
|||
from schemas import CustomToolCreate, CustomToolOut, OpenAPIImportRequest |
|||
from .parser import OpenAPIParser |
|||
from .executor import CustomToolExecutor |
|||
import httpx |
|||
|
|||
router = APIRouter(prefix="/api/custom-tools", tags=["custom_tools"]) |
|||
|
|||
@router.post("/import-openapi") |
|||
async def import_openapi(req: OpenAPIImportRequest, db: AsyncSession = Depends(get_db)): |
|||
async with httpx.AsyncClient() as client: |
|||
resp = await client.get(req.openapi_url, timeout=30) |
|||
spec = resp.json() |
|||
|
|||
parser = OpenAPIParser(spec) |
|||
tools = parser.parse_tools() |
|||
base_url = req.base_url_override or parser.base_url |
|||
|
|||
created = [] |
|||
for t in tools: |
|||
tool = CustomTool( |
|||
name=t["name"], |
|||
description=t["description"], |
|||
schema_json=t["parameters"], |
|||
endpoint_url=base_url, |
|||
method=t["method"], |
|||
path=t["path"], |
|||
) |
|||
db.add(tool) |
|||
created.append(t["name"]) |
|||
await db.flush() |
|||
return {"code": 200, "message": f"成功导入 {len(created)} 个工具", "data": {"tools": created}} |
|||
|
|||
@router.post("/", response_model=CustomToolOut) |
|||
async def create_custom_tool(req: CustomToolCreate, db: AsyncSession = Depends(get_db)): |
|||
tool = CustomTool( |
|||
name=req.name, |
|||
description=req.description, |
|||
schema_json=req.schema_json or {}, |
|||
endpoint_url=req.endpoint_url or "", |
|||
method=req.method, |
|||
path=req.path, |
|||
headers_json=req.headers, |
|||
auth_type=req.auth_type, |
|||
auth_config=req.auth_config, |
|||
) |
|||
db.add(tool) |
|||
await db.flush() |
|||
return tool |
|||
|
|||
@router.get("/", response_model=list[CustomToolOut]) |
|||
async def list_custom_tools(db: AsyncSession = Depends(get_db)): |
|||
result = await db.execute(select(CustomTool).where(CustomTool.is_active == True)) |
|||
return result.scalars().all() |
|||
|
|||
@router.post("/{tool_id}/test") |
|||
async def test_custom_tool(tool_id: uuid.UUID, params: dict, db: AsyncSession = Depends(get_db)): |
|||
tool = await db.get(CustomTool, tool_id) |
|||
if not tool: |
|||
raise HTTPException(404, "工具不存在") |
|||
executor = CustomToolExecutor({ |
|||
"endpoint_url": tool.endpoint_url, |
|||
"method": tool.method, |
|||
"path": tool.path, |
|||
"headers_json": tool.headers_json, |
|||
"auth_type": tool.auth_type, |
|||
"auth_config": tool.auth_config, |
|||
}) |
|||
result = await executor.execute(params) |
|||
return {"code": 200, "data": {"result": result}} |
|||
``` |
|||
|
|||
### 2.5 ToolNodeAgent 集成 |
|||
|
|||
修改 `engine.py` 中 `_init_registry`,加载 CustomTool: |
|||
```python |
|||
@classmethod |
|||
def _init_registry(cls): |
|||
if cls._TOOL_REGISTRY: |
|||
return |
|||
# ... 原有内置工具注册 ... |
|||
# 加载自定义工具 |
|||
try: |
|||
from sqlalchemy import select |
|||
from database import SessionLocal |
|||
from models import CustomTool |
|||
# 注:这里需要用 sync session 或改为异步初始化 |
|||
except ImportError: |
|||
pass |
|||
``` |
|||
|
|||
**更优方案**:在 FlowEngine 初始化时异步加载自定义工具: |
|||
```python |
|||
async def _load_custom_tools(self, db: AsyncSession): |
|||
from models import CustomTool |
|||
result = await db.execute(select(CustomTool).where(CustomTool.is_active == True)) |
|||
for tool in result.scalars().all(): |
|||
ToolNodeAgent._TOOL_REGISTRY[tool.name] = lambda params, t=tool: CustomToolExecutor(t).execute(params) |
|||
ToolNodeAgent._TOOL_SCHEMAS[tool.name] = tool.schema_json |
|||
``` |
|||
|
|||
### 2.6 前端页面 |
|||
|
|||
**`frontend/src/views/tools/CustomToolManager.vue`** |
|||
- 表格:列出所有自定义工具(名称、方法、路径、认证方式) |
|||
- 导入按钮:弹出对话框,输入 OpenAPI URL → 点击导入 |
|||
- 测试按钮:填入参数 → 调用测试端点 → 显示结果 |
|||
- 手动创建:表单填写 name/endpoint/method/path/schema |
|||
|
|||
**`frontend/src/views/flow/node-configs/ToolConfig.vue`** 增强: |
|||
- 工具选择下拉框增加"自定义工具"分组 |
|||
- 选择自定义工具后,根据 schema_json 动态生成参数表单 |
|||
|
|||
--- |
|||
|
|||
## 三、功能 2:前端 EventSource 流式聊天组件 |
|||
|
|||
### 3.1 目标 |
|||
创建一个独立的聊天页面/组件,支持: |
|||
1. 通过 EventSource 连接后端 SSE 端点 |
|||
2. 实时显示 `workflow_started` → `node_started` → `text_chunk` → `workflow_finished` 事件 |
|||
3. 支持选择已发布的 Flow |
|||
4. 显示节点执行进度和中间结果 |
|||
5. 支持多轮对话(session_id 持久化) |
|||
|
|||
### 3.2 组件设计 |
|||
|
|||
**`frontend/src/views/chat/FlowChat.vue`** |
|||
```vue |
|||
<template> |
|||
<div class="flow-chat-page"> |
|||
<el-page-header @back="$router.back()" content="流式对话" /> |
|||
|
|||
<el-card style="margin-top: 20px"> |
|||
<el-form inline> |
|||
<el-form-item label="选择流"> |
|||
<el-select v-model="selectedFlowId" placeholder="选择已发布的流"> |
|||
<el-option v-for="f in publishedFlows" :key="f.id" :label="f.name" :value="f.id" /> |
|||
</el-select> |
|||
</el-form-item> |
|||
<el-form-item label="模式"> |
|||
<el-radio-group v-model="responseMode"> |
|||
<el-radio-button label="blocking">阻塞模式</el-radio-button> |
|||
<el-radio-button label="streaming">流式模式</el-radio-button> |
|||
</el-radio-group> |
|||
</el-form-item> |
|||
</el-form> |
|||
</el-card> |
|||
|
|||
<el-card style="margin-top: 20px; min-height: 400px"> |
|||
<div ref="chatContainer" class="chat-container"> |
|||
<div v-for="msg in messages" :key="msg.id" :class="['message', msg.role]"> |
|||
<div class="message-content" v-html="renderMarkdown(msg.content)" /> |
|||
<div v-if="msg.nodeResults" class="node-results"> |
|||
<el-collapse> |
|||
<el-collapse-item title="节点执行详情"> |
|||
<pre>{{ JSON.stringify(msg.nodeResults, null, 2) }}</pre> |
|||
</el-collapse-item> |
|||
</el-collapse> |
|||
</div> |
|||
</div> |
|||
<div v-if="streaming" class="message assistant streaming"> |
|||
<div class="message-content">{{ streamBuffer }}</div> |
|||
</div> |
|||
</div> |
|||
</el-card> |
|||
|
|||
<el-card style="margin-top: 20px"> |
|||
<el-input |
|||
v-model="inputText" |
|||
type="textarea" |
|||
:rows="3" |
|||
placeholder="输入消息..." |
|||
@keydown.enter.prevent="sendMessage" |
|||
/> |
|||
<el-button type="primary" @click="sendMessage" :loading="sending"> |
|||
发送 |
|||
</el-button> |
|||
</el-card> |
|||
</div> |
|||
</template> |
|||
|
|||
<script setup lang="ts"> |
|||
import { ref, onMounted } from 'vue' |
|||
import { ElMessage } from 'element-plus' |
|||
import { flowApi } from '@/api' |
|||
|
|||
const selectedFlowId = ref('') |
|||
const responseMode = ref('streaming') |
|||
const inputText = ref('') |
|||
const messages = ref<any[]>([]) |
|||
const streaming = ref(false) |
|||
const streamBuffer = ref('') |
|||
const sending = ref(false) |
|||
const publishedFlows = ref<any[]>([]) |
|||
const sessionId = ref(localStorage.getItem('flow_chat_session') || '') |
|||
|
|||
onMounted(async () => { |
|||
const res = await flowApi.getPublishedFlows() |
|||
publishedFlows.value = res.data?.data || [] |
|||
}) |
|||
|
|||
async function sendMessage() { |
|||
if (!selectedFlowId.value || !inputText.value.trim()) return |
|||
|
|||
const userMsg = { id: Date.now(), role: 'user', content: inputText.value } |
|||
messages.value.push(userMsg) |
|||
|
|||
if (responseMode.value === 'streaming') { |
|||
await sendStreaming() |
|||
} else { |
|||
await sendBlocking() |
|||
} |
|||
inputText.value = '' |
|||
} |
|||
|
|||
async function sendStreaming() { |
|||
streaming.value = true |
|||
streamBuffer.value = '' |
|||
sending.value = true |
|||
|
|||
const eventSource = new EventSource( |
|||
`/api/flow/definitions/${selectedFlowId.value}/stream`, |
|||
{ withCredentials: true } |
|||
) |
|||
|
|||
// POST 请求需要通过 fetch + ReadableStream 实现 |
|||
const response = await fetch(`/api/flow/definitions/${selectedFlowId.value}/stream`, { |
|||
method: 'POST', |
|||
headers: { 'Content-Type': 'application/json' }, |
|||
body: JSON.stringify({ |
|||
input: inputText.value, |
|||
session_id: sessionId.value, |
|||
}), |
|||
}) |
|||
|
|||
const reader = response.body?.getReader() |
|||
const decoder = new TextDecoder() |
|||
|
|||
while (reader) { |
|||
const { done, value } = await reader.read() |
|||
if (done) break |
|||
|
|||
const chunk = decoder.decode(value) |
|||
const lines = chunk.split('\n') |
|||
|
|||
for (const line of lines) { |
|||
if (line.startsWith('data: ')) { |
|||
const data = line.slice(6) |
|||
if (data === '[DONE]') { |
|||
streaming.value = false |
|||
sending.value = false |
|||
messages.value.push({ |
|||
id: Date.now(), |
|||
role: 'assistant', |
|||
content: streamBuffer.value, |
|||
}) |
|||
return |
|||
} |
|||
try { |
|||
const event = JSON.parse(data) |
|||
if (event.event === 'text_chunk') { |
|||
streamBuffer.value += event.data?.content || '' |
|||
} else if (event.event === 'workflow_finished') { |
|||
sessionId.value = event.data?.session_id || sessionId.value |
|||
localStorage.setItem('flow_chat_session', sessionId.value) |
|||
} |
|||
} catch {} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
async function sendBlocking() { |
|||
sending.value = true |
|||
try { |
|||
const res = await flowApi.executeFlow(selectedFlowId.value, { |
|||
input: inputText.value, |
|||
session_id: sessionId.value, |
|||
}) |
|||
messages.value.push({ |
|||
id: Date.now(), |
|||
role: 'assistant', |
|||
content: res.data?.data?.output || '无输出', |
|||
nodeResults: res.data?.data?.node_results, |
|||
}) |
|||
} catch (e) { |
|||
ElMessage.error('发送失败') |
|||
} finally { |
|||
sending.value = false |
|||
} |
|||
} |
|||
</script> |
|||
``` |
|||
|
|||
### 3.3 API 封装 |
|||
|
|||
**`frontend/src/api/index.ts`** 新增: |
|||
```typescript |
|||
// 流式执行用 fetch 而非 axios |
|||
executeFlowStream: (id: string, data: any) => { |
|||
return fetch(`/api/flow/definitions/${id}/stream`, { |
|||
method: 'POST', |
|||
headers: { 'Content-Type': 'application/json' }, |
|||
body: JSON.stringify(data), |
|||
}) |
|||
}, |
|||
|
|||
getPublishedFlows: () => api.get('/flow/market'), |
|||
``` |
|||
|
|||
### 3.4 路由注册 |
|||
|
|||
**`frontend/src/router/index.ts`** 新增: |
|||
```typescript |
|||
{ |
|||
path: '/chat/flow', |
|||
name: 'FlowChat', |
|||
component: () => import('@/views/chat/FlowChat.vue'), |
|||
meta: { title: '流式对话', requiresAuth: true }, |
|||
}, |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## 四、实施计划 |
|||
|
|||
| 阶段 | 任务 | 预计工时 | 优先级 | |
|||
|------|------|---------|--------| |
|||
| 1 | 自定义工具数据模型 + Schema | 2h | P0 | |
|||
| 2 | OpenAPI 解析器 (parser.py) | 4h | P0 | |
|||
| 3 | 自定义工具执行器 (executor.py) | 3h | P0 | |
|||
| 4 | 自定义工具 CRUD 路由 | 3h | P0 | |
|||
| 5 | ToolNodeAgent 集成自定义工具 | 2h | P0 | |
|||
| 6 | 前端 CustomToolManager 页面 | 4h | P1 | |
|||
| 7 | ToolConfig.vue 动态参数表单 | 3h | P1 | |
|||
| 8 | 前端 FlowChat.vue EventSource 组件 | 5h | P1 | |
|||
| 9 | 前端路由 + API 封装 | 2h | P1 | |
|||
| 10 | 集成测试 | 4h | P2 | |
|||
|
|||
**总计:约 32 工时(4 天)** |
|||
|
|||
--- |
|||
|
|||
## 五、验收标准 |
|||
|
|||
### 自定义 API 工具 |
|||
- [ ] 输入 `https://petstore.swagger.io/v2/swagger.json` 可成功导入所有 endpoint |
|||
- [ ] 导入的工具出现在 ToolConfig.vue 下拉框中 |
|||
- [ ] 选择自定义工具后,参数表单根据 Schema 动态生成 |
|||
- [ ] Flow 执行时,自定义工具通过 httpx 正确调用并返回结果 |
|||
- [ ] 支持 API Key / Bearer Token 认证 |
|||
|
|||
### 前端 EventSource 聊天 |
|||
- [ ] 打开 `/chat/flow` 页面,选择已发布 Flow |
|||
- [ ] 流式模式下,输入消息后实时看到文字逐字出现 |
|||
- [ ] 阻塞模式下,输入消息后等待完整结果一次性显示 |
|||
- [ ] 显示节点执行详情(折叠面板) |
|||
- [ ] 刷新页面后 session_id 保留,支持多轮对话 |
|||
- [ ] 切换 Flow 后 session_id 重置 |
|||
@ -0,0 +1,43 @@ |
|||
import hashlib |
|||
from datetime import datetime |
|||
from fastapi import Request, HTTPException |
|||
from sqlalchemy import select |
|||
from sqlalchemy.ext.asyncio import AsyncSession |
|||
from models import FlowApiKey |
|||
from database import get_db |
|||
|
|||
|
|||
async def authenticate_api_key(request: Request) -> dict: |
|||
auth_header = request.headers.get("Authorization", "") |
|||
if not auth_header.startswith("Bearer "): |
|||
raise HTTPException(401, "缺少认证信息") |
|||
|
|||
raw_key = auth_header[7:] |
|||
if not raw_key.startswith("flow-"): |
|||
raise HTTPException(401, "无效的API Key格式") |
|||
|
|||
key_hash = hashlib.sha256(raw_key.encode()).hexdigest() |
|||
|
|||
db_gen = get_db() |
|||
db: AsyncSession = await db_gen.__anext__() |
|||
try: |
|||
result = await db.execute( |
|||
select(FlowApiKey).where(FlowApiKey.key_hash == key_hash) |
|||
) |
|||
api_key = result.scalar_one_or_none() |
|||
if not api_key: |
|||
raise HTTPException(401, "API Key无效或已删除") |
|||
|
|||
api_key.last_used_at = datetime.utcnow() |
|||
await db.flush() |
|||
|
|||
return { |
|||
"flow_id": str(api_key.flow_id), |
|||
"api_key_id": str(api_key.id), |
|||
"auth_type": "api_key", |
|||
} |
|||
finally: |
|||
try: |
|||
await db_gen.__anext__() |
|||
except StopAsyncIteration: |
|||
pass |
|||
@ -0,0 +1,99 @@ |
|||
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect, Request |
|||
from sqlalchemy import select |
|||
from sqlalchemy.ext.asyncio import AsyncSession |
|||
from database import get_db |
|||
from models import FlowDefinition, FlowVersion |
|||
from modules.flow_engine.engine import FlowEngine |
|||
from agentscope.message import Msg |
|||
from websocket_manager import ws_manager |
|||
|
|||
router = APIRouter(prefix="/api/chat", tags=["chat"]) |
|||
|
|||
|
|||
@router.websocket("/ws") |
|||
async def chat_websocket(websocket: WebSocket): |
|||
user_id = websocket.query_params.get("user_id", "anonymous") |
|||
await ws_manager.connect(websocket, user_id) |
|||
try: |
|||
while True: |
|||
data = await websocket.receive_text() |
|||
await ws_manager.send_to_user(user_id, {"type": "echo", "message": data}) |
|||
except WebSocketDisconnect: |
|||
ws_manager.disconnect(websocket, user_id) |
|||
|
|||
|
|||
@router.post("/message/{flow_id}") |
|||
async def chat_message( |
|||
flow_id: str, |
|||
request: Request, |
|||
payload: dict, |
|||
db: AsyncSession = Depends(get_db), |
|||
): |
|||
try: |
|||
import uuid as _uuid |
|||
fid = _uuid.UUID(flow_id) |
|||
except ValueError: |
|||
raise HTTPException(400, "无效的流ID") |
|||
|
|||
flow_result = await db.execute(select(FlowDefinition).where(FlowDefinition.id == fid)) |
|||
flow = flow_result.scalar_one_or_none() |
|||
if not flow or flow.status != "published": |
|||
raise HTTPException(404, "流不存在或未发布") |
|||
|
|||
definition = flow.definition_json |
|||
if flow.published_version_id: |
|||
ver_result = await db.execute(select(FlowVersion).where(FlowVersion.id == flow.published_version_id)) |
|||
published = ver_result.scalar_one_or_none() |
|||
if published and published.definition_json: |
|||
import json |
|||
definition = json.loads(json.dumps(published.definition_json)) |
|||
|
|||
user_ctx = request.state.user |
|||
input_text = payload.get("message", payload.get("query", "")) |
|||
if not input_text: |
|||
raise HTTPException(400, "请输入消息内容") |
|||
|
|||
engine = FlowEngine(definition) |
|||
input_msg = Msg(name="user", content=input_text, role="user") |
|||
context = { |
|||
"user_id": user_ctx.get("id", "web_user"), |
|||
"username": user_ctx.get("username", "网页访客"), |
|||
"trigger_data": {"channel": "web_chat"}, |
|||
"_node_results": {}, |
|||
"session_id": payload.get("session_id", str(uuid.uuid4())), |
|||
} |
|||
|
|||
try: |
|||
result_msg = await engine.execute(input_msg, context) |
|||
output_text = result_msg.get_text_content() if hasattr(result_msg, 'get_text_content') else str(result_msg) |
|||
|
|||
return { |
|||
"code": 200, |
|||
"data": { |
|||
"reply": output_text, |
|||
"node_results": context.get("_node_results", {}), |
|||
}, |
|||
} |
|||
except Exception as e: |
|||
raise HTTPException(500, f"流执行失败: {str(e)}") |
|||
|
|||
|
|||
@router.get("/flows") |
|||
async def list_chat_flows(request: Request, db: AsyncSession = Depends(get_db)): |
|||
result = await db.execute( |
|||
select(FlowDefinition).where(FlowDefinition.status == "published") |
|||
) |
|||
flows = result.scalars().all() |
|||
return { |
|||
"code": 200, |
|||
"data": [ |
|||
{ |
|||
"id": str(f.id), |
|||
"name": f.name, |
|||
"description": f.description, |
|||
"published_to_web": f.published_to_web, |
|||
"published_to_wecom": f.published_to_wecom, |
|||
} |
|||
for f in flows |
|||
], |
|||
} |
|||
@ -0,0 +1,3 @@ |
|||
from .router import router |
|||
|
|||
__all__ = ["router"] |
|||
@ -0,0 +1,43 @@ |
|||
import httpx |
|||
import json |
|||
|
|||
class CustomToolExecutor: |
|||
def __init__(self, tool_def: dict): |
|||
self.endpoint_url = tool_def.get("endpoint_url", "") |
|||
self.method = tool_def.get("method", "GET") |
|||
self.path = tool_def.get("path", "") |
|||
self.headers = dict(tool_def.get("headers_json", {})) |
|||
self.auth_type = tool_def.get("auth_type", "none") |
|||
self.auth_config = dict(tool_def.get("auth_config", {})) |
|||
self.timeout = int(tool_def.get("timeout", 30)) |
|||
|
|||
async def execute(self, params: dict) -> str: |
|||
url = f"{self.endpoint_url.rstrip('/')}/{self.path.lstrip('/')}" |
|||
headers = dict(self.headers) |
|||
req_params = dict(params) |
|||
|
|||
if self.auth_type == "api_key": |
|||
key = self.auth_config.get("key", "") |
|||
loc = self.auth_config.get("location", "header") |
|||
name = self.auth_config.get("name", "X-API-Key") |
|||
if loc == "header": |
|||
headers[name] = key |
|||
else: |
|||
req_params[name] = key |
|||
elif self.auth_type == "bearer": |
|||
headers["Authorization"] = f"Bearer {self.auth_config.get('token', '')}" |
|||
|
|||
timeout = httpx.Timeout(self.timeout) |
|||
async with httpx.AsyncClient(timeout=timeout) as client: |
|||
if self.method == "GET": |
|||
resp = await client.get(url, params=req_params, headers=headers) |
|||
else: |
|||
resp = await client.request( |
|||
self.method, url, json=req_params, headers=headers |
|||
) |
|||
|
|||
try: |
|||
data = resp.json() |
|||
return json.dumps(data, ensure_ascii=False, indent=2)[:4000] |
|||
except Exception: |
|||
return resp.text[:4000] |
|||
@ -0,0 +1,81 @@ |
|||
import json |
|||
from typing import Any |
|||
|
|||
class OpenAPIParser: |
|||
def __init__(self, spec: dict): |
|||
self.spec = spec |
|||
self.base_url = "" |
|||
servers = spec.get("servers", [{}]) |
|||
if servers and isinstance(servers, list): |
|||
self.base_url = servers[0].get("url", "") |
|||
|
|||
def parse_tools(self) -> list[dict]: |
|||
tools = [] |
|||
paths = self.spec.get("paths", {}) |
|||
for path, methods in paths.items(): |
|||
if not isinstance(methods, dict): |
|||
continue |
|||
for method, operation in methods.items(): |
|||
if method in ("get", "post", "put", "delete", "patch") and isinstance(operation, dict): |
|||
tool = self._parse_endpoint(path, method, operation) |
|||
if tool: |
|||
tools.append(tool) |
|||
return tools |
|||
|
|||
def _parse_endpoint(self, path: str, method: str, operation: dict) -> dict | None: |
|||
op_id = operation.get("operationId", "") |
|||
if not op_id: |
|||
op_id = f"{method}_{path.replace('/', '_').strip('_')}" |
|||
|
|||
description = operation.get("summary") or operation.get("description") or f"{method.upper()} {path}" |
|||
properties = self._parse_parameters(operation) |
|||
required = [] |
|||
for param in operation.get("parameters", []): |
|||
if isinstance(param, dict) and param.get("required"): |
|||
required.append(param["name"]) |
|||
|
|||
return { |
|||
"name": op_id, |
|||
"description": description, |
|||
"parameters": { |
|||
"type": "object", |
|||
"properties": properties, |
|||
"required": required, |
|||
}, |
|||
"path": path, |
|||
"method": method.upper(), |
|||
} |
|||
|
|||
def _parse_parameters(self, operation: dict) -> dict[str, Any]: |
|||
props = {} |
|||
for param in operation.get("parameters", []): |
|||
if not isinstance(param, dict): |
|||
continue |
|||
pname = param.get("name", "") |
|||
if not pname: |
|||
continue |
|||
schema = param.get("schema", {}) |
|||
if not isinstance(schema, dict): |
|||
schema = {} |
|||
props[pname] = { |
|||
"type": schema.get("type", "string"), |
|||
"description": param.get("description", ""), |
|||
} |
|||
if "enum" in schema: |
|||
props[pname]["enum"] = schema["enum"] |
|||
|
|||
body = ( |
|||
operation.get("requestBody", {}) |
|||
.get("content", {}) |
|||
.get("application/json", {}) |
|||
.get("schema", {}) |
|||
) |
|||
if isinstance(body, dict): |
|||
for name, prop in body.get("properties", {}).items(): |
|||
if isinstance(prop, dict): |
|||
props[name] = { |
|||
"type": prop.get("type", "string"), |
|||
"description": prop.get("description", ""), |
|||
} |
|||
|
|||
return props |
|||
@ -0,0 +1,249 @@ |
|||
import uuid |
|||
import httpx |
|||
from fastapi import APIRouter, Depends, HTTPException, Request |
|||
from sqlalchemy import select |
|||
from sqlalchemy.ext.asyncio import AsyncSession |
|||
from database import get_db |
|||
from models import CustomTool |
|||
from schemas import CustomToolCreate, CustomToolUpdate, CustomToolOut, OpenAPIImportRequest |
|||
from modules.custom_tool.parser import OpenAPIParser |
|||
from modules.custom_tool.executor import CustomToolExecutor |
|||
from modules.flow_engine.engine import ToolNodeAgent |
|||
from dependencies import get_current_user |
|||
import logging |
|||
|
|||
logger = logging.getLogger(__name__) |
|||
|
|||
router = APIRouter(prefix="/api/custom-tools", tags=["custom_tools"]) |
|||
|
|||
|
|||
@router.post("/import-openapi") |
|||
async def import_openapi(req: OpenAPIImportRequest, request: Request, db: AsyncSession = Depends(get_db)): |
|||
user_ctx = request.state.user |
|||
try: |
|||
async with httpx.AsyncClient(timeout=30) as client: |
|||
resp = await client.get(req.openapi_url) |
|||
resp.raise_for_status() |
|||
spec = resp.json() |
|||
except httpx.HTTPError as e: |
|||
raise HTTPException(400, f"获取 OpenAPI 文档失败: {e}") |
|||
except ValueError: |
|||
raise HTTPException(400, "OpenAPI 文档不是有效的 JSON 格式") |
|||
|
|||
parser = OpenAPIParser(spec) |
|||
tools = parser.parse_tools() |
|||
if not tools: |
|||
raise HTTPException(400, "未能从 OpenAPI 文档中解析出任何工具") |
|||
|
|||
base_url = req.base_url_override or parser.base_url |
|||
if not base_url: |
|||
raise HTTPException(400, "未能确定 API 基础 URL,请提供 base_url_override") |
|||
|
|||
created = [] |
|||
for t in tools: |
|||
existing = await db.execute( |
|||
select(CustomTool).where(CustomTool.name == t["name"]) |
|||
) |
|||
if existing.scalar_one_or_none(): |
|||
continue |
|||
|
|||
tool = CustomTool( |
|||
name=t["name"], |
|||
description=t["description"], |
|||
schema_json=t["parameters"], |
|||
endpoint_url=base_url, |
|||
method=t["method"], |
|||
path=t["path"], |
|||
created_by=uuid.UUID(user_ctx["id"]), |
|||
) |
|||
db.add(tool) |
|||
created.append(t["name"]) |
|||
|
|||
ToolNodeAgent.register_custom_tool( |
|||
t["name"], |
|||
t["parameters"], |
|||
{ |
|||
"endpoint_url": base_url, |
|||
"method": t["method"], |
|||
"path": t["path"], |
|||
"headers_json": {}, |
|||
"auth_type": "none", |
|||
"auth_config": {}, |
|||
"timeout": 30, |
|||
}, |
|||
) |
|||
|
|||
await db.flush() |
|||
return {"code": 200, "message": f"成功导入 {len(created)} 个工具", "data": {"tools": created}} |
|||
|
|||
|
|||
@router.post("/", response_model=CustomToolOut) |
|||
async def create_custom_tool(req: CustomToolCreate, request: Request, db: AsyncSession = Depends(get_db)): |
|||
user_ctx = request.state.user |
|||
user_id = uuid.UUID(user_ctx["id"]) |
|||
|
|||
if req.openapi_url: |
|||
try: |
|||
async with httpx.AsyncClient(timeout=30) as client: |
|||
resp = await client.get(req.openapi_url) |
|||
resp.raise_for_status() |
|||
spec = resp.json() |
|||
except Exception as e: |
|||
raise HTTPException(400, f"获取 OpenAPI 文档失败: {e}") |
|||
|
|||
parser = OpenAPIParser(spec) |
|||
tools = parser.parse_tools() |
|||
|
|||
created_tool = None |
|||
for t in tools: |
|||
if t["name"] == req.name or (not req.name and tools): |
|||
existing = await db.execute( |
|||
select(CustomTool).where(CustomTool.name == t["name"]) |
|||
) |
|||
if existing.scalar_one_or_none(): |
|||
continue |
|||
tool = CustomTool( |
|||
name=t["name"], |
|||
description=t["description"], |
|||
schema_json=t["parameters"], |
|||
endpoint_url=parser.base_url, |
|||
method=t["method"], |
|||
path=t["path"], |
|||
created_by=user_id, |
|||
) |
|||
db.add(tool) |
|||
created_tool = tool |
|||
break |
|||
|
|||
if not created_tool: |
|||
raise HTTPException(400, "未找到匹配的工具") |
|||
|
|||
await db.flush() |
|||
return created_tool |
|||
|
|||
schema_json = req.schema_json or {} |
|||
if not schema_json and req.endpoint_url: |
|||
schema_json = { |
|||
"type": "object", |
|||
"properties": {}, |
|||
"description": req.description or "", |
|||
} |
|||
|
|||
tool = CustomTool( |
|||
name=req.name, |
|||
description=req.description, |
|||
schema_json=schema_json, |
|||
endpoint_url=req.endpoint_url or "", |
|||
method=req.method, |
|||
path=req.path, |
|||
headers_json=req.headers, |
|||
auth_type=req.auth_type, |
|||
auth_config=req.auth_config, |
|||
created_by=user_id, |
|||
) |
|||
db.add(tool) |
|||
ToolNodeAgent.register_custom_tool( |
|||
req.name, |
|||
schema_json, |
|||
{ |
|||
"endpoint_url": req.endpoint_url or "", |
|||
"method": req.method, |
|||
"path": req.path, |
|||
"headers_json": req.headers, |
|||
"auth_type": req.auth_type, |
|||
"auth_config": req.auth_config, |
|||
"timeout": 30, |
|||
}, |
|||
) |
|||
await db.flush() |
|||
return tool |
|||
|
|||
|
|||
@router.get("/", response_model=list[CustomToolOut]) |
|||
async def list_custom_tools(db: AsyncSession = Depends(get_db)): |
|||
result = await db.execute( |
|||
select(CustomTool).where(CustomTool.is_active == True).order_by(CustomTool.updated_at.desc()) |
|||
) |
|||
return result.scalars().all() |
|||
|
|||
|
|||
@router.get("/{tool_id}", response_model=CustomToolOut) |
|||
async def get_custom_tool(tool_id: uuid.UUID, db: AsyncSession = Depends(get_db)): |
|||
tool = await db.get(CustomTool, tool_id) |
|||
if not tool: |
|||
raise HTTPException(404, "工具不存在") |
|||
return tool |
|||
|
|||
|
|||
@router.put("/{tool_id}", response_model=CustomToolOut) |
|||
async def update_custom_tool(tool_id: uuid.UUID, req: CustomToolUpdate, db: AsyncSession = Depends(get_db)): |
|||
tool = await db.get(CustomTool, tool_id) |
|||
if not tool: |
|||
raise HTTPException(404, "工具不存在") |
|||
if req.name is not None: |
|||
tool.name = req.name |
|||
if req.description is not None: |
|||
tool.description = req.description |
|||
if req.endpoint_url is not None: |
|||
tool.endpoint_url = req.endpoint_url |
|||
if req.method is not None: |
|||
tool.method = req.method |
|||
if req.path is not None: |
|||
tool.path = req.path |
|||
if req.headers is not None: |
|||
tool.headers_json = req.headers |
|||
if req.auth_type is not None: |
|||
tool.auth_type = req.auth_type |
|||
if req.auth_config is not None: |
|||
tool.auth_config = req.auth_config |
|||
if req.schema_json is not None: |
|||
tool.schema_json = req.schema_json |
|||
if req.is_active is not None: |
|||
tool.is_active = req.is_active |
|||
await db.flush() |
|||
return tool |
|||
|
|||
|
|||
@router.delete("/{tool_id}") |
|||
async def delete_custom_tool(tool_id: uuid.UUID, db: AsyncSession = Depends(get_db)): |
|||
tool = await db.get(CustomTool, tool_id) |
|||
if not tool: |
|||
raise HTTPException(404, "工具不存在") |
|||
tool.is_active = False |
|||
await db.flush() |
|||
return {"code": 200, "message": "工具已停用"} |
|||
|
|||
|
|||
@router.post("/{tool_id}/test") |
|||
async def test_custom_tool(tool_id: uuid.UUID, params: dict = None, db: AsyncSession = Depends(get_db)): |
|||
tool = await db.get(CustomTool, tool_id) |
|||
if not tool: |
|||
raise HTTPException(404, "工具不存在") |
|||
if params is None: |
|||
params = {} |
|||
|
|||
executor = CustomToolExecutor({ |
|||
"endpoint_url": tool.endpoint_url, |
|||
"method": tool.method, |
|||
"path": tool.path, |
|||
"headers_json": tool.headers_json, |
|||
"auth_type": tool.auth_type, |
|||
"auth_config": tool.auth_config, |
|||
}) |
|||
try: |
|||
result = await executor.execute(params) |
|||
return {"code": 200, "data": {"result": result}} |
|||
except Exception as e: |
|||
raise HTTPException(500, f"工具执行失败: {str(e)}") |
|||
|
|||
|
|||
@router.get("/schemas/all") |
|||
async def get_all_tool_schemas(db: AsyncSession = Depends(get_db)): |
|||
result = await db.execute( |
|||
select(CustomTool).where(CustomTool.is_active == True) |
|||
) |
|||
tools = result.scalars().all() |
|||
schemas = {} |
|||
for t in tools: |
|||
schemas[t.name] = t.schema_json |
|||
return {"code": 200, "data": schemas} |
|||
@ -0,0 +1,256 @@ |
|||
import uuid |
|||
import time |
|||
import json |
|||
from datetime import datetime |
|||
from fastapi import APIRouter, Depends, HTTPException, Request, Query |
|||
from fastapi.responses import StreamingResponse |
|||
from sqlalchemy import select |
|||
from sqlalchemy.ext.asyncio import AsyncSession |
|||
from database import get_db |
|||
from models import FlowDefinition, FlowVersion, FlowExecution |
|||
from schemas import FlowChatMessageRequest |
|||
from modules.flow_engine.engine import FlowEngine |
|||
from agentscope.message import Msg |
|||
from middleware.apikey_auth import authenticate_api_key |
|||
from dependencies import get_current_user |
|||
import logging |
|||
|
|||
logger = logging.getLogger(__name__) |
|||
|
|||
gateway_router = APIRouter(prefix="/v1", tags=["gateway"]) |
|||
|
|||
|
|||
async def _resolve_auth(request: Request) -> dict: |
|||
auth_header = request.headers.get("Authorization", "") |
|||
if auth_header.startswith("Bearer flow-"): |
|||
return await authenticate_api_key(request) |
|||
try: |
|||
user = await get_current_user(request) |
|||
return {"user": user, "auth_type": "jwt"} |
|||
except Exception: |
|||
raise HTTPException(401, "认证失败: 请使用 Bearer Token 或 API Key") |
|||
|
|||
|
|||
async def _get_definition_for_execute(flow_id: uuid.UUID, db: AsyncSession) -> dict: |
|||
f = await db.get(FlowDefinition, flow_id) |
|||
if not f: |
|||
raise HTTPException(404, "流不存在") |
|||
if f.status != "published": |
|||
raise HTTPException(400, "流未发布") |
|||
|
|||
if f.published_version_id: |
|||
result = await db.execute(select(FlowVersion).where(FlowVersion.id == f.published_version_id)) |
|||
published = result.scalar_one_or_none() |
|||
if published: |
|||
return json.loads(json.dumps(published.definition_json)) |
|||
return f.definition_json |
|||
|
|||
|
|||
# ============================== 对话型流 ============================== |
|||
|
|||
|
|||
@gateway_router.post("/chat-messages") |
|||
async def chat_messages(request: Request, db: AsyncSession = Depends(get_db)): |
|||
auth = await _resolve_auth(request) |
|||
|
|||
body = await request.json() |
|||
query = body.get("query", "") |
|||
response_mode = body.get("response_mode", "blocking") |
|||
inputs = body.get("inputs", {}) |
|||
user = body.get("user", "anonymous") |
|||
session_id = body.get("conversation_id", body.get("session_id")) |
|||
|
|||
flow_id_str = body.get("flow_id") or inputs.get("flow_id") |
|||
if not flow_id_str: |
|||
raise HTTPException(400, "缺少 flow_id") |
|||
|
|||
flow_id = uuid.UUID(flow_id_str) |
|||
definition = await _get_definition_for_execute(flow_id, db) |
|||
f = await db.get(FlowDefinition, flow_id) |
|||
|
|||
input_text = query |
|||
if inputs: |
|||
extra = json.dumps(inputs, ensure_ascii=False) |
|||
if query: |
|||
input_text = f"{query}\n\n上下文数据:\n{extra}" |
|||
else: |
|||
input_text = extra |
|||
|
|||
user_id = "api" if auth.get("auth_type") == "api_key" else auth.get("user", {}).get("id", "api") |
|||
username = user |
|||
|
|||
if response_mode == "streaming": |
|||
return await _chat_stream(flow_id, definition, input_text, user_id, username, f, db) |
|||
|
|||
return await _chat_blocking(flow_id, definition, input_text, user_id, username, f, db) |
|||
|
|||
|
|||
async def _chat_blocking(flow_id, definition, input_text, user_id, username, flow, db): |
|||
engine = FlowEngine(definition) |
|||
input_msg = Msg(name="user", content=input_text, role="user") |
|||
context = {"user_id": user_id, "username": username, "_node_results": {}, "session_id": str(uuid.uuid4())} |
|||
|
|||
start_time = time.time() |
|||
try: |
|||
result_msg = await engine.execute(input_msg, context) |
|||
elapsed_ms = int((time.time() - start_time) * 1000) |
|||
output_text = result_msg.get_text_content() if hasattr(result_msg, 'get_text_content') else str(result_msg) |
|||
|
|||
execution = FlowExecution( |
|||
flow_id=flow.id, version=flow.version, |
|||
trigger_type="api", input_data={"query": input_text}, |
|||
output_data={"output": output_text}, status="completed", |
|||
latency_ms=elapsed_ms, finished_at=datetime.utcnow(), |
|||
) |
|||
db.add(execution) |
|||
|
|||
return { |
|||
"event": "message", |
|||
"id": str(uuid.uuid4()), |
|||
"answer": output_text, |
|||
"conversation_id": session_id or "", |
|||
"created_at": int(time.time()), |
|||
"metadata": { |
|||
"usage": {"latency_ms": elapsed_ms}, |
|||
"node_results": {k: str(v)[:200] for k, v in context.get("_node_results", {}).items()}, |
|||
}, |
|||
} |
|||
except Exception as e: |
|||
elapsed_ms = int((time.time() - start_time) * 1000) |
|||
execution = FlowExecution( |
|||
flow_id=flow.id, version=flow.version, |
|||
trigger_type="api", input_data={"query": input_text}, |
|||
status="failed", latency_ms=elapsed_ms, |
|||
error_message=str(e)[:2000], finished_at=datetime.utcnow(), |
|||
) |
|||
db.add(execution) |
|||
raise HTTPException(500, f"流执行失败: {str(e)}") |
|||
|
|||
|
|||
async def _chat_stream(flow_id, definition, input_text, user_id, username, flow, db): |
|||
async def event_generator(): |
|||
import asyncio |
|||
engine = FlowEngine(definition) |
|||
context = {"user_id": user_id, "username": username, "_node_results": {}, "session_id": str(uuid.uuid4())} |
|||
input_msg = Msg(name="user", content=input_text, role="user") |
|||
start_time = time.time() |
|||
msg_id = str(uuid.uuid4()) |
|||
|
|||
try: |
|||
yield f"data: {json.dumps({'event': 'workflow_started', 'task_id': msg_id, 'data': {'flow_id': str(flow_id)}}, ensure_ascii=False)}\n\n" |
|||
|
|||
result_msg = await asyncio.wait_for(engine.execute(input_msg, context), timeout=engine.FLOW_TIMEOUT_SECONDS) |
|||
output_text = result_msg.get_text_content() if hasattr(result_msg, 'get_text_content') else str(result_msg) |
|||
elapsed_ms = int((time.time() - start_time) * 1000) |
|||
|
|||
for i in range(0, len(output_text), 10): |
|||
chunk = output_text[i:i + 10] |
|||
yield f"data: {json.dumps({'event': 'message', 'task_id': msg_id, 'answer': chunk, 'created_at': int(time.time())}, ensure_ascii=False)}\n\n" |
|||
|
|||
yield f"data: {json.dumps({'event': 'message_end', 'task_id': msg_id, 'id': msg_id, 'conversation_id': session_id or '', 'metadata': {'usage': {'latency_ms': elapsed_ms}, 'node_results': {k: str(v)[:200] for k, v in context.get('_node_results', {}).items()}}}, ensure_ascii=False)}\n\n" |
|||
|
|||
execution = FlowExecution( |
|||
flow_id=flow.id, version=flow.version, |
|||
trigger_type="api", input_data={"query": input_text}, |
|||
output_data={"output": output_text}, |
|||
status="completed", latency_ms=elapsed_ms, |
|||
finished_at=datetime.utcnow(), |
|||
) |
|||
db.add(execution) |
|||
except asyncio.TimeoutError: |
|||
yield f"data: {json.dumps({'event': 'error', 'task_id': msg_id, 'message': '执行超时'}, ensure_ascii=False)}\n\n" |
|||
except Exception as e: |
|||
yield f"data: {json.dumps({'event': 'error', 'task_id': msg_id, 'message': str(e)}, ensure_ascii=False)}\n\n" |
|||
finally: |
|||
yield "data: [DONE]\n\n" |
|||
|
|||
return StreamingResponse( |
|||
event_generator(), |
|||
media_type="text/event-stream", |
|||
headers={"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"}, |
|||
) |
|||
|
|||
|
|||
# ============================== 工作流型流 ============================== |
|||
|
|||
|
|||
@gateway_router.post("/workflows/run") |
|||
async def workflows_run(request: Request, db: AsyncSession = Depends(get_db)): |
|||
auth = await _resolve_auth(request) |
|||
body = await request.json() |
|||
inputs = body.get("inputs", {}) |
|||
response_mode = body.get("response_mode", "blocking") |
|||
user = body.get("user", "anonymous") |
|||
|
|||
flow_id_str = body.get("workflow_id") or inputs.get("workflow_id") or inputs.get("flow_id") |
|||
if not flow_id_str: |
|||
raise HTTPException(400, "缺少 workflow_id") |
|||
|
|||
flow_id = uuid.UUID(flow_id_str) |
|||
definition = await _get_definition_for_execute(flow_id, db) |
|||
f = await db.get(FlowDefinition, flow_id) |
|||
|
|||
user_id = "api" if auth.get("auth_type") == "api_key" else auth.get("user", {}).get("id", "api") |
|||
|
|||
engine = FlowEngine(definition) |
|||
input_msg = Msg(name="user", content=json.dumps(inputs, ensure_ascii=False), role="user") |
|||
context = {"user_id": user_id, "username": user, "_node_results": {}, "trigger_data": inputs} |
|||
|
|||
start_time = time.time() |
|||
try: |
|||
result_msg = await engine.execute(input_msg, context) |
|||
elapsed_ms = int((time.time() - start_time) * 1000) |
|||
output_text = result_msg.get_text_content() if hasattr(result_msg, 'get_text_content') else str(result_msg) |
|||
|
|||
execution = FlowExecution( |
|||
flow_id=f.id, version=f.version, |
|||
trigger_type="api", input_data={"inputs": inputs}, |
|||
output_data={"output": output_text}, status="completed", |
|||
latency_ms=elapsed_ms, finished_at=datetime.utcnow(), |
|||
) |
|||
db.add(execution) |
|||
|
|||
return { |
|||
"id": str(uuid.uuid4()), |
|||
"workflow_run_id": str(uuid.uuid4()), |
|||
"data": { |
|||
"outputs": {"text": output_text}, |
|||
"node_results": {k: str(v)[:200] for k, v in context.get("_node_results", {}).items()}, |
|||
}, |
|||
"metadata": {"latency_ms": elapsed_ms}, |
|||
} |
|||
except Exception as e: |
|||
elapsed_ms = int((time.time() - start_time) * 1000) |
|||
execution = FlowExecution( |
|||
flow_id=f.id, version=f.version, |
|||
trigger_type="api", input_data={"inputs": inputs}, |
|||
status="failed", latency_ms=elapsed_ms, |
|||
error_message=str(e)[:2000], finished_at=datetime.utcnow(), |
|||
) |
|||
db.add(execution) |
|||
raise HTTPException(500, f"工作流执行失败: {str(e)}") |
|||
|
|||
|
|||
# ============================== 参数信息 ============================== |
|||
|
|||
|
|||
@gateway_router.get("/flows/{flow_id}/parameters") |
|||
async def get_flow_parameters(flow_id: uuid.UUID, request: Request, db: AsyncSession = Depends(get_db)): |
|||
definition = await _get_definition_for_execute(flow_id, db) |
|||
nodes = definition.get("nodes", []) |
|||
trigger_nodes = [n for n in nodes if n.get("type") == "trigger"] |
|||
input_vars = [] |
|||
if trigger_nodes: |
|||
trigger_config = trigger_nodes[0].get("config", {}) |
|||
input_vars = [ |
|||
{"name": "query", "type": "string", "description": "用户输入文本", "required": True}, |
|||
{"name": "session_id", "type": "string", "description": "会话ID(用于多轮对话)", "required": False}, |
|||
] |
|||
return { |
|||
"code": 200, |
|||
"data": { |
|||
"input_variables": input_vars, |
|||
"node_count": len(nodes), |
|||
"edge_count": len(definition.get("edges", [])), |
|||
}, |
|||
} |
|||
@ -0,0 +1,49 @@ |
|||
from fastapi import WebSocket, WebSocketDisconnect |
|||
from typing import Dict, Set |
|||
import json |
|||
import logging |
|||
|
|||
logger = logging.getLogger(__name__) |
|||
|
|||
|
|||
class WebSocketManager: |
|||
def __init__(self): |
|||
self.active_connections: Dict[str, Set[WebSocket]] = {} |
|||
|
|||
async def connect(self, websocket: WebSocket, user_id: str): |
|||
await websocket.accept() |
|||
if user_id not in self.active_connections: |
|||
self.active_connections[user_id] = set() |
|||
self.active_connections[user_id].add(websocket) |
|||
logger.info(f"WebSocket 用户 {user_id} 已连接") |
|||
|
|||
def disconnect(self, websocket: WebSocket, user_id: str): |
|||
if user_id in self.active_connections: |
|||
self.active_connections[user_id].discard(websocket) |
|||
if not self.active_connections[user_id]: |
|||
del self.active_connections[user_id] |
|||
logger.info(f"WebSocket 用户 {user_id} 已断开") |
|||
|
|||
async def send_to_user(self, user_id: str, message: dict): |
|||
if user_id not in self.active_connections: |
|||
return False |
|||
dead_connections = set() |
|||
sent_count = 0 |
|||
for connection in list(self.active_connections.get(user_id, set())): |
|||
try: |
|||
await connection.send_text(json.dumps(message, ensure_ascii=False)) |
|||
sent_count += 1 |
|||
except Exception: |
|||
dead_connections.add(connection) |
|||
for conn in dead_connections: |
|||
self.active_connections[user_id].discard(conn) |
|||
if not self.active_connections.get(user_id): |
|||
self.active_connections.pop(user_id, None) |
|||
return sent_count > 0 |
|||
|
|||
async def broadcast(self, message: dict): |
|||
for user_id in list(self.active_connections.keys()): |
|||
await self.send_to_user(user_id, message) |
|||
|
|||
|
|||
ws_manager = WebSocketManager() |
|||
@ -0,0 +1,393 @@ |
|||
<template> |
|||
<div class="flow-chat-page"> |
|||
<el-page-header @back="$router.back()" content="流式对话" /> |
|||
|
|||
<el-card style="margin-top: 20px"> |
|||
<el-form inline> |
|||
<el-form-item label="选择流"> |
|||
<el-select v-model="selectedFlowId" placeholder="选择已发布的流" filterable @change="onFlowChange"> |
|||
<el-option v-for="f in publishedFlows" :key="f.id" :label="f.name" :value="f.id" /> |
|||
</el-select> |
|||
</el-form-item> |
|||
<el-form-item label="模式"> |
|||
<el-radio-group v-model="responseMode"> |
|||
<el-radio-button label="blocking">阻塞模式</el-radio-button> |
|||
<el-radio-button label="streaming">流式模式</el-radio-button> |
|||
</el-radio-group> |
|||
</el-form-item> |
|||
<el-form-item> |
|||
<el-button @click="clearMessages" :disabled="messages.length === 0">清空对话</el-button> |
|||
</el-form-item> |
|||
</el-form> |
|||
</el-card> |
|||
|
|||
<el-card style="margin-top: 20px; min-height: 450px" class="chat-card"> |
|||
<div ref="chatContainer" class="chat-container"> |
|||
<el-empty v-if="messages.length === 0 && !streaming" description="选择一个已发布的流,开始对话" /> |
|||
|
|||
<div v-for="msg in messages" :key="msg.id" :class="['message', msg.role]"> |
|||
<div class="message-avatar"> |
|||
<el-avatar v-if="msg.role === 'user'" :size="32" :icon="UserFilled" /> |
|||
<el-avatar v-else :size="32" style="background-color: #409EFF"> |
|||
<el-icon><Cpu /></el-icon> |
|||
</el-avatar> |
|||
</div> |
|||
<div class="message-body"> |
|||
<div class="message-content">{{ msg.content }}</div> |
|||
<div v-if="msg.nodeResults && Object.keys(msg.nodeResults).length > 0" class="node-results"> |
|||
<el-collapse> |
|||
<el-collapse-item title="节点执行详情"> |
|||
<pre>{{ JSON.stringify(msg.nodeResults, null, 2) }}</pre> |
|||
</el-collapse-item> |
|||
</el-collapse> |
|||
</div> |
|||
<div class="message-time">{{ msg.time }}</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<div v-if="streaming" class="message assistant"> |
|||
<div class="message-avatar"> |
|||
<el-avatar :size="32" style="background-color: #409EFF"> |
|||
<el-icon class="is-loading"><Loading /></el-icon> |
|||
</el-avatar> |
|||
</div> |
|||
<div class="message-body"> |
|||
<div class="message-content stream-content"> |
|||
{{ streamBuffer }} |
|||
<span class="cursor-blink">|</span> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</el-card> |
|||
|
|||
<el-card style="margin-top: 20px"> |
|||
<div class="input-area"> |
|||
<el-input |
|||
v-model="inputText" |
|||
type="textarea" |
|||
:rows="3" |
|||
placeholder="输入消息..." |
|||
:disabled="!selectedFlowId" |
|||
@keydown.enter.exact.prevent="sendMessage" |
|||
/> |
|||
<el-button type="primary" @click="sendMessage" :loading="sending" :disabled="!selectedFlowId || !inputText.trim()" style="margin-top: 10px"> |
|||
<el-icon><Promotion /></el-icon> 发送 |
|||
</el-button> |
|||
<el-button @click="abortStream" v-if="abortController" type="warning" style="margin-top: 10px; margin-left: 8px"> |
|||
停止生成 |
|||
</el-button> |
|||
</div> |
|||
</el-card> |
|||
</div> |
|||
</template> |
|||
|
|||
<script setup lang="ts"> |
|||
import { ref, onMounted, nextTick } from 'vue' |
|||
import { ElMessage } from 'element-plus' |
|||
import { UserFilled, Cpu, Loading, Promotion } from '@element-plus/icons-vue' |
|||
import api from '@/api' |
|||
|
|||
const selectedFlowId = ref('') |
|||
const responseMode = ref('streaming') |
|||
const inputText = ref('') |
|||
const messages = ref<any[]>([]) |
|||
const streaming = ref(false) |
|||
const streamBuffer = ref('') |
|||
const sending = ref(false) |
|||
const publishedFlows = ref<any[]>([]) |
|||
const sessionId = ref(localStorage.getItem('flow_chat_session') || '') |
|||
const chatContainer = ref<HTMLElement>() |
|||
const abortController = ref<AbortController | null>(null) |
|||
|
|||
onMounted(async () => { |
|||
try { |
|||
const res = await api.get('/flow/market') |
|||
publishedFlows.value = Array.isArray(res) ? res : ((res as any)?.data || []) |
|||
} catch { |
|||
ElMessage.warning('获取流列表失败') |
|||
} |
|||
}) |
|||
|
|||
function onFlowChange() { |
|||
sessionId.value = '' |
|||
localStorage.removeItem('flow_chat_session') |
|||
messages.value = [] |
|||
} |
|||
|
|||
function clearMessages() { |
|||
messages.value = [] |
|||
streamBuffer.value = '' |
|||
streaming.value = false |
|||
sessionId.value = '' |
|||
localStorage.removeItem('flow_chat_session') |
|||
if (abortController.value) { |
|||
abortController.value.abort() |
|||
abortController.value = null |
|||
} |
|||
} |
|||
|
|||
function scrollToBottom() { |
|||
nextTick(() => { |
|||
if (chatContainer.value) { |
|||
chatContainer.value.scrollTop = chatContainer.value.scrollHeight |
|||
} |
|||
}) |
|||
} |
|||
|
|||
async function sendMessage() { |
|||
if (!selectedFlowId.value || !inputText.value.trim()) return |
|||
|
|||
const userMsg = { |
|||
id: Date.now(), |
|||
role: 'user', |
|||
content: inputText.value, |
|||
time: new Date().toLocaleTimeString('zh-CN'), |
|||
} |
|||
messages.value.push(userMsg) |
|||
scrollToBottom() |
|||
|
|||
if (responseMode.value === 'streaming') { |
|||
await sendStreaming() |
|||
} else { |
|||
await sendBlocking() |
|||
} |
|||
inputText.value = '' |
|||
} |
|||
|
|||
async function sendBlocking() { |
|||
sending.value = true |
|||
try { |
|||
const res = await api.post(`/flow/definitions/${selectedFlowId.value}/execute`, { |
|||
input: messages.value[messages.value.length - 1]?.content, |
|||
session_id: sessionId.value || undefined, |
|||
trigger_type: 'chat', |
|||
}) |
|||
const data = (res as any)?.data |
|||
const output = data?.output || data?.result || JSON.stringify(res) |
|||
messages.value.push({ |
|||
id: Date.now(), |
|||
role: 'assistant', |
|||
content: output, |
|||
nodeResults: data?.node_results, |
|||
time: new Date().toLocaleTimeString('zh-CN'), |
|||
}) |
|||
if (data?.session_id) { |
|||
sessionId.value = data.session_id |
|||
localStorage.setItem('flow_chat_session', sessionId.value) |
|||
} |
|||
} catch (e: any) { |
|||
ElMessage.error(e?.response?.data?.detail || '发送失败') |
|||
} finally { |
|||
sending.value = false |
|||
scrollToBottom() |
|||
} |
|||
} |
|||
|
|||
async function sendStreaming() { |
|||
streaming.value = true |
|||
streamBuffer.value = '' |
|||
sending.value = true |
|||
abortController.value = new AbortController() |
|||
|
|||
try { |
|||
const response = await fetch(`/api/flow/definitions/${selectedFlowId.value}/stream`, { |
|||
method: 'POST', |
|||
headers: { |
|||
'Content-Type': 'application/json', |
|||
'Authorization': `Bearer ${localStorage.getItem('token')}`, |
|||
}, |
|||
body: JSON.stringify({ |
|||
input: messages.value[messages.value.length - 1]?.content, |
|||
session_id: sessionId.value || undefined, |
|||
trigger_type: 'chat', |
|||
}), |
|||
signal: abortController.value.signal, |
|||
}) |
|||
|
|||
if (!response.ok) { |
|||
throw new Error(`HTTP ${response.status}`) |
|||
} |
|||
|
|||
const reader = response.body?.getReader() |
|||
if (!reader) { |
|||
throw new Error('浏览器不支持流式读取') |
|||
} |
|||
|
|||
const decoder = new TextDecoder() |
|||
let nodeResults: Record<string, any> = {} |
|||
let finalSessionId = '' |
|||
|
|||
while (true) { |
|||
const { done, value } = await reader.read() |
|||
if (done) break |
|||
|
|||
const chunk = decoder.decode(value, { stream: true }) |
|||
const lines = chunk.split('\n') |
|||
|
|||
for (const line of lines) { |
|||
if (line.startsWith('data: ')) { |
|||
const jsonStr = line.slice(6).trim() |
|||
if (!jsonStr) continue |
|||
|
|||
try { |
|||
const event = JSON.parse(jsonStr) |
|||
|
|||
if (event.event === 'workflow_started') { |
|||
// 流开始 |
|||
} else if (event.event === 'node_started') { |
|||
// 节点开始 |
|||
} else if (event.event === 'text_chunk') { |
|||
streamBuffer.value += event.data?.content || '' |
|||
scrollToBottom() |
|||
} else if (event.event === 'node_completed') { |
|||
const nodeId = event.data?.node_id |
|||
if (nodeId) { |
|||
nodeResults[nodeId] = event.data |
|||
} |
|||
} else if (event.event === 'workflow_finished') { |
|||
finalSessionId = event.data?.session_id || '' |
|||
} |
|||
} catch { |
|||
// 跳过无法解析的行 |
|||
} |
|||
} |
|||
} |
|||
scrollToBottom() |
|||
} |
|||
|
|||
streaming.value = false |
|||
if (streamBuffer.value) { |
|||
messages.value.push({ |
|||
id: Date.now(), |
|||
role: 'assistant', |
|||
content: streamBuffer.value, |
|||
nodeResults: Object.keys(nodeResults).length > 0 ? nodeResults : undefined, |
|||
time: new Date().toLocaleTimeString('zh-CN'), |
|||
}) |
|||
} |
|||
if (finalSessionId) { |
|||
sessionId.value = finalSessionId |
|||
localStorage.setItem('flow_chat_session', finalSessionId) |
|||
} |
|||
} catch (e: any) { |
|||
if (e.name !== 'AbortError') { |
|||
ElMessage.error('流式请求失败: ' + (e.message || '网络错误')) |
|||
} |
|||
if (streamBuffer.value) { |
|||
messages.value.push({ |
|||
id: Date.now(), |
|||
role: 'assistant', |
|||
content: streamBuffer.value + '\n\n[流中断]', |
|||
time: new Date().toLocaleTimeString('zh-CN'), |
|||
}) |
|||
} |
|||
streaming.value = false |
|||
} finally { |
|||
sending.value = false |
|||
abortController.value = null |
|||
scrollToBottom() |
|||
} |
|||
} |
|||
|
|||
function abortStream() { |
|||
if (abortController.value) { |
|||
abortController.value.abort() |
|||
abortController.value = null |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<style scoped> |
|||
.flow-chat-page { |
|||
padding: 0; |
|||
} |
|||
|
|||
.chat-card { |
|||
display: flex; |
|||
flex-direction: column; |
|||
} |
|||
|
|||
.chat-container { |
|||
max-height: 500px; |
|||
overflow-y: auto; |
|||
padding: 12px 0; |
|||
} |
|||
|
|||
.message { |
|||
display: flex; |
|||
gap: 10px; |
|||
margin-bottom: 18px; |
|||
} |
|||
|
|||
.message.user { |
|||
flex-direction: row-reverse; |
|||
} |
|||
|
|||
.message.user .message-body { |
|||
background-color: #ecf5ff; |
|||
border-radius: 12px 4px 12px 12px; |
|||
} |
|||
|
|||
.message.assistant .message-body { |
|||
background-color: #f5f7fa; |
|||
border-radius: 4px 12px 12px 12px; |
|||
} |
|||
|
|||
.message-avatar { |
|||
flex-shrink: 0; |
|||
align-self: flex-start; |
|||
} |
|||
|
|||
.message-body { |
|||
padding: 10px 14px; |
|||
max-width: 75%; |
|||
} |
|||
|
|||
.message-content { |
|||
white-space: pre-wrap; |
|||
word-break: break-word; |
|||
line-height: 1.6; |
|||
} |
|||
|
|||
.stream-content { |
|||
min-height: 20px; |
|||
} |
|||
|
|||
.cursor-blink { |
|||
animation: blink 1s infinite; |
|||
color: #409EFF; |
|||
font-weight: bold; |
|||
} |
|||
|
|||
@keyframes blink { |
|||
0%, 50% { opacity: 1; } |
|||
51%, 100% { opacity: 0; } |
|||
} |
|||
|
|||
.message-time { |
|||
font-size: 11px; |
|||
color: #999; |
|||
margin-top: 4px; |
|||
text-align: right; |
|||
} |
|||
|
|||
.node-results { |
|||
margin-top: 8px; |
|||
} |
|||
|
|||
.node-results pre { |
|||
font-size: 12px; |
|||
white-space: pre-wrap; |
|||
word-break: break-all; |
|||
max-height: 200px; |
|||
overflow-y: auto; |
|||
background: #fafafa; |
|||
padding: 8px; |
|||
border-radius: 4px; |
|||
} |
|||
|
|||
.input-area { |
|||
width: 100%; |
|||
} |
|||
</style> |
|||
@ -0,0 +1,50 @@ |
|||
<template> |
|||
<div class="node-config"> |
|||
<el-divider content-position="left">代码配置</el-divider> |
|||
|
|||
<el-form-item label="语言"> |
|||
<el-select :model-value="modelValue.language || 'python'" @change="update('language', $event)"> |
|||
<el-option label="Python" value="python" /> |
|||
<el-option label="JavaScript" value="javascript" /> |
|||
</el-select> |
|||
</el-form-item> |
|||
|
|||
<el-form-item label="代码"> |
|||
<el-input :model-value="modelValue.code" type="textarea" :rows="8" @input="(e: any) => update('code', e)" placeholder="print("Hello") result = INPUT_TEXT.upper() print(result)" /> |
|||
<div class="code-hint">输入数据通过 <code>INPUT_TEXT</code> 变量获取,用 <code>print()</code> 输出结果</div> |
|||
</el-form-item> |
|||
|
|||
<el-divider content-position="left">执行选项</el-divider> |
|||
|
|||
<el-form-item label="超时(秒)"> |
|||
<el-input-number :model-value="modelValue.timeout ?? 30" :min="1" :max="300" :step="10" @change="update('timeout', $event)" /> |
|||
</el-form-item> |
|||
|
|||
<el-form-item label="沙箱模式"> |
|||
<el-switch :model-value="modelValue.sandbox ?? true" @change="update('sandbox', $event)" /> |
|||
</el-form-item> |
|||
</div> |
|||
</template> |
|||
|
|||
<script setup lang="ts"> |
|||
const props = defineProps<{ modelValue: any }>() |
|||
const emit = defineEmits(['change', 'update:modelValue']) |
|||
function update(key: string, val: any) { |
|||
emit('update:modelValue', { ...props.modelValue, [key]: val }) |
|||
emit('change') |
|||
} |
|||
</script> |
|||
|
|||
<style scoped> |
|||
.code-hint { |
|||
font-size: 12px; |
|||
color: #909399; |
|||
margin-top: 4px; |
|||
} |
|||
.code-hint code { |
|||
background: #f5f7fa; |
|||
padding: 1px 4px; |
|||
border-radius: 2px; |
|||
color: #409EFF; |
|||
} |
|||
</style> |
|||
@ -0,0 +1,74 @@ |
|||
<template> |
|||
<div class="node-config"> |
|||
<el-divider content-position="left">循环配置</el-divider> |
|||
|
|||
<el-form-item label="循环类型"> |
|||
<el-select :model-value="modelValue.loop_type || 'fixed'" @change="update('loop_type', $event)"> |
|||
<el-option label="固定次数" value="fixed" /> |
|||
<el-option label="条件循环" value="conditional" /> |
|||
<el-option label="遍历数组" value="array" /> |
|||
</el-select> |
|||
</el-form-item> |
|||
|
|||
<el-form-item label="循环次数" v-if="modelValue.loop_type === 'fixed' || !modelValue.loop_type || modelValue.loop_type === 'fixed'"> |
|||
<el-input-number :model-value="modelValue.count ?? 3" :min="1" :max="50" :step="1" @change="update('count', $event)" /> |
|||
</el-form-item> |
|||
|
|||
<el-form-item label="遍历数据" v-if="modelValue.loop_type === 'array'"> |
|||
<el-input :model-value="modelValue.items_text || formatItems(modelValue.items)" type="textarea" :rows="4" @input="parseItems" placeholder="每行一个数据项,或粘贴JSON数组" /> |
|||
</el-form-item> |
|||
|
|||
<el-form-item label="循环变量名"> |
|||
<el-input :model-value="modelValue.iterator_variable || 'item'" @input="(e: any) => update('iterator_variable', e)" placeholder="如: item, i, record" /> |
|||
</el-form-item> |
|||
|
|||
<el-form-item label="最大迭代上限"> |
|||
<el-input-number :model-value="modelValue.max_iterations ?? 10" :min="1" :max="100" :step="1" @change="update('max_iterations', $event)" /> |
|||
</el-form-item> |
|||
|
|||
<div class="loop-hint"> |
|||
<p>循环节点有两个出口:</p> |
|||
<p>→ <b>loop_body</b>(继续循环) 连回循环体</p> |
|||
<p>→ <b>loop_done</b>(循环完成) 连到后续节点</p> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<script setup lang="ts"> |
|||
const props = defineProps<{ modelValue: any }>() |
|||
const emit = defineEmits(['change', 'update:modelValue']) |
|||
function update(key: string, val: any) { |
|||
emit('update:modelValue', { ...props.modelValue, [key]: val }) |
|||
emit('change') |
|||
} |
|||
function formatItems(items: any[] | undefined): string { |
|||
if (!items || !Array.isArray(items)) return '' |
|||
try { |
|||
return JSON.stringify(items, null, 2) |
|||
} catch { |
|||
return items.map(String).join('\n') |
|||
} |
|||
} |
|||
function parseItems(val: string) { |
|||
const trimmed = val.trim() |
|||
let items: any[] = [] |
|||
if (trimmed.startsWith('[')) { |
|||
try { items = JSON.parse(trimmed) } catch {} |
|||
} else { |
|||
items = trimmed.split('\n').filter(Boolean) |
|||
} |
|||
emit('update:modelValue', { ...props.modelValue, items, items_text: val }) |
|||
emit('change') |
|||
} |
|||
</script> |
|||
|
|||
<style scoped> |
|||
.loop-hint { |
|||
font-size: 12px; |
|||
color: #909399; |
|||
background: #f5f7fa; |
|||
padding: 8px 12px; |
|||
border-radius: 4px; |
|||
line-height: 1.8; |
|||
} |
|||
</style> |
|||
@ -0,0 +1,344 @@ |
|||
<template> |
|||
<div class="custom-tool-manager"> |
|||
<el-page-header @back="$router.back()" content="自定义API工具管理" /> |
|||
|
|||
<el-card style="margin-top: 20px"> |
|||
<div style="display: flex; gap: 12px; margin-bottom: 16px"> |
|||
<el-button type="primary" @click="showImportDialog = true"> |
|||
<el-icon><Upload /></el-icon> 导入 OpenAPI |
|||
</el-button> |
|||
<el-button type="success" @click="showCreateDialog = true"> |
|||
<el-icon><Plus /></el-icon> 手动创建 |
|||
</el-button> |
|||
</div> |
|||
|
|||
<el-table :data="tools" v-loading="loading" stripe> |
|||
<el-table-column prop="name" label="工具名称" min-width="160" /> |
|||
<el-table-column prop="description" label="描述" min-width="200" show-overflow-tooltip /> |
|||
<el-table-column prop="method" label="方法" width="80"> |
|||
<template #default="{ row }"> |
|||
<el-tag :type="methodTagType(row.method)" size="small">{{ row.method }}</el-tag> |
|||
</template> |
|||
</el-table-column> |
|||
<el-table-column prop="path" label="路径" min-width="180" show-overflow-tooltip /> |
|||
<el-table-column prop="auth_type" label="认证" width="100"> |
|||
<template #default="{ row }"> |
|||
<el-tag :type="row.auth_type === 'none' ? 'info' : 'warning'" size="small"> |
|||
{{ row.auth_type === 'api_key' ? 'API Key' : row.auth_type === 'bearer' ? 'Bearer' : '无' }} |
|||
</el-tag> |
|||
</template> |
|||
</el-table-column> |
|||
<el-table-column prop="created_at" label="创建时间" width="170"> |
|||
<template #default="{ row }"> |
|||
{{ formatTime(row.created_at) }} |
|||
</template> |
|||
</el-table-column> |
|||
<el-table-column label="操作" width="240" fixed="right"> |
|||
<template #default="{ row }"> |
|||
<el-button size="small" type="primary" link @click="openTestDialog(row)">测试</el-button> |
|||
<el-button size="small" link @click="openEditDialog(row)">编辑</el-button> |
|||
<el-button size="small" type="danger" link @click="handleDelete(row)">删除</el-button> |
|||
</template> |
|||
</el-table-column> |
|||
</el-table> |
|||
</el-card> |
|||
|
|||
<!-- 导入OpenAPI对话框 --> |
|||
<el-dialog v-model="showImportDialog" title="导入 OpenAPI 工具" width="500px"> |
|||
<el-form :model="importForm" label-width="120px"> |
|||
<el-form-item label="OpenAPI URL"> |
|||
<el-input v-model="importForm.openapi_url" placeholder="https://petstore.swagger.io/v2/swagger.json" /> |
|||
</el-form-item> |
|||
<el-form-item label="Base URL 覆盖"> |
|||
<el-input v-model="importForm.base_url_override" placeholder="可选,覆盖API基础URL" /> |
|||
</el-form-item> |
|||
</el-form> |
|||
<template #footer> |
|||
<el-button @click="showImportDialog = false">取消</el-button> |
|||
<el-button type="primary" @click="handleImport" :loading="importing">导入</el-button> |
|||
</template> |
|||
</el-dialog> |
|||
|
|||
<!-- 创建/编辑对话框 --> |
|||
<el-dialog v-model="showCreateDialog" :title="editingTool ? '编辑工具' : '创建自定义工具'" width="600px"> |
|||
<el-form :model="createForm" label-width="120px"> |
|||
<el-form-item label="工具名称" required> |
|||
<el-input v-model="createForm.name" placeholder="如: get_weather" /> |
|||
</el-form-item> |
|||
<el-form-item label="描述"> |
|||
<el-input v-model="createForm.description" type="textarea" :rows="2" /> |
|||
</el-form-item> |
|||
<el-form-item label="请求方法"> |
|||
<el-select v-model="createForm.method"> |
|||
<el-option label="GET" value="GET" /> |
|||
<el-option label="POST" value="POST" /> |
|||
<el-option label="PUT" value="PUT" /> |
|||
<el-option label="PATCH" value="PATCH" /> |
|||
<el-option label="DELETE" value="DELETE" /> |
|||
</el-select> |
|||
</el-form-item> |
|||
<el-form-item label="API端点URL" required> |
|||
<el-input v-model="createForm.endpoint_url" placeholder="https://api.example.com" /> |
|||
</el-form-item> |
|||
<el-form-item label="路径"> |
|||
<el-input v-model="createForm.path" placeholder="/v1/weather" /> |
|||
</el-form-item> |
|||
<el-form-item label="认证方式"> |
|||
<el-select v-model="createForm.auth_type"> |
|||
<el-option label="无" value="none" /> |
|||
<el-option label="API Key" value="api_key" /> |
|||
<el-option label="Bearer Token" value="bearer" /> |
|||
</el-select> |
|||
</el-form-item> |
|||
<template v-if="createForm.auth_type === 'api_key'"> |
|||
<el-form-item label="Key名称"> |
|||
<el-input v-model="createForm.auth_config.name" placeholder="X-API-Key" /> |
|||
</el-form-item> |
|||
<el-form-item label="Key值"> |
|||
<el-input v-model="createForm.auth_config.key" type="password" placeholder="your-api-key" show-password /> |
|||
</el-form-item> |
|||
<el-form-item label="位置"> |
|||
<el-select v-model="createForm.auth_config.location"> |
|||
<el-option label="Header" value="header" /> |
|||
<el-option label="Query" value="query" /> |
|||
</el-select> |
|||
</el-form-item> |
|||
</template> |
|||
<template v-if="createForm.auth_type === 'bearer'"> |
|||
<el-form-item label="Token"> |
|||
<el-input v-model="createForm.auth_config.token" type="password" placeholder="bearer-token" show-password /> |
|||
</el-form-item> |
|||
</template> |
|||
<el-form-item label="参数 Schema (JSON)"> |
|||
<el-input v-model="createForm.schema_json_str" type="textarea" :rows="5" placeholder='{"type":"object","properties":{"city":{"type":"string","description":"城市名称"}}}' /> |
|||
</el-form-item> |
|||
</el-form> |
|||
<template #footer> |
|||
<el-button @click="showCreateDialog = false">取消</el-button> |
|||
<el-button type="primary" @click="handleSave" :loading="saving">保存</el-button> |
|||
</template> |
|||
</el-dialog> |
|||
|
|||
<!-- 测试对话框 --> |
|||
<el-dialog v-model="showTestDialog" title="测试工具" width="700px"> |
|||
<template v-if="testTool"> |
|||
<el-descriptions :column="2" border style="margin-bottom: 16px"> |
|||
<el-descriptions-item label="名称">{{ testTool.name }}</el-descriptions-item> |
|||
<el-descriptions-item label="方法"> |
|||
<el-tag :type="methodTagType(testTool.method)">{{ testTool.method }}</el-tag> |
|||
</el-descriptions-item> |
|||
<el-descriptions-item label="URL" :span="2">{{ testTool.endpoint_url }}{{ testTool.path }}</el-descriptions-item> |
|||
</el-descriptions> |
|||
|
|||
<el-form label-width="100px"> |
|||
<el-form-item label="请求参数 (JSON)"> |
|||
<el-input v-model="testParamsStr" type="textarea" :rows="4" placeholder='{"city":"beijing"}' /> |
|||
</el-form-item> |
|||
<el-form-item> |
|||
<el-button type="primary" @click="handleTest" :loading="testing"> |
|||
<el-icon><Connection /></el-icon> 发送测试 |
|||
</el-button> |
|||
</el-form-item> |
|||
</el-form> |
|||
|
|||
<div v-if="testResult !== null" style="margin-top: 16px"> |
|||
<el-divider /> |
|||
<h4>测试结果:</h4> |
|||
<el-input v-model="testResult" type="textarea" :rows="10" readonly /> |
|||
</div> |
|||
</template> |
|||
</el-dialog> |
|||
</div> |
|||
</template> |
|||
|
|||
<script setup lang="ts"> |
|||
import { ref, reactive, onMounted } from 'vue' |
|||
import { ElMessage, ElMessageBox } from 'element-plus' |
|||
import { Upload, Plus, Connection } from '@element-plus/icons-vue' |
|||
import api from '@/api' |
|||
|
|||
const tools = ref<any[]>([]) |
|||
const loading = ref(false) |
|||
const importing = ref(false) |
|||
const saving = ref(false) |
|||
const testing = ref(false) |
|||
|
|||
const showImportDialog = ref(false) |
|||
const showCreateDialog = ref(false) |
|||
const showTestDialog = ref(false) |
|||
const editingTool = ref<any>(null) |
|||
const testTool = ref<any>(null) |
|||
const testParamsStr = ref('{}') |
|||
const testResult = ref<string | null>(null) |
|||
|
|||
const importForm = reactive({ |
|||
openapi_url: '', |
|||
base_url_override: '', |
|||
}) |
|||
|
|||
const createForm = reactive<any>({ |
|||
name: '', |
|||
description: '', |
|||
method: 'GET', |
|||
endpoint_url: '', |
|||
path: '', |
|||
auth_type: 'none', |
|||
auth_config: { name: 'X-API-Key', key: '', location: 'header', token: '' }, |
|||
schema_json_str: '{}', |
|||
}) |
|||
|
|||
function methodTagType(method: string) { |
|||
const map: Record<string, string> = { GET: '', POST: 'primary', PUT: 'warning', PATCH: 'info', DELETE: 'danger' } |
|||
return map[method] || '' |
|||
} |
|||
|
|||
function formatTime(t: string) { |
|||
if (!t) return '' |
|||
return new Date(t).toLocaleString('zh-CN') |
|||
} |
|||
|
|||
async function fetchTools() { |
|||
loading.value = true |
|||
try { |
|||
const res = await api.get('/custom-tools/') |
|||
tools.value = (res as any)?.data || res |
|||
} catch { |
|||
ElMessage.error('获取工具列表失败') |
|||
} |
|||
loading.value = false |
|||
} |
|||
|
|||
async function handleImport() { |
|||
if (!importForm.openapi_url.trim()) { |
|||
ElMessage.warning('请输入 OpenAPI URL') |
|||
return |
|||
} |
|||
importing.value = true |
|||
try { |
|||
await api.post('/custom-tools/import-openapi', { |
|||
openapi_url: importForm.openapi_url, |
|||
base_url_override: importForm.base_url_override || undefined, |
|||
}) |
|||
ElMessage.success('导入成功') |
|||
showImportDialog.value = false |
|||
importForm.openapi_url = '' |
|||
importForm.base_url_override = '' |
|||
await fetchTools() |
|||
} catch (e: any) { |
|||
ElMessage.error(e?.response?.data?.detail || '导入失败') |
|||
} |
|||
importing.value = false |
|||
} |
|||
|
|||
function openEditDialog(tool: any) { |
|||
editingTool.value = tool |
|||
createForm.name = tool.name |
|||
createForm.description = tool.description || '' |
|||
createForm.method = tool.method |
|||
createForm.endpoint_url = tool.endpoint_url |
|||
createForm.path = tool.path || '' |
|||
createForm.auth_type = tool.auth_type || 'none' |
|||
createForm.auth_config = { ...tool.auth_config } || {} |
|||
createForm.schema_json_str = JSON.stringify(tool.schema_json || {}, null, 2) |
|||
showCreateDialog.value = true |
|||
} |
|||
|
|||
async function handleSave() { |
|||
let schemaJson = {} |
|||
try { |
|||
schemaJson = JSON.parse(createForm.schema_json_str) |
|||
} catch { |
|||
ElMessage.warning('参数 Schema 不是有效的 JSON') |
|||
return |
|||
} |
|||
saving.value = true |
|||
try { |
|||
const payload = { |
|||
name: createForm.name, |
|||
description: createForm.description, |
|||
method: createForm.method, |
|||
endpoint_url: createForm.endpoint_url, |
|||
path: createForm.path, |
|||
auth_type: createForm.auth_type, |
|||
auth_config: createForm.auth_config, |
|||
schema_json: schemaJson, |
|||
headers: {}, |
|||
} |
|||
if (editingTool.value) { |
|||
await api.put(`/custom-tools/${editingTool.value.id}`, payload) |
|||
ElMessage.success('更新成功') |
|||
} else { |
|||
await api.post('/custom-tools/', payload) |
|||
ElMessage.success('创建成功') |
|||
} |
|||
showCreateDialog.value = false |
|||
editingTool.value = null |
|||
resetCreateForm() |
|||
await fetchTools() |
|||
} catch (e: any) { |
|||
ElMessage.error(e?.response?.data?.detail || '保存失败') |
|||
} |
|||
saving.value = false |
|||
} |
|||
|
|||
function resetCreateForm() { |
|||
createForm.name = '' |
|||
createForm.description = '' |
|||
createForm.method = 'GET' |
|||
createForm.endpoint_url = '' |
|||
createForm.path = '' |
|||
createForm.auth_type = 'none' |
|||
createForm.auth_config = { name: 'X-API-Key', key: '', location: 'header', token: '' } |
|||
createForm.schema_json_str = '{}' |
|||
} |
|||
|
|||
async function handleDelete(tool: any) { |
|||
try { |
|||
await ElMessageBox.confirm(`确认停用工具 "${tool.name}"?`, '提示', { type: 'warning' }) |
|||
} catch { |
|||
return |
|||
} |
|||
try { |
|||
await api.delete(`/custom-tools/${tool.id}`) |
|||
ElMessage.success('已停用') |
|||
await fetchTools() |
|||
} catch { |
|||
ElMessage.error('操作失败') |
|||
} |
|||
} |
|||
|
|||
function openTestDialog(tool: any) { |
|||
testTool.value = tool |
|||
testResult.value = null |
|||
testParamsStr.value = '{}' |
|||
showTestDialog.value = true |
|||
} |
|||
|
|||
async function handleTest() { |
|||
let params = {} |
|||
try { |
|||
params = JSON.parse(testParamsStr.value) |
|||
} catch { |
|||
ElMessage.warning('参数不是有效的 JSON') |
|||
return |
|||
} |
|||
testing.value = true |
|||
try { |
|||
const res = await api.post(`/custom-tools/${testTool.value.id}/test`, params) |
|||
testResult.value = (res as any)?.data?.result || JSON.stringify(res) |
|||
} catch (e: any) { |
|||
testResult.value = e?.response?.data?.detail || '测试失败' |
|||
} |
|||
testing.value = false |
|||
} |
|||
|
|||
onMounted(() => { |
|||
fetchTools() |
|||
}) |
|||
</script> |
|||
|
|||
<style scoped> |
|||
.custom-tool-manager { |
|||
padding: 0; |
|||
} |
|||
</style> |
|||
Loading…
Reference in new issue