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