feat(workflow): 工作流编辑器——可视化画布编辑 + WORKFLOW.md 落盘 + REST CRUD + 侧边栏入口
- 编辑器:/workflows 库页 + /workflow/<name> 编辑器(Vue Flow 画布,开始/结束/阶段/审核/分支五类节点,白前进/蓝通过/红驳回三色语义连线,dagre 自动排版) - 落盘:modules/workflow_manager.py;host 存运行态根 workflows/,web/docker 存 users/<user>/personal/workflows/;源码树 workflows/ 为内置种子,用户库同名覆盖、内置不可删 - REST:GET/PUT/DELETE /api/workflows[/<name>],保存时后端强制 error 级结构校验 - 路由根治:新增 isConversationIndependentRoute() 统一谓词(URL 派生),收敛全部 9 处对话接管/URL 写回判断点,根治 /workflows 下地址栏被拽回对话的问题 - 深色主题 token 提亮(surface-soft/card/muted 去近黑色阶)
@ -1,24 +1,28 @@
|
||||
# 工作流(Workflow)功能设计方案
|
||||
|
||||
> 状态:设计已讨论定稿(2026-08-18),待实施。
|
||||
> 状态:设计讨论中(2026-08-18 更新,纳入阶段汇报工具 / 非线性路由 / 可视化编辑决策)。
|
||||
> 调研依据:`workflow_research/external_products/agentic_workflow_research.md`(外部产品)、`workflow_research/code_mount_points/code_mount_points.md`(代码挂载点)。
|
||||
|
||||
## 1. 功能定义
|
||||
|
||||
用户把「一套既定流程:工作方式 → 验证方式 → 结束方式」存为一个**工作流**(workflow),之后在任何普通对话中激活它,让主智能体复用现有循环与执行引擎按流程推进;阶段结束/整体结束时可由审核智能体软审核。
|
||||
|
||||
核心原则(来自外部调研共识):**结构在边上,自主在节点内**——工作流只约束阶段顺序与进出契约,阶段内主智能体完全自主(自由调工具、跑脚本)。
|
||||
核心原则(外部调研共识):**结构在边上,自主在节点内**——工作流只约束阶段拓扑与进出契约,阶段内主智能体完全自主(自由调工具、跑脚本)。
|
||||
|
||||
## 2. 已拍板的设计决策
|
||||
|
||||
| 决策点 | 结论 |
|
||||
|---|---|
|
||||
| 运行方式 | **不做独立模式/特殊 URL/新对话类型**,只能在正常对话中激活 |
|
||||
| 激活入口 | **/ 菜单(slash 菜单)**,新增 workflows 模式选择要激活的工作流(仿 skills 模式) |
|
||||
| 激活入口 | **/ 菜单(slash 菜单)** 人工激活 + **AI 工具 `activate_workflow`** 自主激活 |
|
||||
| 阶段边界 | **显式化**:AI 调**阶段汇报工具**声明阶段完成(期间汇报触发审核与推进;无后续阶段时该汇报即停止信号) |
|
||||
| 阶段模型 | **非线性**:阶段间为候选路由集(结构约束候选、AI 自主选择、审核把关),非纯线性 |
|
||||
| 可视化编辑 | 拖拽画布编辑器(类似 ComfyUI 流程图)纳入设计范围,编辑对象是 WORKFLOW.md 的阶段拓扑 |
|
||||
| 与目标模式关系 | **不互斥**:工作流可以是目标模式中的一个小步骤;目标模式后续将重做,本期不考虑两者冲突 |
|
||||
| 审核配置 | **独立**:`workflow_review.json`(走 resolve_deploy_config 回退链)+ frontmatter `review_mode` 自声明 |
|
||||
| 前后端通信 | **轮询**(复用 task 轮询 session_data 快照链路,不新增 websocket 通道) |
|
||||
| 状态隔离 | **对话级状态**,多对话可同时激活不同工作流,进度互不串扰 |
|
||||
| 审核机制 | 复用审核智能体架构(`goal_review_agent.py` 模式),审核智能体可调用只读 run_command 取证 |
|
||||
| 硬校验 | 不做步骤级硬校验;只做归档时结构校验(frontmatter 合法、阶段 id 唯一等) |
|
||||
| 硬校验 | 不做步骤级硬校验;归档时只做结构校验(frontmatter 合法、阶段 id 唯一、路由目标存在) |
|
||||
| 存储形态 | 工作流文件夹 = `WORKFLOW.md`(frontmatter 结构 + 自然语言正文)+ `scripts/`(已验证脚本)+ `references/`(可选),与 skill 对齐 |
|
||||
|
||||
## 3. 存储与落盘位置
|
||||
@ -28,11 +32,11 @@
|
||||
| host | `~/.astrion/astrion/host/workflows/` | 统一,不按用户拆(对齐 `CUSTOM_SKILLS_DIR`) |
|
||||
| docker/web | `users/<user>/personal/workflows/` | 每用户私有,多项目共享(对齐 `infer_private_skills_dir`) |
|
||||
|
||||
- 源码树 `workflows/` 只放内置示例种子,双源合并(对齐 `agentskills/` + `CUSTOM_SKILLS_DIR`)。
|
||||
- 源码树 `workflows/` 只放内置示例种子,双源合并。
|
||||
- 归档工具 `create_workflow`:校验 → `shutil.move` → 已存在拒绝覆盖(复用 `archive_skill_directory` 模式)。
|
||||
- 运行时状态:`{workspace.data_dir}/workflow_states/<conversation_id>.json`(对话级,压缩 handoff 点处理 key 迁移)。
|
||||
- 运行时状态:`{workspace.data_dir}/workflow_states/<conversation_id>.json`(对话级,对齐已修复的 goal 对话级方案)。
|
||||
|
||||
## 4. WORKFLOW.md 格式(草案,实施时可微调)
|
||||
## 4. WORKFLOW.md 格式(非线性候选路由)
|
||||
|
||||
```markdown
|
||||
---
|
||||
@ -40,70 +44,98 @@ name: code-review-pipeline
|
||||
description: 代码评审标准流程
|
||||
review_mode: active # 审核智能体是否可调用只读 run_command 取证
|
||||
max_stage_rounds: 20 # 单阶段最大轮数,防死循环
|
||||
entry: explore # 入口阶段
|
||||
stages:
|
||||
- id: explore
|
||||
name: 代码探索
|
||||
goal: 理解改动范围与相关模块
|
||||
review: false
|
||||
next: [review] # 候选路由集:AI 汇报时从中选择下一站
|
||||
- id: review
|
||||
name: 逐项评审
|
||||
goal: 按 checklist 评审每个文件
|
||||
review: true
|
||||
review: true # 阶段汇报先触发审核
|
||||
review_prompt: 检查是否遗漏边界条件和安全问题
|
||||
next: [report, explore] # 审核通过后可进报告,也可回探索补充
|
||||
- id: report
|
||||
name: 输出报告
|
||||
goal: 生成结构化评审报告
|
||||
review: true
|
||||
next: [] # 空 = 终点,汇报即触发整体结束
|
||||
end_conditions: 报告落盘且审核通过
|
||||
---
|
||||
|
||||
# 工作方式 / 验证方式 / 结束方式(自然语言正文,随阶段上下文注入)
|
||||
```
|
||||
|
||||
- 阶段默认线性推进;`on_pass` / `on_fail` 可跳转指定阶段(一期线性+有限跳转,不做完整 DAG)。
|
||||
- 结构校验在归档时做(name/description/stages 存在、id 唯一、跳转目标存在);内容质量交给审核智能体。
|
||||
- **路由语义**:`next` 是候选集(结构约束);AI 在阶段汇报工具中传 `next_stage_id` 自主选择;审核智能体可否决路由选择(打回时附带建议去向)。
|
||||
- **结构校验**(归档时):name/description/entry/stages 齐全、id 唯一、next 引用存在、entry 可达终点。
|
||||
- `position` 字段(可选)仅记录画布坐标,供可视化编辑器使用,不影响执行。
|
||||
|
||||
## 5. 运行时机制(全部复用现有引擎)
|
||||
## 5. 运行时机制(复用现有引擎 + 显式阶段边界)
|
||||
|
||||
- **主循环不新增**:复用 `process_message_task` 门闸 + `handle_task_with_sender` 迭代。
|
||||
- **拦截点**:`server/chat_flow_task_main.py` 的 `if not tool_calls:` 分支,与 goal 分支平排新增 workflow 分支:
|
||||
- 当前阶段 `review=false` → 推进下一阶段,注入新阶段上下文,`continue` 主循环
|
||||
- `review=true` → `WorkflowReviewAgent` 审核 → pass 推进 / retry 则 `inject_runtime_user_message` 续命
|
||||
- 最后阶段通过 → 整体结束审核 → `mark_done`
|
||||
- 边界保护:`max_stage_rounds`、空转保护(仿 `REASON_IDLE_NO_TOOL`)、用户取消
|
||||
- **压缩维持**:状态落盘 + `handle_task_with_sender` 入口检查活跃状态并重注入当前阶段上下文(对齐 goal 的 `inject_goal_prompt` 重注入模式)。
|
||||
- **编排模块**:新增 `server/workflow_flow.py`(仿 `server/goal_flow.py`),减少对主循环侵入。
|
||||
- **审核智能体**:新增 `modules/workflow_review_agent.py`(fork `goal_review_agent.py`),内部工具 `report_workflow_stage_status`(pass/retry/complete),active 模式注入只读 run_command;配置走 `resolve_deploy_config("workflow_review.json")` 回退链。
|
||||
### 主路径:阶段汇报工具驱动
|
||||
|
||||
```
|
||||
对话激活工作流 → 注入入口阶段上下文(goal + 工作方式 + 候选路由)
|
||||
↓
|
||||
主智能体正常跑(现有引擎不变,自由调工具、跑 scripts/)
|
||||
↓
|
||||
AI 调 report_workflow_stage(summary, next_stage_id?) ← 阶段边界显式声明
|
||||
↓
|
||||
当前阶段 review=true?──否──→ 校验 next_stage_id 在候选集 → 推进,注入新阶段上下文
|
||||
│是
|
||||
↓
|
||||
WorkflowReviewAgent 审核(active 模式可只读取证)
|
||||
├─ pass → 推进(next 为空 → 整体结束审核 → done,停止)
|
||||
└─ retry → 工具结果返回整改反馈,本阶段继续
|
||||
```
|
||||
|
||||
### 兜底:无 tool_calls 拦截(降级为提醒)
|
||||
|
||||
主循环 `if not tool_calls:` 分支仍保留工作流检查,但语义降为**提醒**:工作流活跃且当前阶段有产出却未汇报时,注入「你还有进行中的工作流阶段 X,请用阶段汇报工具汇报或继续推进」提示并 continue 一轮;连续提醒无响应则按空转保护停止。阶段推进的主路径永远是显式汇报。
|
||||
|
||||
### 其他
|
||||
|
||||
- **结束**三种:汇报至终点点(done)、撞边界(max_stage_rounds / 空转)、用户取消(slash 菜单或 deactivate)。
|
||||
- **压缩维持**:状态落盘 + `handle_task_with_sender` 入口重注入当前阶段上下文(对齐 goal 重注入模式)。
|
||||
- **编排模块**:新增 `server/workflow_flow.py`(仿 `server/goal_flow.py`)。
|
||||
- **审核智能体**:`modules/workflow_review_agent.py`(fork `goal_review_agent.py`),内部工具 `report_workflow_stage_status`(pass/retry/complete);配置 `resolve_deploy_config("workflow_review.json")`。
|
||||
- **与目标模式并存**:不互斥;目标模式后续重做时统一考虑两者关系。
|
||||
|
||||
## 6. 通信与前端
|
||||
|
||||
- **轮询链路**(现成):sender 事件 → `session_data` 快照(`server/tasks/models.py` L763 模式)→ REST 轮询透传(`server/tasks/api.py` L269 模式)→ 前端 `taskPolling/lifecycle.ts` case 消费。
|
||||
- 新增事件:`workflow_progress` / `workflow_review_progress` / `workflow_completed` / `workflow_stopped`。
|
||||
- socket 事件 data 必须带 `conversation_id`,前端按当前对话过滤,保证多对话不串。
|
||||
- **激活**:`InputComposer.vue` slash 菜单新增 `workflows` 模式(数据 `GET /api/workflows`),root 菜单加「工作流」入口;激活/退出走 REST(`POST /api/workflow/activate|deactivate`)。
|
||||
- **进度展示**:仿 goal 进度组件——当前阶段 x/N · 阶段名 · 审核状态 · 轮数。
|
||||
- **轮询链路**(现成):sender 事件 → `session_data` 快照 → REST 轮询透传 → 前端 `taskPolling/lifecycle.ts` case 消费。
|
||||
- 新增事件:`workflow_progress` / `workflow_review_progress` / `workflow_completed` / `workflow_stopped`,快照自带 `conversation_id`(对齐 goal 修复后的过滤机制)。
|
||||
- **激活**:slash 菜单新增 `workflows` 模式(数据 `GET /api/workflows`);`POST /api/workflow/activate|deactivate`。
|
||||
- **进度展示**:仿 goal 进度组件——当前阶段名 · 已完成阶段列表 · 审核状态 · 轮数。
|
||||
- **状态 API**:`GET /api/workflow/status?conversation_id=` 供轮询/刷新恢复。
|
||||
|
||||
## 7. 新增工具与文件清单
|
||||
## 7. 可视化拖拽编辑器(设计讨论中)
|
||||
|
||||
| 项 | 类型 | 参照 |
|
||||
- **技术选型**:Vue Flow(项目为 Vue 3,React Flow 的 Vue 移植,API 同构)。
|
||||
- **编辑对象**:WORKFLOW.md frontmatter 的 stages 拓扑;节点 = 阶段,边 = next 候选路由;`position` 仅存坐标,与执行解耦。
|
||||
- **与 AI 生成协同**:AI `create_workflow` 生成归档 → 画布打开可视化/微调;画布编辑 → 序列化回 WORKFLOW.md。
|
||||
- **页面形态与一期范围**:待定(见 §9)。
|
||||
|
||||
## 8. 新增工具与文件清单
|
||||
|
||||
| 项 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `create_workflow` | AI 工具(生成并归档) | `create_skill` |
|
||||
| `read_workflow` | AI 工具(读定义) | `read_skill` |
|
||||
| `report_workflow_stage_status` | 审核智能体内部工具 | `report_goal_status` |
|
||||
| `modules/workflows_manager.py` | 校验/归档/目录合并 | `modules/skills_manager.py` |
|
||||
| `modules/workflow_state_manager.py` | 对话级状态落盘 | `modules/goal_state_manager.py` |
|
||||
| `server/workflow_flow.py` | 编排(启动/注入/审核/推进) | `server/goal_flow.py` |
|
||||
| `prompts/workflow.txt` | 提示词模板 | `prompts/` 现有模板 |
|
||||
| `server/tasks/workflows.py` | REST API(列表/激活/状态) | `server/tasks/skills.py` |
|
||||
| `create_workflow` | AI 工具 | 生成并校验归档(仿 create_skill) |
|
||||
| `read_workflow` | AI 工具 | 读工作流定义(仿 read_skill) |
|
||||
| `activate_workflow` / `deactivate_workflow` | AI 工具 | 开启/退出当前对话的工作流 |
|
||||
| `report_workflow_stage` | AI 工具 | **阶段汇报**:期间汇报触发审核推进;无后续阶段时即停止信号 |
|
||||
| `report_workflow_stage_status` | 审核智能体内部工具 | pass/retry/complete(仿 report_goal_status) |
|
||||
| `modules/workflows_manager.py` | 模块 | 校验/归档/目录合并(仿 skills_manager) |
|
||||
| `modules/workflow_state_manager.py` | 模块 | 对话级状态落盘(对齐 goal_state_manager 对话级方案) |
|
||||
| `modules/workflow_review_agent.py` | 模块 | 阶段/整体审核 |
|
||||
| `server/workflow_flow.py` | 模块 | 编排:激活/注入/汇报处理/推进/兜底提醒 |
|
||||
| `server/tasks/workflows.py` | REST API | 列表/激活/状态/画布读写 |
|
||||
| `prompts/workflow.txt` | 提示词 | 工作流上下文模板 |
|
||||
|
||||
## 8. 分期
|
||||
## 9. 待确认
|
||||
|
||||
- **一期(本次)**:格式 + 归档工具 + 提示词注入 + 阶段状态机与审核 + 轮询进度 + slash 菜单激活 + 对话级状态隔离。
|
||||
- **二期**:拖拽画布编辑器(React Flow),本质是 stages frontmatter 的可视化编辑器(坐标只作视觉)。
|
||||
- **三期**:分支/DAG、并行阶段、子工作流嵌套。
|
||||
|
||||
## 9. 待确认(实施前如需变更再讨论)
|
||||
|
||||
- 工作流与目标模式是否互斥激活(建议互斥,避免双「防停止」机制叠加)。
|
||||
- `activate_workflow`/`deactivate_workflow` 是否做成 AI 工具(slash 菜单人工激活为一期必须)。
|
||||
- 可视化编辑器页面形态:独立路由页面(如 `/workflows` 库 + 编辑器)还是对话内弹层/抽屉?
|
||||
- 编辑器一期范围:完整拖拽编辑(增删节点/连线/改参数/保存)还是先做只读可视化 + 文本编辑?
|
||||
- 兜底提醒的容忍轮数(建议连续 2 轮无响应转空转停止)。
|
||||
|
||||
328
modules/workflow_manager.py
Normal file
@ -0,0 +1,328 @@
|
||||
"""工作流(Workflow)存储管理:WORKFLOW.md 的加载、保存、列举、删除。
|
||||
|
||||
存储布局(对齐 skills 的双源合并模式):
|
||||
- 内置示例:源码树 ``workflows/<name>/WORKFLOW.md``(只读种子)
|
||||
- 用户库:host 模式 ``<runtime_root>/host/workflows/``;web/docker 模式
|
||||
``users/<user>/personal/workflows/``(从 workspace data_dir 推断)
|
||||
|
||||
文件格式:YAML frontmatter(snake_case 结构)+ markdown 正文(body)。
|
||||
API 层传输使用 camelCase dict,本模块负责双向转换。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import yaml
|
||||
|
||||
from config.paths import CUSTOM_SKILLS_DIR, IS_HOST_MODE
|
||||
|
||||
# 内置示例种子目录(源码树 workflows/)
|
||||
BUILTIN_WORKFLOWS_DIR = Path(__file__).resolve().parent.parent / "workflows"
|
||||
|
||||
WORKFLOW_FILENAME = "WORKFLOW.md"
|
||||
|
||||
# host 模式用户库:与 CUSTOM_SKILLS_DIR 平级(<runtime_root>/<mode>/workflows)
|
||||
CUSTOM_WORKFLOWS_DIR = str(Path(CUSTOM_SKILLS_DIR).parent / "workflows")
|
||||
|
||||
_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 路径推断
|
||||
|
||||
|
||||
def infer_user_workflows_dir(data_dir: str | Path | None) -> Optional[Path]:
|
||||
"""从 workspace data_dir 推断用户工作流库目录(对齐 infer_private_skills_dir)。
|
||||
|
||||
host 模式:统一运行态根下的 workflows/,不按用户拆分。
|
||||
web/docker:users/<user>/personal/workflows/。
|
||||
"""
|
||||
if IS_HOST_MODE:
|
||||
root = Path(CUSTOM_WORKFLOWS_DIR).expanduser().resolve()
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
return root
|
||||
if not data_dir:
|
||||
return None
|
||||
try:
|
||||
data_path = Path(data_dir).expanduser().resolve()
|
||||
if data_path.name == "data" and data_path.parent.parent.name == "workspaces":
|
||||
root = (data_path.parent.parent.parent / "workflows").resolve()
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
return root
|
||||
if data_path.name == "data" and data_path.parent.parent.name in ("projects", "project"):
|
||||
user_root = data_path.parent.parent.parent
|
||||
root = (user_root / "personal" / "workflows").resolve()
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
return root
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _workflow_file(root: Path, name: str) -> Path:
|
||||
return (root / name / WORKFLOW_FILENAME).resolve()
|
||||
|
||||
|
||||
def _safe_name(name: str) -> str:
|
||||
"""校验并返回合法的工作流目录名(slug)。"""
|
||||
cleaned = (name or "").strip()
|
||||
if not _NAME_RE.match(cleaned):
|
||||
raise ValueError(f"工作流名称不合法:{cleaned!r}(仅限小写字母/数字/连字符,3-64 字符)")
|
||||
return cleaned
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- camelCase ↔ snake_case 转换
|
||||
|
||||
_NODE_KINDS = ("start", "end", "stage", "review", "branch")
|
||||
|
||||
|
||||
def _node_to_yaml(node: Dict[str, Any]) -> Dict[str, Any]:
|
||||
out: Dict[str, Any] = {"id": node.get("id"), "kind": node.get("kind")}
|
||||
if node.get("name"):
|
||||
out["name"] = node["name"]
|
||||
kind = node.get("kind")
|
||||
if kind == "stage":
|
||||
out["goal"] = node.get("goal", "")
|
||||
out["instructions"] = node.get("instructions", "")
|
||||
out["next"] = node.get("next")
|
||||
elif kind == "review":
|
||||
out["prompt"] = node.get("prompt", "")
|
||||
out["next"] = node.get("next")
|
||||
out["reject_to"] = node.get("rejectTo")
|
||||
out["max_rejects"] = node.get("maxRejects", 3)
|
||||
elif kind == "branch":
|
||||
out["next"] = [
|
||||
{"target": r.get("target"), "condition": r.get("condition", "")}
|
||||
for r in node.get("next", [])
|
||||
]
|
||||
elif kind == "start":
|
||||
out["next"] = node.get("next")
|
||||
if node.get("position"):
|
||||
out["position"] = {"x": round(node["position"].get("x", 0)), "y": round(node["position"].get("y", 0))}
|
||||
return out
|
||||
|
||||
|
||||
def _node_from_yaml(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
kind = data.get("kind")
|
||||
node: Dict[str, Any] = {
|
||||
"id": str(data.get("id") or ""),
|
||||
"kind": kind if kind in _NODE_KINDS else "stage",
|
||||
"name": str(data.get("name") or data.get("id") or ""),
|
||||
}
|
||||
if node["kind"] == "stage":
|
||||
node["goal"] = str(data.get("goal") or "")
|
||||
node["instructions"] = str(data.get("instructions") or "")
|
||||
node["next"] = data.get("next") or None
|
||||
elif node["kind"] == "review":
|
||||
node["prompt"] = str(data.get("prompt") or "")
|
||||
node["next"] = data.get("next") or None
|
||||
node["rejectTo"] = data.get("reject_to") or None
|
||||
node["maxRejects"] = int(data.get("max_rejects") or 3)
|
||||
elif node["kind"] == "branch":
|
||||
routes = []
|
||||
for r in data.get("next") or []:
|
||||
if isinstance(r, dict) and r.get("target"):
|
||||
routes.append({"target": str(r["target"]), "condition": str(r.get("condition") or "")})
|
||||
node["next"] = routes
|
||||
elif node["kind"] == "start":
|
||||
node["next"] = data.get("next") or None
|
||||
pos = data.get("position")
|
||||
if isinstance(pos, dict) and isinstance(pos.get("x"), (int, float)):
|
||||
node["position"] = {"x": float(pos["x"]), "y": float(pos.get("y") or 0)}
|
||||
return node
|
||||
|
||||
|
||||
def workflow_to_markdown(wf: Dict[str, Any]) -> str:
|
||||
"""camelCase dict → WORKFLOW.md 文本。"""
|
||||
meta = {
|
||||
"name": wf.get("name"),
|
||||
"description": wf.get("description", ""),
|
||||
"review_mode": wf.get("reviewMode", "active"),
|
||||
"max_stage_rounds": int(wf.get("maxStageRounds", 20)),
|
||||
"end_conditions": wf.get("endConditions", ""),
|
||||
"updated_at": wf.get("updatedAt") or datetime.now().strftime("%Y-%m-%d %H:%M"),
|
||||
"nodes": [_node_to_yaml(n) for n in wf.get("nodes", [])],
|
||||
}
|
||||
frontmatter = yaml.safe_dump(meta, allow_unicode=True, sort_keys=False).strip()
|
||||
body = (wf.get("body") or "").strip()
|
||||
return f"---\n{frontmatter}\n---\n\n{body}\n"
|
||||
|
||||
|
||||
def workflow_from_markdown(text: str, source: str) -> Dict[str, Any]:
|
||||
"""WORKFLOW.md 文本 → camelCase dict。"""
|
||||
match = re.match(r"^---\s*\n(.*?)\n---\s*\n?(.*)$", text, re.DOTALL)
|
||||
if not match:
|
||||
raise ValueError("WORKFLOW.md 缺少 YAML frontmatter")
|
||||
meta = yaml.safe_load(match.group(1)) or {}
|
||||
body = match.group(2).strip()
|
||||
return {
|
||||
"name": str(meta.get("name") or ""),
|
||||
"description": str(meta.get("description") or ""),
|
||||
"reviewMode": meta.get("review_mode") if meta.get("review_mode") in ("readonly", "active") else "active",
|
||||
"maxStageRounds": int(meta.get("max_stage_rounds") or 20),
|
||||
"endConditions": str(meta.get("end_conditions") or ""),
|
||||
"updatedAt": str(meta.get("updated_at") or ""),
|
||||
"body": body,
|
||||
"source": source,
|
||||
"nodes": [_node_from_yaml(n) for n in meta.get("nodes") or [] if isinstance(n, dict)],
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 结构校验(保存时强制 error 级)
|
||||
|
||||
|
||||
def validate_structure(wf: Dict[str, Any]) -> List[str]:
|
||||
"""返回 error 级问题列表(空 = 可保存)。规则与前端 validateWorkflow 对齐。"""
|
||||
errors: List[str] = []
|
||||
if not str(wf.get("name") or "").strip():
|
||||
errors.append("工作流缺少 name")
|
||||
nodes = wf.get("nodes") or []
|
||||
if not nodes:
|
||||
errors.append("至少需要一个开始节点和一个结束节点")
|
||||
return errors
|
||||
by_id: Dict[str, Dict[str, Any]] = {}
|
||||
for n in nodes:
|
||||
nid = n.get("id")
|
||||
if nid in by_id:
|
||||
errors.append(f"节点 id 重复:{nid}")
|
||||
by_id[nid] = n
|
||||
starts = [n for n in nodes if n.get("kind") == "start"]
|
||||
ends = [n for n in nodes if n.get("kind") == "end"]
|
||||
if len(starts) == 0:
|
||||
errors.append("缺少开始节点")
|
||||
elif len(starts) > 1:
|
||||
errors.append(f"开始节点只能有一个(当前 {len(starts)} 个)")
|
||||
if not ends:
|
||||
errors.append("缺少结束节点")
|
||||
|
||||
def check_ref(owner: Dict[str, Any], target: Any, label: str) -> None:
|
||||
if not target:
|
||||
errors.append(f"{label}未连接")
|
||||
elif target not in by_id:
|
||||
errors.append(f"{label}指向不存在的节点:{target}")
|
||||
elif by_id[target].get("kind") == "start":
|
||||
errors.append(f"{label}不能指向开始节点")
|
||||
|
||||
for n in nodes:
|
||||
kind = n.get("kind")
|
||||
name = n.get("name") or n.get("id")
|
||||
if kind == "start":
|
||||
check_ref(n, n.get("next"), "开始节点")
|
||||
elif kind == "stage":
|
||||
check_ref(n, n.get("next"), f"阶段「{name}」")
|
||||
elif kind == "review":
|
||||
check_ref(n, n.get("next"), f"审核「{name}」的通过路由")
|
||||
check_ref(n, n.get("rejectTo"), f"审核「{name}」的驳回路由")
|
||||
if not isinstance(n.get("maxRejects"), int) or n["maxRejects"] < 1:
|
||||
errors.append(f"审核「{name}」驳回上限必须 ≥ 1")
|
||||
elif kind == "branch":
|
||||
for r in n.get("next") or []:
|
||||
check_ref(n, r.get("target"), f"分支「{name}」的出线")
|
||||
return errors
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- CRUD
|
||||
|
||||
|
||||
def _iter_workflow_dirs(root: Path) -> List[Tuple[str, Path]]:
|
||||
if not root.exists() or not root.is_dir():
|
||||
return []
|
||||
out = []
|
||||
for child in sorted(root.iterdir()):
|
||||
wf_file = child / WORKFLOW_FILENAME
|
||||
if child.is_dir() and wf_file.exists():
|
||||
out.append((child.name, wf_file))
|
||||
return out
|
||||
|
||||
|
||||
def list_workflows(data_dir: str | Path | None) -> List[Dict[str, Any]]:
|
||||
"""双源合并列举(用户库同名覆盖内置)。"""
|
||||
merged: Dict[str, Dict[str, Any]] = {}
|
||||
for source, root in (("builtin", BUILTIN_WORKFLOWS_DIR), ("user", infer_user_workflows_dir(data_dir))):
|
||||
if not root:
|
||||
continue
|
||||
for name, wf_file in _iter_workflow_dirs(Path(root)):
|
||||
try:
|
||||
wf = workflow_from_markdown(wf_file.read_text(encoding="utf-8"), source)
|
||||
merged[wf["name"] or name] = {
|
||||
"name": wf["name"] or name,
|
||||
"description": wf["description"],
|
||||
"source": source,
|
||||
"updatedAt": wf["updatedAt"],
|
||||
"nodeCount": len(wf["nodes"]),
|
||||
}
|
||||
except Exception:
|
||||
# 损坏文件不拖垮列表
|
||||
merged[name] = {
|
||||
"name": name,
|
||||
"description": "(文件解析失败)",
|
||||
"source": source,
|
||||
"updatedAt": "",
|
||||
"nodeCount": 0,
|
||||
}
|
||||
return sorted(merged.values(), key=lambda x: x["name"])
|
||||
|
||||
|
||||
def load_workflow(name: str, data_dir: str | Path | None) -> Dict[str, Any]:
|
||||
"""加载完整工作流(用户库优先,其次内置)。返回 camelCase dict。"""
|
||||
_safe_name(name)
|
||||
user_root = infer_user_workflows_dir(data_dir)
|
||||
if user_root:
|
||||
user_file = _workflow_file(user_root, name)
|
||||
if user_file.exists():
|
||||
return workflow_from_markdown(user_file.read_text(encoding="utf-8"), "user")
|
||||
builtin_file = _workflow_file(BUILTIN_WORKFLOWS_DIR, name)
|
||||
if builtin_file.exists():
|
||||
return workflow_from_markdown(builtin_file.read_text(encoding="utf-8"), "builtin")
|
||||
raise FileNotFoundError(f"工作流不存在:{name}")
|
||||
|
||||
|
||||
def save_workflow(wf: Dict[str, Any], data_dir: str | Path | None) -> Path:
|
||||
"""保存到用户库(原子写)。结构 error 时 raise ValueError。"""
|
||||
name = _safe_name(str(wf.get("name") or ""))
|
||||
errors = validate_structure(wf)
|
||||
if errors:
|
||||
raise ValueError("工作流结构校验未通过:" + ";".join(errors))
|
||||
root = infer_user_workflows_dir(data_dir)
|
||||
if not root:
|
||||
raise ValueError("无法确定用户工作流库目录")
|
||||
wf = dict(wf)
|
||||
wf["name"] = name
|
||||
wf["updatedAt"] = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||
target_dir = (root / name).resolve()
|
||||
if not str(target_dir).startswith(str(root.resolve())):
|
||||
raise ValueError("非法路径")
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
content = workflow_to_markdown(wf)
|
||||
fd, tmp_path = tempfile.mkstemp(dir=str(target_dir), prefix=".WORKFLOW.", suffix=".tmp")
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
os.replace(tmp_path, target_dir / WORKFLOW_FILENAME)
|
||||
finally:
|
||||
if os.path.exists(tmp_path):
|
||||
os.unlink(tmp_path)
|
||||
return target_dir / WORKFLOW_FILENAME
|
||||
|
||||
|
||||
def delete_workflow(name: str, data_dir: str | Path | None) -> None:
|
||||
"""删除用户库中的工作流(内置示例不可删)。"""
|
||||
_safe_name(name)
|
||||
root = infer_user_workflows_dir(data_dir)
|
||||
if not root:
|
||||
raise ValueError("无法确定用户工作流库目录")
|
||||
target_dir = (root / name).resolve()
|
||||
if not str(target_dir).startswith(str(root.resolve())):
|
||||
raise ValueError("非法路径")
|
||||
if not target_dir.exists():
|
||||
builtin_file = _workflow_file(BUILTIN_WORKFLOWS_DIR, name)
|
||||
if builtin_file.exists():
|
||||
raise ValueError("内置示例不可删除(可复制为用户工作流后删除副本)")
|
||||
raise FileNotFoundError(f"工作流不存在:{name}")
|
||||
shutil.rmtree(target_dir)
|
||||
269
package-lock.json
generated
@ -8,12 +8,17 @@
|
||||
"name": "astrion-frontend",
|
||||
"version": "4.1.0",
|
||||
"dependencies": {
|
||||
"@dagrejs/dagre": "^3.1.1",
|
||||
"@tiptap/extension-mention": "^3.23.6",
|
||||
"@tiptap/extension-placeholder": "^3.23.6",
|
||||
"@tiptap/starter-kit": "^3.23.6",
|
||||
"@tiptap/suggestion": "^3.23.6",
|
||||
"@tiptap/vue-3": "^3.23.6",
|
||||
"@types/html2canvas": "^0.5.35",
|
||||
"@vue-flow/background": "^1.3.2",
|
||||
"@vue-flow/controls": "^1.1.3",
|
||||
"@vue-flow/core": "^1.48.2",
|
||||
"@vue-flow/minimap": "^1.5.4",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"diff": "^8.0.3",
|
||||
@ -279,6 +284,21 @@
|
||||
"@csstools/css-tokenizer": "^3.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@dagrejs/dagre": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-3.1.1.tgz",
|
||||
"integrity": "sha512-zroZB1dFOFiGgv4Xcrn1DckB1o4aOikPqD2NDQPV0WM//CXGcS6xiD0rNkqHmw6FEg4tabt4nxPLwgCWT+Vb2A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dagrejs/graphlib": "4.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@dagrejs/graphlib": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@dagrejs/graphlib/-/graphlib-4.0.5.tgz",
|
||||
"integrity": "sha512-7xrBTqIts3o+PMUZX97wSc+7TUbW+/rULzGNCTP6yooNVDXbzw4Wutg/H/xOutTB/c/k0YqOAavgPh4/Zk9PFA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@dual-bundle/import-meta-resolve": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@dual-bundle/import-meta-resolve/-/import-meta-resolve-4.2.1.tgz",
|
||||
@ -2421,6 +2441,12 @@
|
||||
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/web-bluetooth": {
|
||||
"version": "0.0.20",
|
||||
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz",
|
||||
"integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz",
|
||||
@ -2670,6 +2696,56 @@
|
||||
"path-browserify": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue-flow/background": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@vue-flow/background/-/background-1.3.2.tgz",
|
||||
"integrity": "sha512-eJPhDcLj1wEo45bBoqTXw1uhl0yK2RaQGnEINqvvBsAFKh/camHJd5NPmOdS1w+M9lggc9igUewxaEd3iCQX2w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@vue-flow/core": "^1.23.0",
|
||||
"vue": "^3.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue-flow/controls": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@vue-flow/controls/-/controls-1.1.3.tgz",
|
||||
"integrity": "sha512-XCf+G+jCvaWURdFlZmOjifZGw3XMhN5hHlfMGkWh9xot+9nH9gdTZtn+ldIJKtarg3B21iyHU8JjKDhYcB6JMw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@vue-flow/core": "^1.23.0",
|
||||
"vue": "^3.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue-flow/core": {
|
||||
"version": "1.48.2",
|
||||
"resolved": "https://registry.npmjs.org/@vue-flow/core/-/core-1.48.2.tgz",
|
||||
"integrity": "sha512-raxhgKWE+G/mcEvXJjGFUDYW9rAI3GOtiHR3ZkNpwBWuIaCC1EYiBmKGwJOoNzVFgwO7COgErnK7i08i287AFA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vueuse/core": "^10.5.0",
|
||||
"d3-drag": "^3.0.0",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-selection": "^3.0.0",
|
||||
"d3-zoom": "^3.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue-flow/minimap": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@vue-flow/minimap/-/minimap-1.5.4.tgz",
|
||||
"integrity": "sha512-l4C+XTAXnRxsRpUdN7cAVFBennC1sVRzq4bDSpVK+ag7tdMczAnhFYGgbLkUw3v3sY6gokyWwMl8CDonp8eB2g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"d3-selection": "^3.0.0",
|
||||
"d3-zoom": "^3.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@vue-flow/core": "^1.23.0",
|
||||
"vue": "^3.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-core": {
|
||||
"version": "3.5.35",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.35.tgz",
|
||||
@ -2881,6 +2957,94 @@
|
||||
"integrity": "sha512-zSbjL7gRXwks2ZQLRGCajBtBXEOXW9Ddhn/HvSdrGkE2dqGnumzW8XtusRrxrE9LvqtiqDXQ+A60Hp6mvdYxfA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vueuse/core": {
|
||||
"version": "10.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-10.11.1.tgz",
|
||||
"integrity": "sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/web-bluetooth": "^0.0.20",
|
||||
"@vueuse/metadata": "10.11.1",
|
||||
"@vueuse/shared": "10.11.1",
|
||||
"vue-demi": ">=0.14.8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/@vueuse/core/node_modules/vue-demi": {
|
||||
"version": "0.14.10",
|
||||
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
|
||||
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"vue-demi-fix": "bin/vue-demi-fix.js",
|
||||
"vue-demi-switch": "bin/vue-demi-switch.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@vue/composition-api": "^1.0.0-rc.1",
|
||||
"vue": "^3.0.0-0 || ^2.6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@vue/composition-api": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vueuse/metadata": {
|
||||
"version": "10.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-10.11.1.tgz",
|
||||
"integrity": "sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/@vueuse/shared": {
|
||||
"version": "10.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-10.11.1.tgz",
|
||||
"integrity": "sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"vue-demi": ">=0.14.8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/@vueuse/shared/node_modules/vue-demi": {
|
||||
"version": "0.14.10",
|
||||
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
|
||||
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"vue-demi-fix": "bin/vue-demi-fix.js",
|
||||
"vue-demi-switch": "bin/vue-demi-switch.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@vue/composition-api": "^1.0.0-rc.1",
|
||||
"vue": "^3.0.0-0 || ^2.6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@vue/composition-api": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-fit": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
|
||||
@ -3335,6 +3499,111 @@
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-dispatch": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
|
||||
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-drag": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
|
||||
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-selection": "3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-selection": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-timer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-transition": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
|
||||
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3",
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-ease": "1 - 3",
|
||||
"d3-interpolate": "1 - 3",
|
||||
"d3-timer": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"d3-selection": "2 - 3"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-zoom": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
|
||||
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-drag": "2 - 3",
|
||||
"d3-interpolate": "1 - 3",
|
||||
"d3-selection": "2 - 3",
|
||||
"d3-transition": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/de-indent": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",
|
||||
|
||||
@ -15,12 +15,17 @@
|
||||
"cli:typecheck": "npm --prefix cli run typecheck"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dagrejs/dagre": "^3.1.1",
|
||||
"@tiptap/extension-mention": "^3.23.6",
|
||||
"@tiptap/extension-placeholder": "^3.23.6",
|
||||
"@tiptap/starter-kit": "^3.23.6",
|
||||
"@tiptap/suggestion": "^3.23.6",
|
||||
"@tiptap/vue-3": "^3.23.6",
|
||||
"@types/html2canvas": "^0.5.35",
|
||||
"@vue-flow/background": "^1.3.2",
|
||||
"@vue-flow/controls": "^1.1.3",
|
||||
"@vue-flow/core": "^1.48.2",
|
||||
"@vue-flow/minimap": "^1.5.4",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"diff": "^8.0.3",
|
||||
|
||||
@ -9,3 +9,4 @@ openai
|
||||
cryptography
|
||||
pillow
|
||||
websockets
|
||||
pyyaml>=6.0
|
||||
|
||||
@ -37,6 +37,7 @@ from server.status import status_bp
|
||||
from server.tasks import tasks_bp
|
||||
from server.api_v1 import api_v1_bp
|
||||
from server.multi_agent import multi_agent_bp
|
||||
from server.workflow_page import workflow_page_bp
|
||||
from server.conversation_bootstrap import conversation_bootstrap_bp
|
||||
from server.socket_handlers import socketio
|
||||
from server.security import attach_security_hooks
|
||||
@ -301,6 +302,7 @@ app.register_blueprint(status_bp)
|
||||
app.register_blueprint(tasks_bp)
|
||||
app.register_blueprint(api_v1_bp)
|
||||
app.register_blueprint(multi_agent_bp)
|
||||
app.register_blueprint(workflow_page_bp)
|
||||
app.register_blueprint(conversation_bootstrap_bp)
|
||||
|
||||
# 安全钩子(CSRF 校验 + 响应头)
|
||||
|
||||
96
server/workflow_page.py
Normal file
@ -0,0 +1,96 @@
|
||||
"""工作流页面路由 + REST API。
|
||||
|
||||
- `/workflows` 工作流库列表页,返回主 SPA 入口,由前端 bootstrapRoute 识别路径
|
||||
- `/workflow/<name>` 工作流编辑器页,同上
|
||||
- `/api/workflows` 系列:工作流 CRUD(WORKFLOW.md 落盘,见 modules/workflow_manager.py)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Blueprint, current_app, jsonify, request
|
||||
|
||||
from modules.workflow_manager import (
|
||||
delete_workflow,
|
||||
list_workflows,
|
||||
load_workflow,
|
||||
save_workflow,
|
||||
)
|
||||
from server.auth_helpers import api_login_required, login_required
|
||||
from server.context import with_terminal
|
||||
|
||||
workflow_page_bp = Blueprint("workflow_page", __name__)
|
||||
|
||||
|
||||
@workflow_page_bp.route("/workflows")
|
||||
@login_required
|
||||
def workflow_library_page():
|
||||
"""工作流库入口,返回与 /new 相同的 SPA index.html。"""
|
||||
return current_app.send_static_file("index.html")
|
||||
|
||||
|
||||
@workflow_page_bp.route("/workflow/<path:name>")
|
||||
@login_required
|
||||
def workflow_editor_page(name: str):
|
||||
"""工作流编辑器入口,返回 SPA index.html 让前端路由处理。"""
|
||||
return current_app.send_static_file("index.html")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- REST API
|
||||
|
||||
|
||||
@workflow_page_bp.route("/api/workflows", methods=["GET"])
|
||||
@api_login_required
|
||||
@with_terminal
|
||||
def api_list_workflows(terminal, workspace, username):
|
||||
"""工作流列表(内置 + 用户库双源合并,仅元信息)。"""
|
||||
try:
|
||||
return jsonify({"workflows": list_workflows(workspace.data_dir)})
|
||||
except Exception as exc:
|
||||
return jsonify({"error": f"加载工作流列表失败:{exc}"}), 500
|
||||
|
||||
|
||||
@workflow_page_bp.route("/api/workflows/<path:name>", methods=["GET"])
|
||||
@api_login_required
|
||||
@with_terminal
|
||||
def api_load_workflow(name: str, terminal, workspace, username):
|
||||
"""加载完整工作流定义(用户库优先,其次内置)。"""
|
||||
try:
|
||||
return jsonify({"workflow": load_workflow(name, workspace.data_dir)})
|
||||
except FileNotFoundError:
|
||||
return jsonify({"error": f"工作流不存在:{name}"}), 404
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
|
||||
|
||||
@workflow_page_bp.route("/api/workflows/<path:name>", methods=["PUT"])
|
||||
@api_login_required
|
||||
@with_terminal
|
||||
def api_save_workflow(name: str, terminal, workspace, username):
|
||||
"""保存工作流到用户库(原子写;结构 error 级校验不过则 400)。"""
|
||||
data = request.get_json(silent=True)
|
||||
if not isinstance(data, dict) or not isinstance(data.get("workflow"), dict):
|
||||
return jsonify({"error": "请求体缺少 workflow 对象"}), 400
|
||||
wf = dict(data["workflow"])
|
||||
wf["name"] = name # 名称以 URL 为准
|
||||
try:
|
||||
save_workflow(wf, workspace.data_dir)
|
||||
return jsonify({"ok": True})
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
except OSError as exc:
|
||||
return jsonify({"error": f"写入文件失败:{exc}"}), 500
|
||||
|
||||
|
||||
@workflow_page_bp.route("/api/workflows/<path:name>", methods=["DELETE"])
|
||||
@api_login_required
|
||||
@with_terminal
|
||||
def api_delete_workflow(name: str, terminal, workspace, username):
|
||||
"""删除用户库中的工作流(内置示例不可删)。"""
|
||||
try:
|
||||
delete_workflow(name, workspace.data_dir)
|
||||
return jsonify({"ok": True})
|
||||
except FileNotFoundError:
|
||||
return jsonify({"error": f"工作流不存在:{name}"}), 404
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
except OSError as exc:
|
||||
return jsonify({"error": f"删除失败:{exc}"}), 500
|
||||
14
static/icons/arrow-left.svg
Normal file
@ -0,0 +1,14 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m12 19-7-7 7-7" />
|
||||
<path d="M19 12H5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 262 B |
13
static/icons/chevron-down.svg
Normal file
@ -0,0 +1,13 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 236 B |
16
static/icons/git-branch.svg
Normal file
@ -0,0 +1,16 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<line x1="6" x2="6" y1="3" y2="15" />
|
||||
<circle cx="18" cy="6" r="3" />
|
||||
<circle cx="6" cy="18" r="3" />
|
||||
<path d="M18 9a9 9 0 0 1-9 9" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 351 B |
16
static/icons/layout-grid.svg
Normal file
@ -0,0 +1,16 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect width="7" height="7" x="3" y="3" rx="1" />
|
||||
<rect width="7" height="7" x="14" y="3" rx="1" />
|
||||
<rect width="7" height="7" x="14" y="14" rx="1" />
|
||||
<rect width="7" height="7" x="3" y="14" rx="1" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 416 B |
13
static/icons/play.svg
Normal file
@ -0,0 +1,13 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<polygon points="6 3 20 12 6 21 6 3" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 250 B |
14
static/icons/plus.svg
Normal file
@ -0,0 +1,14 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M5 12h14" />
|
||||
<path d="M12 5v14" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 256 B |
16
static/icons/workflow.svg
Normal file
@ -0,0 +1,16 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect width="8" height="8" x="3" y="3" rx="2" />
|
||||
<path d="M7 11v4a2 2 0 0 0 2 2h4" />
|
||||
<rect width="8" height="8" x="13" y="13" rx="2" />
|
||||
<path d="M17 9V7a2 2 0 0 0-2-2h-4" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 391 B |
@ -96,6 +96,7 @@
|
||||
@rename-workspace="handleRenameWorkspaceFromSidebar"
|
||||
@pin-workspace="handlePinWorkspaceFromSidebar"
|
||||
@conversation-type-change="handleSidebarConversationTypeChange"
|
||||
@open-workflows="openWorkflowsPage"
|
||||
/>
|
||||
|
||||
<main
|
||||
@ -817,6 +818,9 @@
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<!-- 工作流编辑器 demo:/workflows 与 /workflow/<name> 全屏覆盖层 -->
|
||||
<WorkflowDemoShell v-if="workflowDemoRoute" :route-path="workflowDemoRoute" />
|
||||
</template>
|
||||
</AppShell>
|
||||
</template>
|
||||
|
||||
@ -42,6 +42,9 @@ const NewUserTutorialPrompt = defineAsyncComponent(
|
||||
const GoalProgressDialog = defineAsyncComponent(
|
||||
() => import('../components/overlay/GoalProgressDialog.vue')
|
||||
);
|
||||
const WorkflowDemoShell = defineAsyncComponent(
|
||||
() => import('../components/workflow/WorkflowDemoShell.vue')
|
||||
);
|
||||
|
||||
export const appComponents = {
|
||||
ChatArea,
|
||||
@ -65,5 +68,6 @@ export const appComponents = {
|
||||
PlanApprovalDialog,
|
||||
TutorialOverlay,
|
||||
NewUserTutorialPrompt,
|
||||
GoalProgressDialog
|
||||
GoalProgressDialog,
|
||||
WorkflowDemoShell
|
||||
};
|
||||
|
||||
@ -92,7 +92,8 @@ export const loadMethods = {
|
||||
queryOffset === 0 &&
|
||||
!this.currentConversationId &&
|
||||
cache.list.length > 0 &&
|
||||
!this.isExplicitNewConversationRoute()
|
||||
!this.isExplicitNewConversationRoute() &&
|
||||
!this.isConversationIndependentRoute()
|
||||
) {
|
||||
// 只有在初始化完成后,才自动加载第一个对话
|
||||
// 避免与 bootstrapRoute 冲突
|
||||
|
||||
@ -117,15 +117,20 @@ export const compressionMethods = {
|
||||
messagesLen: Array.isArray(this.messages) ? this.messages.length : -1
|
||||
});
|
||||
|
||||
// 显式新建对话路由(/new、/multiagent/new):运行中的任务属于上一个对话,
|
||||
// 用户主动选择了空白页。这里不能接管任务/重放事件/恢复轮询——
|
||||
// loadRunningTask(null) 会匹配任意运行中任务,一旦接管,该对话的
|
||||
// 用户输入/AI输出/工具调用会被全量重放到空白页(刷新也复现)。
|
||||
// 显式新建对话路由(/new、/multiagent/new)与独立全屏路由(工作流编辑器等):
|
||||
// 前者是用户主动选择的空白页,后者不归对话体系。这里不能接管任务/重放事件/
|
||||
// 恢复轮询——loadRunningTask(null) 会匹配任意运行中任务,一旦接管,该对话的
|
||||
// 用户输入/AI输出/工具调用会被全量重放到空白页/被覆盖的隐藏视图(刷新也复现),
|
||||
// 压缩完成等回调还会 loadConversation 改写 URL。
|
||||
// 侧边栏运行标识由 refreshRunningWorkspaceTasks 独立维护,不受影响。
|
||||
const onIndependentRoute =
|
||||
typeof this.isConversationIndependentRoute === 'function' &&
|
||||
this.isConversationIndependentRoute();
|
||||
if (
|
||||
!this.currentConversationId &&
|
||||
typeof this.isExplicitNewConversationRoute === 'function' &&
|
||||
this.isExplicitNewConversationRoute()
|
||||
((typeof this.isExplicitNewConversationRoute === 'function' &&
|
||||
this.isExplicitNewConversationRoute()) ||
|
||||
onIndependentRoute)
|
||||
) {
|
||||
restoreDebugLog('restore:skip-explicit-new-route', {
|
||||
path: typeof window !== 'undefined' ? window.location.pathname : ''
|
||||
|
||||
@ -71,17 +71,21 @@ export const lifecycleMethods = {
|
||||
'shallow_compression',
|
||||
'compression_finished'
|
||||
]);
|
||||
// 显式新建路由(/new、/multiagent/new)上 currentConversationId 为空是常态:
|
||||
// 空 id 不等于“无归属”,此时任何携带 conversation_id 的事件都属于其他对话,
|
||||
// 必须丢弃,否则运行中对话的用户输入/AI输出/工具调用会渲染到空白页。
|
||||
// 显式新建路由(/new、/multiagent/new)与独立全屏路由(工作流编辑器等)上
|
||||
// currentConversationId 为空是常态:空 id 不等于“无归属”,此时任何携带
|
||||
// conversation_id 的事件都属于其他对话,必须丢弃,否则运行中对话的
|
||||
// 用户输入/AI输出/工具调用会渲染到空白页/被覆盖的隐藏视图。
|
||||
const onExplicitNewRoute =
|
||||
typeof this.isExplicitNewConversationRoute === 'function' &&
|
||||
this.isExplicitNewConversationRoute();
|
||||
const onIndependentRoute =
|
||||
typeof this.isConversationIndependentRoute === 'function' &&
|
||||
this.isConversationIndependentRoute();
|
||||
const eventConversationMismatch = !!(
|
||||
eventData.conversation_id &&
|
||||
(this.currentConversationId
|
||||
? eventData.conversation_id !== this.currentConversationId
|
||||
: onExplicitNewRoute)
|
||||
: onExplicitNewRoute || onIndependentRoute)
|
||||
);
|
||||
if (!crossConversationAllowed.has(eventType) && eventConversationMismatch) {
|
||||
{
|
||||
|
||||
@ -105,6 +105,8 @@ export const syncMethods = {
|
||||
}
|
||||
},
|
||||
handleConversationResolved(data: any) {
|
||||
// 独立全屏路由(工作流编辑器等)下不恢复对话、不改写 URL
|
||||
if (this.isConversationIndependentRoute()) return;
|
||||
if (data && data.conversation_id) {
|
||||
this.currentConversationId = data.conversation_id;
|
||||
if (data.title) {
|
||||
|
||||
@ -30,7 +30,7 @@ export const routeMethods = {
|
||||
this.titleReady = false;
|
||||
this.currentConversationTitle = '';
|
||||
this.titleTypingText = '';
|
||||
let path = window.location.pathname.replace(/^\/+/, '');
|
||||
let path = window.location.pathname.replace(/^\/+|\/+$/g, '');
|
||||
// 兼容重定向:旧多智能体路由统一收敛到裸路径。
|
||||
// 对话类型是 metadata 属性(创建时确定),不再是路由概念。
|
||||
if (path === 'multiagent/new' || path === 'multiagent') {
|
||||
@ -42,6 +42,21 @@ export const routeMethods = {
|
||||
history.replaceState({}, '', `/${bareId}`);
|
||||
path = bareId;
|
||||
}
|
||||
// 工作流编辑器:独立于对话体系的全屏路由,直接交给前端组件
|
||||
if (this.isConversationIndependentRoute()) {
|
||||
if (path === 'workflow') {
|
||||
history.replaceState({}, '', '/workflows');
|
||||
path = 'workflows';
|
||||
}
|
||||
this.workflowDemoRoute = path;
|
||||
this.currentConversationId = null;
|
||||
this.currentConversationTitle = '';
|
||||
this.messages = [];
|
||||
this.titleReady = true;
|
||||
this.suppressTitleTyping = false;
|
||||
this.initialRouteResolved = true;
|
||||
return;
|
||||
}
|
||||
if (!path || this.isExplicitNewConversationRoute()) {
|
||||
this.currentConversationId = null;
|
||||
this.currentConversationTitle = '新对话';
|
||||
@ -136,6 +151,25 @@ export const routeMethods = {
|
||||
// 多智能体旧路由在 bootstrapRoute 已被重定向到 /new,这里只判裸新建路由
|
||||
return normalizedPath === 'new';
|
||||
},
|
||||
/**
|
||||
* 当前 URL 是否为独立于对话体系的全屏路由(当前:/workflows、/workflow/*)。
|
||||
* 与 isExplicitNewConversationRoute 一样直接读 location.pathname,不依赖
|
||||
* bootstrap 阶段写入的状态字段,任意时机调用结果都正确。
|
||||
* 对话体系的自动恢复/广播接管/任务接管/URL 写回在这些路由下必须全部禁用,
|
||||
* 这是唯一判定入口,新增同类路由(全屏独立页)时在此登记。
|
||||
*/
|
||||
isConversationIndependentRoute() {
|
||||
const normalizedPath = window.location.pathname.replace(/^\/+|\/+$/g, '');
|
||||
return (
|
||||
normalizedPath === 'workflows' ||
|
||||
normalizedPath === 'workflow' ||
|
||||
normalizedPath.startsWith('workflow/')
|
||||
);
|
||||
},
|
||||
openWorkflowsPage() {
|
||||
// 工作流编辑器是 bootstrap 级全屏路由,与退出方向(/new)对称使用整页跳转,保证状态干净
|
||||
window.location.assign('/workflows');
|
||||
},
|
||||
stripConversationPrefix(conversationId) {
|
||||
if (!conversationId) return '';
|
||||
return conversationId.startsWith('conv_') ? conversationId.slice(5) : conversationId;
|
||||
|
||||
@ -371,13 +371,15 @@ export const socketMethods = {
|
||||
const currentPath = window.location.pathname.replace(/^\/+/, '');
|
||||
const isMultiAgentNewRoute = currentPath === 'multiagent/new' || currentPath === 'multiagent';
|
||||
|
||||
// 显式新建对话路由(/new、/multiagent/new)永不自动恢复运行中的对话:
|
||||
// 用户主动点击"新建对话"就是明确要空白页,运行中任务可从侧边栏随时切回。
|
||||
// 显式新建对话路由(/new、/multiagent/new)与独立全屏路由(工作流编辑器等)
|
||||
// 永不自动恢复运行中的对话:前者是用户明确要空白页,后者不归对话体系,
|
||||
// 运行中任务可从侧边栏随时切回。
|
||||
if (
|
||||
resumeConversationId &&
|
||||
!this.currentConversationId &&
|
||||
!isMultiAgentNewRoute &&
|
||||
!isExplicitNewRoute
|
||||
!isExplicitNewRoute &&
|
||||
!this.isConversationIndependentRoute()
|
||||
) {
|
||||
this.skipConversationHistoryReload = true;
|
||||
// 首次从状态恢复对话时,避免 socket 的 conversation_loaded 再次触发历史加载
|
||||
|
||||
@ -52,6 +52,8 @@ export function dataState() {
|
||||
// 路由相关
|
||||
initialRouteResolved: false,
|
||||
dropToolEvents: false,
|
||||
// 工作流编辑器 demo 路由('workflows' | 'workflow/<name>',空串 = 不在 demo 视图)
|
||||
workflowDemoRoute: '',
|
||||
// 当前打开对话的类型('normal' | 'multi_agent',创建时确定、不可变);
|
||||
// 空对话态为 null。权威来源是对话 metadata.multi_agent_mode。
|
||||
currentConversationType: null,
|
||||
|
||||
@ -94,6 +94,34 @@
|
||||
</span>
|
||||
<span class="sidebar-nav-label">{{ workspaceKind === 'project' ? '项目' : '工作区' }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="sidebar-nav-row"
|
||||
title="工作流"
|
||||
@click="$emit('open-workflows')"
|
||||
>
|
||||
<span class="sidebar-nav-icon workflow-icon" aria-hidden="true">
|
||||
<!-- 与其他导航按钮统一:内联 workflow 图标,避免 mask 图标异步加载闪烁 -->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.6"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect width="8" height="8" x="3" y="3" rx="2" />
|
||||
<path d="M7 11v4a2 2 0 0 0 2 2h4" />
|
||||
<rect width="8" height="8" x="13" y="13" rx="2" />
|
||||
<path d="M17 9V7a2 2 0 0 0-2-2h-4" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="sidebar-nav-label">工作流</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="conversation-search">
|
||||
@ -751,6 +779,7 @@ const emit = defineEmits<{
|
||||
(event: 'rename-workspace', payload: { workspaceId: string; label: string }): void;
|
||||
(event: 'pin-workspace', workspaceId: string): void;
|
||||
(event: 'conversation-type-change', type: 'normal' | 'multi_agent'): void;
|
||||
(event: 'open-workflows'): void;
|
||||
}>();
|
||||
|
||||
const uiStore = useUiStore();
|
||||
|
||||
116
static/src/components/workflow/WorkflowBoundaryNode.vue
Normal file
@ -0,0 +1,116 @@
|
||||
<template>
|
||||
<div
|
||||
class="boundary-node"
|
||||
:class="[
|
||||
`boundary-node--${data.node.kind}`,
|
||||
{
|
||||
'boundary-node--selected': selected,
|
||||
'boundary-node--issue': data.hasIssue,
|
||||
},
|
||||
]"
|
||||
>
|
||||
<!-- 结束节点左侧入桩:每条入线一个桩(含 1 个常驻空桩),上下均布 -->
|
||||
<Handle
|
||||
v-if="data.node.kind === 'end'"
|
||||
v-for="i in data.inCount + 1"
|
||||
:key="`in-${i - 1}`"
|
||||
:id="`in-${i - 1}`"
|
||||
type="target"
|
||||
:position="Position.Left"
|
||||
:class="{ 'boundary-node__in--empty': i > data.inCount }"
|
||||
:style="{ top: `${(i / (data.inCount + 2)) * 100}%` }"
|
||||
/>
|
||||
<span
|
||||
class="icon boundary-node__icon"
|
||||
:style="iconSrc(data.node.kind === 'start' ? ICONS.play : ICONS.octagon)"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<span class="boundary-node__label">{{ data.node.name || (data.node.kind === 'start' ? '开始' : '结束') }}</span>
|
||||
<Handle v-if="data.node.kind === 'start'" id="out-0" type="source" :position="Position.Right" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Handle, Position } from '@vue-flow/core';
|
||||
import { ICONS } from '@/utils/icons';
|
||||
import type { WorkflowEndDef, WorkflowStartDef } from './workflowModel';
|
||||
|
||||
interface BoundaryNodeData {
|
||||
node: WorkflowStartDef | WorkflowEndDef;
|
||||
hasIssue: boolean;
|
||||
/** 结束节点左入桩已占用数量(组件渲染 inCount + 1,含 1 个常驻空桩) */
|
||||
inCount: number;
|
||||
outCount: number;
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
id: string;
|
||||
data: BoundaryNodeData;
|
||||
selected?: boolean;
|
||||
}>();
|
||||
|
||||
function iconSrc(url: string) {
|
||||
return { '--icon-src': `url(${url})` } as Record<string, string>;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.boundary-node {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 36px;
|
||||
padding: 0 16px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--border-default);
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
user-select: none;
|
||||
cursor: grab;
|
||||
transition: border-color 0.15s ease;
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
&--selected {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
&--issue {
|
||||
border-color: var(--state-danger);
|
||||
}
|
||||
|
||||
/* 空入桩弱化:仅作拉新线的入口 */
|
||||
:deep(.boundary-node__in--empty) {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
--icon-size: 13px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&--start {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--state-success);
|
||||
|
||||
.boundary-node__icon {
|
||||
color: var(--state-success);
|
||||
}
|
||||
|
||||
&.boundary-node--selected {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
&.boundary-node--issue {
|
||||
border-color: var(--state-danger);
|
||||
}
|
||||
}
|
||||
|
||||
&--end {
|
||||
border-style: dashed;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
142
static/src/components/workflow/WorkflowBranchNode.vue
Normal file
@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<div
|
||||
class="branch-node"
|
||||
:class="{
|
||||
'branch-node--selected': selected,
|
||||
'branch-node--issue': data.hasIssue,
|
||||
}"
|
||||
:style="{ minHeight: `${bodyHeight}px` }"
|
||||
>
|
||||
<!-- 左:入桩 × n + 1 个常驻空桩(拉新线用,弱化显示;与右侧行为一致) -->
|
||||
<Handle
|
||||
v-for="i in data.inCount + 1"
|
||||
:key="`in-${i - 1}`"
|
||||
:id="`in-${i - 1}`"
|
||||
type="target"
|
||||
:position="Position.Left"
|
||||
:class="{ 'branch-node__in--empty': i > data.inCount }"
|
||||
:style="{ top: `${(i / (data.inCount + 2)) * 100}%` }"
|
||||
/>
|
||||
<!-- 右:出桩 × n + 1 个常驻空桩(拉新线用,弱化显示) -->
|
||||
<Handle
|
||||
v-for="i in data.outCount + 1"
|
||||
:key="`out-${i - 1}`"
|
||||
:id="`out-${i - 1}`"
|
||||
type="source"
|
||||
:position="Position.Right"
|
||||
:class="{ 'branch-node__out--empty': i > data.outCount }"
|
||||
:style="{ top: `${(i / (data.outCount + 2)) * 100}%` }"
|
||||
/>
|
||||
<!-- 上/下:驳回红线接收入口(语义相同,按相对位置自动选向,只进不出) -->
|
||||
<Handle id="in-top" type="target" :position="Position.Top" class="branch-node__reject-in" />
|
||||
<Handle id="in-bottom" type="target" :position="Position.Bottom" class="branch-node__reject-in" />
|
||||
<div class="branch-node__body">
|
||||
<span class="icon branch-node__icon" :style="iconSrc(ICONS.gitBranch)" aria-hidden="true"></span>
|
||||
<span class="branch-node__name">{{ data.node.name || data.node.id }}</span>
|
||||
<span class="branch-node__meta">{{ data.outCount }} 出</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { Handle, Position } from '@vue-flow/core';
|
||||
import { ICONS } from '@/utils/icons';
|
||||
import type { WorkflowBranchDef } from './workflowModel';
|
||||
|
||||
interface BranchNodeData {
|
||||
node: WorkflowBranchDef;
|
||||
isEntry: boolean;
|
||||
hasIssue: boolean;
|
||||
/** 入桩数量(= 入线数量,至少 1) */
|
||||
inCount: number;
|
||||
/** 已占用出桩数量(= 右出线数量);实际渲染 outCount + 1(1 个空桩) */
|
||||
outCount: number;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
id: string;
|
||||
data: BranchNodeData;
|
||||
selected?: boolean;
|
||||
}>();
|
||||
|
||||
// 高度随左右桩数多的一方自适应(各含 1 个空桩),桩位不挤
|
||||
const bodyHeight = computed(() => {
|
||||
const rows = Math.max(props.data.inCount + 1, props.data.outCount + 1, 2);
|
||||
return Math.max(64, rows * 22 + 28);
|
||||
});
|
||||
|
||||
function iconSrc(url: string) {
|
||||
return { '--icon-src': `url(${url})` } as Record<string, string>;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.branch-node {
|
||||
width: 150px;
|
||||
background: var(--surface-raised);
|
||||
border: 1px dashed var(--border-strong);
|
||||
border-radius: 10px;
|
||||
box-shadow: var(--shadow-soft);
|
||||
font-family: inherit;
|
||||
cursor: grab;
|
||||
transition: border-color 0.15s ease;
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
&--selected {
|
||||
border-color: var(--accent);
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
&--issue {
|
||||
border-color: var(--state-danger);
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
/* 空桩弱化:仅作拉新线的入口 */
|
||||
:deep(.branch-node__out--empty),
|
||||
:deep(.branch-node__in--empty) {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
/* 驳回入口(上/下)视觉弱化:仅红线落点 */
|
||||
:deep(.branch-node__reject-in) {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
min-height: inherit;
|
||||
padding: 8px 14px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
--icon-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
&__name {
|
||||
max-width: 100%;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
font-size: 10px;
|
||||
color: var(--text-tertiary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
234
static/src/components/workflow/WorkflowDemoShell.vue
Normal file
@ -0,0 +1,234 @@
|
||||
<template>
|
||||
<div class="wf-shell">
|
||||
<WorkflowEditorView
|
||||
v-if="view === 'editor' && currentWorkflow"
|
||||
:workflow="currentWorkflow"
|
||||
@back="backToLibrary"
|
||||
@save="onSaved"
|
||||
/>
|
||||
<WorkflowLibraryView
|
||||
v-else-if="view === 'library' && listLoaded"
|
||||
:workflows="workflows"
|
||||
@open="openEditor"
|
||||
@create="createWorkflow"
|
||||
@duplicate="duplicateWorkflow"
|
||||
@delete="deleteWorkflowByName"
|
||||
@exit="exitDemo"
|
||||
/>
|
||||
<div v-else class="wf-shell__loading">加载中…</div>
|
||||
<div v-if="errorMessage" class="wf-shell__error" role="alert">
|
||||
<span>{{ errorMessage }}</span>
|
||||
<button type="button" class="wf-shell__error-close" aria-label="关闭" @click="errorMessage = ''">×</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import WorkflowLibraryView from './WorkflowLibraryView.vue';
|
||||
import WorkflowEditorView from './WorkflowEditorView.vue';
|
||||
import { createEmptyWorkflow, type WorkflowDef } from './workflowModel';
|
||||
import {
|
||||
deleteWorkflow,
|
||||
listWorkflows,
|
||||
loadWorkflow,
|
||||
saveWorkflow,
|
||||
type WorkflowListItem,
|
||||
} from './api';
|
||||
|
||||
/**
|
||||
* 工作流编辑器外壳:全屏覆盖层,在工作流库 / 编辑器间切换。
|
||||
* 数据通过 /api/workflows 落盘(WORKFLOW.md),列表页只持元信息,
|
||||
* 进入编辑器时按需加载完整定义。
|
||||
* 导航通过本地状态 + history.replaceState 同步地址栏,不触发整页刷新。
|
||||
*/
|
||||
|
||||
const props = defineProps<{
|
||||
routePath: string;
|
||||
}>();
|
||||
|
||||
// 路由解析:'workflows' → 库;'workflow/<name>' 或 'workflow/<name>/edit' → 编辑器
|
||||
function parseEditingName(path: string): string {
|
||||
if (!path.startsWith('workflow/')) return '';
|
||||
const rest = path.slice('workflow/'.length).replace(/\/+$/, '');
|
||||
return rest.endsWith('/edit') ? rest.slice(0, -'/edit'.length) : rest;
|
||||
}
|
||||
|
||||
const workflows = ref<WorkflowListItem[]>([]);
|
||||
const listLoaded = ref(false);
|
||||
const editingName = ref(parseEditingName(props.routePath));
|
||||
const currentWorkflow = ref<WorkflowDef | null>(null);
|
||||
const errorMessage = ref('');
|
||||
|
||||
const view = computed<'library' | 'editor'>(() => (editingName.value ? 'editor' : 'library'));
|
||||
|
||||
function showError(err: unknown, fallback: string) {
|
||||
errorMessage.value = err instanceof Error ? err.message : fallback;
|
||||
}
|
||||
|
||||
async function refreshList() {
|
||||
try {
|
||||
workflows.value = await listWorkflows();
|
||||
} catch (err) {
|
||||
showError(err, '加载工作流列表失败');
|
||||
} finally {
|
||||
listLoaded.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function openEditor(name: string) {
|
||||
try {
|
||||
currentWorkflow.value = await loadWorkflow(name);
|
||||
editingName.value = name;
|
||||
history.replaceState({}, '', `/workflow/${name}`);
|
||||
} catch (err) {
|
||||
showError(err, '加载工作流失败');
|
||||
}
|
||||
}
|
||||
|
||||
function backToLibrary() {
|
||||
editingName.value = '';
|
||||
currentWorkflow.value = null;
|
||||
history.replaceState({}, '', '/workflows');
|
||||
void refreshList();
|
||||
}
|
||||
|
||||
function uniqueName(base: string): string {
|
||||
const existing = new Set(workflows.value.map((w) => w.name));
|
||||
if (!existing.has(base)) return base;
|
||||
let n = 2;
|
||||
while (existing.has(`${base}-${n}`)) n += 1;
|
||||
return `${base}-${n}`;
|
||||
}
|
||||
|
||||
async function createWorkflow() {
|
||||
const def = createEmptyWorkflow(uniqueName(`new-workflow-${Date.now().toString(36)}`));
|
||||
try {
|
||||
await saveWorkflow(def);
|
||||
await refreshList();
|
||||
await openEditor(def.name);
|
||||
} catch (err) {
|
||||
showError(err, '新建工作流失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function duplicateWorkflow(name: string) {
|
||||
try {
|
||||
const source = await loadWorkflow(name);
|
||||
const copy: WorkflowDef = JSON.parse(JSON.stringify(source));
|
||||
copy.name = uniqueName(`${name}-copy`);
|
||||
copy.source = 'user';
|
||||
await saveWorkflow(copy);
|
||||
await refreshList();
|
||||
} catch (err) {
|
||||
showError(err, '复制工作流失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteWorkflowByName(name: string) {
|
||||
try {
|
||||
await deleteWorkflow(name);
|
||||
if (editingName.value === name) {
|
||||
backToLibrary();
|
||||
return;
|
||||
}
|
||||
await refreshList();
|
||||
} catch (err) {
|
||||
showError(err, '删除工作流失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function onSaved() {
|
||||
const wf = currentWorkflow.value;
|
||||
if (!wf) return;
|
||||
// 改名 = 另存新名 + 删除旧文件(内置示例不可删,降级为保留原件)
|
||||
if (wf.name !== editingName.value) {
|
||||
const oldName = editingName.value;
|
||||
editingName.value = wf.name;
|
||||
history.replaceState({}, '', `/workflow/${wf.name}`);
|
||||
try {
|
||||
await deleteWorkflow(oldName);
|
||||
} catch {
|
||||
// 旧文件删除失败(如内置示例)不阻断保存结果
|
||||
}
|
||||
}
|
||||
void refreshList();
|
||||
}
|
||||
|
||||
function exitDemo() {
|
||||
// 与对话体系完全隔离,直接整页跳回新对话,避免状态残留
|
||||
window.location.assign('/new');
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await refreshList();
|
||||
// 直接以 /workflow/<name> 进入时加载目标工作流;失败退回库页
|
||||
if (editingName.value) {
|
||||
try {
|
||||
currentWorkflow.value = await loadWorkflow(editingName.value);
|
||||
} catch (err) {
|
||||
showError(err, '加载工作流失败');
|
||||
backToLibrary();
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.wf-shell {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 200;
|
||||
background: var(--surface-base);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&__loading {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
&__error {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 24px;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
max-width: min(560px, calc(100% - 48px));
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--state-danger);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-raised);
|
||||
color: var(--state-danger);
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
&__error-close {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
1478
static/src/components/workflow/WorkflowEditorView.vue
Normal file
361
static/src/components/workflow/WorkflowLibraryView.vue
Normal file
@ -0,0 +1,361 @@
|
||||
<template>
|
||||
<div class="wf-library">
|
||||
<header class="wf-library__topbar">
|
||||
<button type="button" class="wf-btn wf-btn--ghost" @click="$emit('exit')">
|
||||
<span class="icon" :style="iconSrc(ICONS.arrowLeft)" aria-hidden="true"></span>
|
||||
<span>返回对话</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="wf-library__container">
|
||||
<div class="wf-library__heading">
|
||||
<div class="wf-library__title-block">
|
||||
<h1 class="wf-library__title">工作流</h1>
|
||||
<p class="wf-library__subtitle">
|
||||
把一套既定的工作方式、验证方式与结束方式存为流程,在对话中激活复用。
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" class="wf-btn wf-btn--primary" @click="$emit('create')">
|
||||
<span class="icon" :style="iconSrc(ICONS.plus)" aria-hidden="true"></span>
|
||||
<span>新建工作流</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="workflows.length" class="wf-library__list">
|
||||
<div
|
||||
v-for="wf in workflows"
|
||||
:key="wf.name"
|
||||
class="wf-row"
|
||||
@click="$emit('open', wf.name)"
|
||||
>
|
||||
<div class="wf-row__icon">
|
||||
<span class="icon icon-md" :style="iconSrc(ICONS.workflow)" aria-hidden="true"></span>
|
||||
</div>
|
||||
<div class="wf-row__main">
|
||||
<div class="wf-row__name">{{ wf.name }}</div>
|
||||
<div class="wf-row__desc">{{ wf.description || '(无描述)' }}</div>
|
||||
</div>
|
||||
<div class="wf-row__meta">
|
||||
<span>{{ wf.nodeCount }} 个节点</span>
|
||||
<span class="wf-row__meta-sep">·</span>
|
||||
<span>{{ wf.source === 'builtin' ? '内置' : '用户' }}</span>
|
||||
<span class="wf-row__meta-sep">·</span>
|
||||
<span>{{ wf.updatedAt }}</span>
|
||||
</div>
|
||||
<div class="wf-row__actions" @click.stop>
|
||||
<button
|
||||
type="button"
|
||||
class="wf-icon-btn"
|
||||
aria-label="编辑"
|
||||
title="编辑"
|
||||
@click="$emit('open', wf.name)"
|
||||
>
|
||||
<span class="icon" :style="iconSrc(ICONS.pencil)" aria-hidden="true"></span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="wf-icon-btn"
|
||||
aria-label="复制"
|
||||
title="复制"
|
||||
@click="$emit('duplicate', wf.name)"
|
||||
>
|
||||
<span class="icon" :style="iconSrc(ICONS.copy)" aria-hidden="true"></span>
|
||||
</button>
|
||||
<button
|
||||
v-if="confirmingDelete !== wf.name"
|
||||
type="button"
|
||||
class="wf-icon-btn wf-icon-btn--danger"
|
||||
aria-label="删除"
|
||||
title="删除"
|
||||
@click="confirmingDelete = wf.name"
|
||||
>
|
||||
<span class="icon" :style="iconSrc(ICONS.trash)" aria-hidden="true"></span>
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="wf-btn wf-btn--danger-confirm"
|
||||
@click="onConfirmDelete(wf.name)"
|
||||
>
|
||||
确认删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="wf-library__empty">
|
||||
<span class="icon icon-xl" :style="iconSrc(ICONS.workflow)" aria-hidden="true"></span>
|
||||
<p class="wf-library__empty-title">还没有工作流</p>
|
||||
<p class="wf-library__empty-hint">新建一个,或在对话中让 AI 帮你生成后归档。</p>
|
||||
<button type="button" class="wf-btn wf-btn--primary" @click="$emit('create')">
|
||||
<span class="icon" :style="iconSrc(ICONS.plus)" aria-hidden="true"></span>
|
||||
<span>新建工作流</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { ICONS } from '@/utils/icons';
|
||||
import type { WorkflowListItem } from './api';
|
||||
|
||||
defineProps<{
|
||||
workflows: WorkflowListItem[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'open', name: string): void;
|
||||
(event: 'create'): void;
|
||||
(event: 'duplicate', name: string): void;
|
||||
(event: 'delete', name: string): void;
|
||||
(event: 'exit'): void;
|
||||
}>();
|
||||
|
||||
const confirmingDelete = ref('');
|
||||
|
||||
function onConfirmDelete(name: string) {
|
||||
emit('delete', name);
|
||||
confirmingDelete.value = '';
|
||||
}
|
||||
|
||||
function iconSrc(url: string) {
|
||||
return { '--icon-src': `url(${url})` } as Record<string, string>;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.wf-library {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: var(--surface-base);
|
||||
overflow: hidden;
|
||||
|
||||
&__topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 48px;
|
||||
padding: 0 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__container {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
max-width: 860px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 24px 48px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
&__heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 650;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
&__subtitle {
|
||||
margin: 6px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
&__list {
|
||||
border-top: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
&__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 96px 0;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
&__empty-title {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
&__empty-hint {
|
||||
margin: 0 0 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.wf-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
height: 68px;
|
||||
padding: 0 12px;
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.12s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
&__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 9px;
|
||||
background: var(--surface-soft);
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
&__name {
|
||||
height: 20px;
|
||||
line-height: 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&__desc {
|
||||
height: 17px;
|
||||
line-height: 17px;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__meta-sep {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* demo 内共享按钮体系(编辑器页复用同款类名,但 scoped 各自定义) */
|
||||
.wf-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-raised);
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.12s ease, color 0.12s ease;
|
||||
|
||||
.icon {
|
||||
--icon-size: 14px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--hover-bg);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
&--ghost {
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
|
||||
&:hover {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
}
|
||||
|
||||
&--primary {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
|
||||
&:hover {
|
||||
background: var(--accent-hover);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
}
|
||||
|
||||
&--danger-confirm {
|
||||
border-color: var(--state-danger);
|
||||
background: var(--state-danger);
|
||||
color: var(--on-accent);
|
||||
|
||||
&:hover {
|
||||
background: var(--state-danger-strong);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.wf-icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.12s ease, color 0.12s ease;
|
||||
|
||||
.icon {
|
||||
--icon-size: 15px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--hover-bg);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
&--danger:hover {
|
||||
color: var(--state-danger);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
127
static/src/components/workflow/WorkflowReviewNode.vue
Normal file
@ -0,0 +1,127 @@
|
||||
<template>
|
||||
<div
|
||||
class="review-node"
|
||||
:class="{
|
||||
'review-node--selected': selected,
|
||||
'review-node--issue': data.hasIssue,
|
||||
}"
|
||||
>
|
||||
<!-- 菱形衬底:内联 SVG polygon(clip-path 会裁掉描边,故用矢量描边) -->
|
||||
<svg class="review-node__shape" viewBox="0 0 170 96" preserveAspectRatio="none" aria-hidden="true">
|
||||
<polygon class="review-node__polygon" points="85,1 169,48 85,95 1,48" />
|
||||
</svg>
|
||||
<!-- 左:前进入桩 -->
|
||||
<Handle id="in" type="target" :position="Position.Left" />
|
||||
<!-- 右:通过出桩(蓝线,至多 1 条) -->
|
||||
<Handle id="out-0" type="source" :position="Position.Right" class="review-node__pass-out" />
|
||||
<!-- 上/下:驳回出桩(红线,同一 rejectTo,方向按目标相对位置自动选) -->
|
||||
<Handle id="reject-out" type="source" :position="Position.Top" class="review-node__reject-out" />
|
||||
<Handle id="reject-out-b" type="source" :position="Position.Bottom" class="review-node__reject-out" />
|
||||
<div class="review-node__body">
|
||||
<span class="icon review-node__eye" :style="iconSrc(ICONS.eye)" aria-hidden="true"></span>
|
||||
<span class="review-node__name">{{ data.node.name || data.node.id }}</span>
|
||||
<span class="review-node__meta">驳回 ≤{{ data.node.maxRejects }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Handle, Position } from '@vue-flow/core';
|
||||
import { ICONS } from '@/utils/icons';
|
||||
import type { WorkflowReviewDef } from './workflowModel';
|
||||
|
||||
interface ReviewNodeData {
|
||||
node: WorkflowReviewDef;
|
||||
isEntry: boolean;
|
||||
hasIssue: boolean;
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
id: string;
|
||||
data: ReviewNodeData;
|
||||
selected?: boolean;
|
||||
}>();
|
||||
|
||||
function iconSrc(url: string) {
|
||||
return { '--icon-src': `url(${url})` } as Record<string, string>;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.review-node {
|
||||
width: 170px;
|
||||
height: 96px;
|
||||
position: relative;
|
||||
font-family: inherit;
|
||||
cursor: grab;
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
&__shape {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&__polygon {
|
||||
fill: var(--surface-raised);
|
||||
stroke: var(--border-default);
|
||||
stroke-width: 1;
|
||||
vector-effect: non-scaling-stroke;
|
||||
transition: stroke 0.15s ease;
|
||||
}
|
||||
|
||||
&--selected &__polygon {
|
||||
stroke: var(--accent);
|
||||
}
|
||||
|
||||
&--issue &__polygon {
|
||||
stroke: var(--state-danger);
|
||||
}
|
||||
|
||||
/* 通过出桩染蓝、驳回归出桩染红:颜色即语义 */
|
||||
:deep(.review-node__pass-out) {
|
||||
background: var(--state-info);
|
||||
}
|
||||
|
||||
:deep(.review-node__reject-out) {
|
||||
background: var(--state-danger);
|
||||
}
|
||||
|
||||
&__body {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
padding: 0 28px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&__eye {
|
||||
--icon-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
&__name {
|
||||
max-width: 100%;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
font-size: 10px;
|
||||
color: var(--text-tertiary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
174
static/src/components/workflow/WorkflowStageNode.vue
Normal file
@ -0,0 +1,174 @@
|
||||
<template>
|
||||
<div
|
||||
class="stage-node"
|
||||
:class="{
|
||||
'stage-node--selected': selected,
|
||||
'stage-node--issue': data.hasIssue,
|
||||
}"
|
||||
>
|
||||
<!-- 左:前进入桩(白/蓝线) -->
|
||||
<Handle id="in" type="target" :position="Position.Left" />
|
||||
<!-- 右:前退出桩(白线,至多 1 条) -->
|
||||
<Handle id="out-0" type="source" :position="Position.Right" />
|
||||
<!-- 上/下:驳回红线接收入口(语义相同,按相对位置自动选向,只进不出) -->
|
||||
<Handle id="in-top" type="target" :position="Position.Top" class="stage-node__reject-in" />
|
||||
<Handle id="in-bottom" type="target" :position="Position.Bottom" class="stage-node__reject-in" />
|
||||
<div class="stage-node__head">
|
||||
<span
|
||||
v-if="data.isEntry"
|
||||
class="icon stage-node__flag stage-node__flag--entry"
|
||||
:style="iconSrc(ICONS.flag)"
|
||||
aria-label="入口阶段"
|
||||
></span>
|
||||
<span
|
||||
v-else-if="data.isTerminal"
|
||||
class="icon stage-node__flag stage-node__flag--terminal"
|
||||
:style="iconSrc(ICONS.octagon)"
|
||||
aria-label="终点阶段"
|
||||
></span>
|
||||
<span v-else class="stage-node__dot" aria-hidden="true"></span>
|
||||
<span class="stage-node__name">{{ data.node.name || data.node.id }}</span>
|
||||
</div>
|
||||
<div class="stage-node__goal" :class="{ 'stage-node__goal--empty': !data.node.goal }">
|
||||
{{ data.node.goal || '未设置阶段目标' }}
|
||||
</div>
|
||||
<div class="stage-node__foot">
|
||||
<span class="stage-node__id">{{ data.node.id }}</span>
|
||||
<span class="stage-node__routes">{{ data.isTerminal ? '终点' : '单出线' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Handle, Position } from '@vue-flow/core';
|
||||
import { ICONS } from '@/utils/icons';
|
||||
import type { WorkflowStageDef } from './workflowModel';
|
||||
|
||||
interface StageNodeData {
|
||||
node: WorkflowStageDef;
|
||||
isEntry: boolean;
|
||||
isTerminal: boolean;
|
||||
hasIssue: boolean;
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
id: string;
|
||||
data: StageNodeData;
|
||||
selected?: boolean;
|
||||
}>();
|
||||
|
||||
function iconSrc(url: string) {
|
||||
return { '--icon-src': `url(${url})` } as Record<string, string>;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.stage-node {
|
||||
width: 210px;
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 10px;
|
||||
box-shadow: var(--shadow-soft);
|
||||
font-family: inherit;
|
||||
cursor: grab;
|
||||
transition: border-color 0.15s ease;
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
&--selected {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
&--issue {
|
||||
border-color: var(--state-danger);
|
||||
}
|
||||
|
||||
/* 驳回入口(上/下)视觉弱化:仅红线落点 */
|
||||
:deep(.stage-node__reject-in) {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 34px;
|
||||
padding: 0 10px;
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
&__flag {
|
||||
--icon-size: 13px;
|
||||
flex-shrink: 0;
|
||||
|
||||
&--entry {
|
||||
color: var(--state-success);
|
||||
}
|
||||
|
||||
&--terminal {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
&__dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
/* 与 13px 图标视觉体积对齐:圆点略小,居中补偿 */
|
||||
margin: 0 3.5px;
|
||||
}
|
||||
|
||||
&__name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&__goal {
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
padding: 0 10px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
&--empty {
|
||||
color: var(--text-tertiary);
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
|
||||
&__foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 26px;
|
||||
padding: 0 10px;
|
||||
border-top: 1px solid var(--border-default);
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
&__id {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&__routes {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
55
static/src/components/workflow/api.ts
Normal file
@ -0,0 +1,55 @@
|
||||
/**
|
||||
* 工作流 REST API 封装(对应 server/workflow_page.py 的 /api/workflows 端点)。
|
||||
*/
|
||||
import type { WorkflowDef } from './workflowModel';
|
||||
|
||||
/** 列表接口返回的轻量元信息(不含节点明细) */
|
||||
export interface WorkflowListItem {
|
||||
name: string;
|
||||
description: string;
|
||||
source: 'builtin' | 'user';
|
||||
updatedAt: string;
|
||||
nodeCount: number;
|
||||
}
|
||||
|
||||
async function parseError(resp: Response, fallback: string): Promise<Error> {
|
||||
try {
|
||||
const data = await resp.json();
|
||||
if (data && typeof data.error === 'string' && data.error) {
|
||||
return new Error(data.error);
|
||||
}
|
||||
} catch {
|
||||
// 响应体不是 JSON 时走兜底文案
|
||||
}
|
||||
return new Error(`${fallback}(HTTP ${resp.status})`);
|
||||
}
|
||||
|
||||
export async function listWorkflows(): Promise<WorkflowListItem[]> {
|
||||
const resp = await fetch('/api/workflows');
|
||||
if (!resp.ok) throw await parseError(resp, '加载工作流列表失败');
|
||||
const data = await resp.json();
|
||||
return Array.isArray(data.workflows) ? (data.workflows as WorkflowListItem[]) : [];
|
||||
}
|
||||
|
||||
export async function loadWorkflow(name: string): Promise<WorkflowDef> {
|
||||
const resp = await fetch(`/api/workflows/${encodeURIComponent(name)}`);
|
||||
if (!resp.ok) throw await parseError(resp, '加载工作流失败');
|
||||
const data = await resp.json();
|
||||
return data.workflow as WorkflowDef;
|
||||
}
|
||||
|
||||
export async function saveWorkflow(wf: WorkflowDef): Promise<void> {
|
||||
const resp = await fetch(`/api/workflows/${encodeURIComponent(wf.name)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workflow: wf }),
|
||||
});
|
||||
if (!resp.ok) throw await parseError(resp, '保存工作流失败');
|
||||
}
|
||||
|
||||
export async function deleteWorkflow(name: string): Promise<void> {
|
||||
const resp = await fetch(`/api/workflows/${encodeURIComponent(name)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!resp.ok) throw await parseError(resp, '删除工作流失败');
|
||||
}
|
||||
692
static/src/components/workflow/workflowModel.ts
Normal file
@ -0,0 +1,692 @@
|
||||
/**
|
||||
* 工作流数据模型。
|
||||
*
|
||||
* 五种节点(串行边界:无并行语义):
|
||||
* - start(胶囊):入口。右 1 出、无入桩;恰好 1 个(校验强制)。可增删拖动。
|
||||
* - end(胶囊):终点。左 n 入、无出桩;至少 1 个。可增删拖动。
|
||||
* - stage(矩形):AI 执行阶段。左 1 入、右 1 出——严格线性,next 为单值;
|
||||
* 想分叉必须显式经过分支节点。
|
||||
* - review(菱形):审核智能体把关。左 1 入、右 1 出(通过·蓝线)、
|
||||
* 上/下各 1 个驳回出口(同一 rejectTo,方向按目标相对位置自动选)。
|
||||
* 驳回路由必须显式连接,否则校验不通过。
|
||||
* - branch(虚线矩形):分线/并线器。左 n 入、右 n 出(next 路由数组,
|
||||
* 每条带 condition 条件描述,多条出线时必填 = AI 决策依据)。
|
||||
*
|
||||
* 上下连接点(stage/branch/review 标配):上是驳回的入、下也是驳回的入——
|
||||
* 上下功能相同,两个方向只是美观(按源/目标相对位置自动选向)。
|
||||
*
|
||||
* 入侧约定:一个入桩允许收多条线;出侧严格限量。
|
||||
*
|
||||
* 所有路径显式化:没有隐式终点,阶段/审核的出线必须连到后续节点或结束
|
||||
* 节点(next 为 null 校验不通过);开始/结束是真实节点,无任何派生边。
|
||||
*
|
||||
* 画布拓扑(nodes + next/rejectTo 路由)是结构约束,节点内行为
|
||||
* (goal/instructions/prompt/condition)由自然语言描述。position 仅为画布坐标。
|
||||
* 桩位分配是纯渲染层规则(workflowToFlow 时按对端方位分配 handle id),
|
||||
* 数据模型不存桩位——连线语义由拉出的连桩类型决定。
|
||||
*/
|
||||
|
||||
import type { Edge, Node } from '@vue-flow/core';
|
||||
import { MarkerType } from '@vue-flow/core';
|
||||
import dagre from '@dagrejs/dagre';
|
||||
|
||||
// ---------------------------------------------------------------- 类型
|
||||
|
||||
export interface WorkflowStartDef {
|
||||
kind: 'start';
|
||||
id: string;
|
||||
name: string;
|
||||
/** 右 1 出(白线);null 校验不通过(开始节点必须连出) */
|
||||
next: string | null;
|
||||
position?: { x: number; y: number };
|
||||
}
|
||||
|
||||
export interface WorkflowEndDef {
|
||||
kind: 'end';
|
||||
id: string;
|
||||
name: string;
|
||||
/** 画布坐标(仅视觉) */
|
||||
position?: { x: number; y: number };
|
||||
}
|
||||
|
||||
export interface WorkflowStageDef {
|
||||
kind: 'stage';
|
||||
id: string;
|
||||
name: string;
|
||||
/** 本阶段要达成什么(注入提示词的核心) */
|
||||
goal: string;
|
||||
/** 本阶段的具体工作方式(自然语言) */
|
||||
instructions: string;
|
||||
/** 唯一后续节点 id(白线,右侧出);null 校验不通过(必须显式连到后续或结束) */
|
||||
next: string | null;
|
||||
position?: { x: number; y: number };
|
||||
}
|
||||
|
||||
export interface WorkflowReviewDef {
|
||||
kind: 'review';
|
||||
id: string;
|
||||
name: string;
|
||||
/** 审核关注点(注入审核智能体提示词) */
|
||||
prompt: string;
|
||||
/** 通过去向(蓝线);null 校验不通过(必须显式连到后续或结束) */
|
||||
next: string | null;
|
||||
/** 驳回去向(红线,上/下出、目标上/下进);null 校验不通过 */
|
||||
rejectTo: string | null;
|
||||
/** 连续驳回上限,超限整个工作流失败终止 */
|
||||
maxRejects: number;
|
||||
position?: { x: number; y: number };
|
||||
}
|
||||
|
||||
/** 分支节点的一条出线:目标 + 走这条路的条件(自然语言,AI 决策依据) */
|
||||
export interface WorkflowBranchRoute {
|
||||
target: string;
|
||||
/** 走这条出线的条件描述;多条出线时必填(校验提示),单出线(并线器)可空 */
|
||||
condition: string;
|
||||
}
|
||||
|
||||
export interface WorkflowBranchDef {
|
||||
kind: 'branch';
|
||||
id: string;
|
||||
name: string;
|
||||
/** 右侧出候选(白线,n 条 = AI 决策分支;1 条 = 并线器) */
|
||||
next: WorkflowBranchRoute[];
|
||||
position?: { x: number; y: number };
|
||||
}
|
||||
|
||||
export type WorkflowNodeDef =
|
||||
| WorkflowStartDef
|
||||
| WorkflowEndDef
|
||||
| WorkflowStageDef
|
||||
| WorkflowReviewDef
|
||||
| WorkflowBranchDef;
|
||||
|
||||
/** 有出线的节点(start/stage/review/branch) */
|
||||
export type WorkflowSourceNodeDef = Exclude<WorkflowNodeDef, WorkflowEndDef>;
|
||||
|
||||
export interface WorkflowDef {
|
||||
name: string;
|
||||
description: string;
|
||||
/** 审核智能体取证能力:active = 可调用只读 run_command */
|
||||
reviewMode: 'readonly' | 'active';
|
||||
/** 单节点最大轮数,防死循环 */
|
||||
maxStageRounds: number;
|
||||
/** 整体结束方式(自然语言) */
|
||||
endConditions: string;
|
||||
/** 全局正文:工作方式 / 验证方式 / 结束方式 */
|
||||
body: string;
|
||||
source: 'builtin' | 'user';
|
||||
updatedAt: string;
|
||||
nodes: WorkflowNodeDef[];
|
||||
}
|
||||
|
||||
export interface WorkflowIssue {
|
||||
level: 'error' | 'warning';
|
||||
message: string;
|
||||
/** 关联节点 id(用于画布标红) */
|
||||
nodeId?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 新建模板
|
||||
|
||||
/** 空白工作流:默认一个开始 + 一个结束并直连,用户在中间插入节点 */
|
||||
export function createEmptyWorkflow(name: string): WorkflowDef {
|
||||
return {
|
||||
name,
|
||||
description: '',
|
||||
reviewMode: 'active',
|
||||
maxStageRounds: 20,
|
||||
endConditions: '',
|
||||
body: '## 工作方式\n\n## 验证方式\n\n## 结束方式\n',
|
||||
source: 'user',
|
||||
updatedAt: '',
|
||||
nodes: [
|
||||
{ kind: 'start', id: 'start-1', name: '开始', next: 'end-1', position: { x: 60, y: 220 } },
|
||||
{ kind: 'end', id: 'end-1', name: '结束', position: { x: 480, y: 220 } },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 节点工具
|
||||
|
||||
export function findNode(def: WorkflowDef, id: string): WorkflowNodeDef | undefined {
|
||||
return def.nodes.find((n) => n.id === id);
|
||||
}
|
||||
|
||||
export function nodeExists(def: WorkflowDef, id: string): boolean {
|
||||
return def.nodes.some((n) => n.id === id);
|
||||
}
|
||||
|
||||
/** 节点垂直方位(桩位排序用) */
|
||||
function nodeY(def: WorkflowDef, id: string): number {
|
||||
return findNode(def, id)?.position?.y ?? Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- nodes → flow 转换
|
||||
|
||||
/** 节点近似尺寸(与节点组件一致;仅用于布局间距计算) */
|
||||
const NODE_WIDTH = 210;
|
||||
const NODE_HEIGHT = 118;
|
||||
// 菱形审核节点更小
|
||||
const REVIEW_WIDTH = 170;
|
||||
const REVIEW_HEIGHT = 96;
|
||||
// 分支节点窄;高度随桩数自适应(布局时估算)
|
||||
const BRANCH_WIDTH = 150;
|
||||
const BRANCH_HEIGHT_MIN = 80;
|
||||
// 开始/结束胶囊
|
||||
const BOUNDARY_WIDTH = 110;
|
||||
const BOUNDARY_HEIGHT = 40;
|
||||
|
||||
const STROKE_FORWARD = 'var(--text-secondary)';
|
||||
const STROKE_PASS = 'var(--state-info)';
|
||||
const STROKE_REJECT = 'var(--state-danger)';
|
||||
|
||||
/**
|
||||
* 边语义(Visio 式):
|
||||
* - 白线:阶段的下一步 / 分支的出线 / 开始的入口线(右出左进)
|
||||
* - 蓝线:审核通过(菱形右出左进)
|
||||
* - 红线:审核驳回(菱形上/下出、目标上/下进;目标无上下入桩时回落左入)
|
||||
* 不指定 type:Vue Flow 默认 default 即贝塞尔曲线,禁止 smoothstep 直角弯。
|
||||
*
|
||||
* 桩位分配:分支节点第 i 条右出线占出桩 `out-${i}`,第 j 条入线占入桩
|
||||
* `in-${j}`——按对端节点垂直方位排序分配(上方的对端占靠上的桩,线不
|
||||
* 交叉、方位一致);阶段/审核/开始出入桩固定;结束左入桩同分支规则。
|
||||
*/
|
||||
export function workflowToFlow(def: WorkflowDef, issueNodeIds: Set<string>): { nodes: Node[]; edges: Edge[] } {
|
||||
// 入边收集(target -> {source, isReject} 列表),用于分支/结束节点入桩分配
|
||||
interface IncomingEdge {
|
||||
source: string;
|
||||
isReject: boolean;
|
||||
}
|
||||
const incomingOf = new Map<string, IncomingEdge[]>();
|
||||
const recordIncoming = (target: string, source: string, isReject = false) => {
|
||||
if (!incomingOf.has(target)) incomingOf.set(target, []);
|
||||
(incomingOf.get(target) as IncomingEdge[]).push({ source, isReject });
|
||||
};
|
||||
for (const n of def.nodes) {
|
||||
if (n.kind === 'end') continue;
|
||||
if (n.kind === 'branch') {
|
||||
for (const r of n.next) recordIncoming(r.target, n.id);
|
||||
} else {
|
||||
if (n.next) recordIncoming(n.next, n.id);
|
||||
if (n.kind === 'review' && n.rejectTo) recordIncoming(n.rejectTo, n.id, true);
|
||||
}
|
||||
}
|
||||
|
||||
const yOf = (id: string): number => nodeY(def, id);
|
||||
|
||||
// 左入桩只分配给走左入的前进线(白/蓝);驳回红线进 stage/branch 时走
|
||||
// 上/下入桩不占左桩,仅当目标无上下入桩(start/end/review)时回落占左桩。
|
||||
const leftIncomingOf = (target: string): string[] => {
|
||||
const targetNode = findNode(def, target);
|
||||
const hasVerticalIn = targetNode?.kind === 'stage' || targetNode?.kind === 'branch';
|
||||
const list = (incomingOf.get(target) ?? []).filter((e) => !e.isReject || !hasVerticalIn);
|
||||
return list.map((e) => e.source).sort((a, b) => yOf(a) - yOf(b));
|
||||
};
|
||||
|
||||
const inIndexOf = (target: string, source: string): number => {
|
||||
const list = leftIncomingOf(target);
|
||||
const idx = list.indexOf(source);
|
||||
return idx >= 0 ? idx : 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* 目标入桩:branch/end 按入边方位序分配独立桩;stage/review 固定单桩 'in'。
|
||||
* 驳回红线进 stage/branch 按相对位置选向(上进或下进,纯美观)。
|
||||
*/
|
||||
const targetHandleOf = (target: string, source: string, isRejectEdge: boolean, rejectFromAbove = true): string => {
|
||||
const targetNode = findNode(def, target);
|
||||
if (isRejectEdge && targetNode && (targetNode.kind === 'stage' || targetNode.kind === 'branch')) {
|
||||
return rejectFromAbove ? 'in-top' : 'in-bottom';
|
||||
}
|
||||
if (targetNode?.kind === 'branch' || targetNode?.kind === 'end') {
|
||||
return `in-${inIndexOf(target, source)}`;
|
||||
}
|
||||
return 'in';
|
||||
};
|
||||
|
||||
/** 驳回红线方向:目标在审核上方 → 上出上进;下方 → 下出下进(美观自动选向) */
|
||||
const rejectGoesUp = (source: WorkflowNodeDef, targetId: string): boolean => {
|
||||
const targetNode = findNode(def, targetId);
|
||||
if (!source.position || !targetNode?.position) return true;
|
||||
return targetNode.position.y <= source.position.y;
|
||||
};
|
||||
|
||||
const nodes: Node[] = def.nodes.map((n) => {
|
||||
const isBoundary = n.kind === 'start' || n.kind === 'end';
|
||||
return {
|
||||
id: n.id,
|
||||
type: isBoundary ? 'boundary' : n.kind,
|
||||
position: n.position ?? { x: 0, y: 0 },
|
||||
data: {
|
||||
node: n,
|
||||
hasIssue: issueNodeIds.has(n.id),
|
||||
// 左入桩已占用数量(只含前进线,不含上/下入的红线);分支/结束组件会加 1 个常驻空桩渲染
|
||||
inCount: leftIncomingOf(n.id).length,
|
||||
outCount: n.kind === 'branch' ? n.next.length : 1,
|
||||
},
|
||||
};
|
||||
});
|
||||
const edges: Edge[] = [];
|
||||
|
||||
for (const n of def.nodes) {
|
||||
if (n.kind === 'end') continue;
|
||||
if (n.kind === 'branch') {
|
||||
const sortedRoutes = [...n.next].sort((a, b) => yOf(a.target) - yOf(b.target));
|
||||
sortedRoutes.forEach((route, i) => {
|
||||
// 条件文字作为边标签,超长截断
|
||||
const label = route.condition.length > 14 ? `${route.condition.slice(0, 14)}…` : route.condition;
|
||||
edges.push({
|
||||
id: `${n.id}->${route.target}`,
|
||||
source: n.id,
|
||||
target: route.target,
|
||||
sourceHandle: `out-${i}`,
|
||||
targetHandle: targetHandleOf(route.target, n.id, false),
|
||||
...(label ? { label } : {}),
|
||||
markerEnd: { type: MarkerType.ArrowClosed, color: STROKE_FORWARD },
|
||||
});
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (n.next) {
|
||||
// start/stage 白线、review 蓝线
|
||||
edges.push({
|
||||
id: `${n.id}->${n.next}`,
|
||||
source: n.id,
|
||||
target: n.next,
|
||||
sourceHandle: 'out-0',
|
||||
targetHandle: targetHandleOf(n.next, n.id, false),
|
||||
...(n.kind === 'review' ? { class: 'wf-edge-approved' } : {}),
|
||||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
color: n.kind === 'review' ? STROKE_PASS : STROKE_FORWARD,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (n.kind === 'review' && n.rejectTo) {
|
||||
const up = rejectGoesUp(n, n.rejectTo);
|
||||
edges.push({
|
||||
id: `reject:${n.id}->${n.rejectTo}`,
|
||||
source: n.id,
|
||||
target: n.rejectTo,
|
||||
sourceHandle: up ? 'reject-out' : 'reject-out-b',
|
||||
targetHandle: targetHandleOf(n.rejectTo, n.id, true, up),
|
||||
class: 'wf-edge-back',
|
||||
markerEnd: { type: MarkerType.ArrowClosed, color: STROKE_REJECT },
|
||||
});
|
||||
}
|
||||
}
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 结构操作
|
||||
|
||||
let idSeq = 0;
|
||||
|
||||
function genNodeId(def: WorkflowDef, prefix: string): string {
|
||||
idSeq += 1;
|
||||
let n = def.nodes.length + idSeq;
|
||||
let id = `${prefix}-${n}`;
|
||||
while (nodeExists(def, id)) {
|
||||
n += 1;
|
||||
id = `${prefix}-${n}`;
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
export function addStage(def: WorkflowDef, position: { x: number; y: number }): WorkflowStageDef {
|
||||
const stage: WorkflowStageDef = {
|
||||
kind: 'stage',
|
||||
id: genNodeId(def, 'stage'),
|
||||
name: `阶段 ${def.nodes.filter((x) => x.kind === 'stage').length + 1}`,
|
||||
goal: '',
|
||||
instructions: '',
|
||||
next: null,
|
||||
position,
|
||||
};
|
||||
def.nodes.push(stage);
|
||||
return stage;
|
||||
}
|
||||
|
||||
export function addReview(def: WorkflowDef, position: { x: number; y: number }): WorkflowReviewDef {
|
||||
const review: WorkflowReviewDef = {
|
||||
kind: 'review',
|
||||
id: genNodeId(def, 'review'),
|
||||
name: `审核 ${def.nodes.filter((x) => x.kind === 'review').length + 1}`,
|
||||
prompt: '',
|
||||
next: null,
|
||||
rejectTo: null,
|
||||
maxRejects: 3,
|
||||
position,
|
||||
};
|
||||
def.nodes.push(review);
|
||||
return review;
|
||||
}
|
||||
|
||||
export function addBranch(def: WorkflowDef, position: { x: number; y: number }): WorkflowBranchDef {
|
||||
const branch: WorkflowBranchDef = {
|
||||
kind: 'branch',
|
||||
id: genNodeId(def, 'branch'),
|
||||
name: `分支 ${def.nodes.filter((x) => x.kind === 'branch').length + 1}`,
|
||||
next: [],
|
||||
position,
|
||||
};
|
||||
def.nodes.push(branch);
|
||||
return branch;
|
||||
}
|
||||
|
||||
export function addStart(def: WorkflowDef, position: { x: number; y: number }): WorkflowStartDef {
|
||||
const start: WorkflowStartDef = {
|
||||
kind: 'start',
|
||||
id: genNodeId(def, 'start'),
|
||||
name: '开始',
|
||||
next: null,
|
||||
position,
|
||||
};
|
||||
def.nodes.push(start);
|
||||
return start;
|
||||
}
|
||||
|
||||
export function addEnd(def: WorkflowDef, position: { x: number; y: number }): WorkflowEndDef {
|
||||
const end: WorkflowEndDef = {
|
||||
kind: 'end',
|
||||
id: genNodeId(def, 'end'),
|
||||
name: '结束',
|
||||
position,
|
||||
};
|
||||
def.nodes.push(end);
|
||||
return end;
|
||||
}
|
||||
|
||||
export function removeNode(def: WorkflowDef, nodeId: string): void {
|
||||
def.nodes = def.nodes.filter((n) => n.id !== nodeId);
|
||||
for (const n of def.nodes) {
|
||||
if (n.kind === 'end') continue;
|
||||
if (n.kind === 'branch') {
|
||||
n.next = n.next.filter((r) => r.target !== nodeId);
|
||||
} else {
|
||||
if (n.next === nodeId) n.next = null;
|
||||
if (n.kind === 'review' && n.rejectTo === nodeId) n.rejectTo = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 前进/通过/开始/分支出线连线。返回错误信息,null 为成功。
|
||||
* - start/stage/review 源:单值替换语义(已有出线时被替换,调用方可先读旧值提示)
|
||||
* - branch 源:累加语义(重复返回错误)
|
||||
*/
|
||||
export function connectNext(def: WorkflowDef, sourceId: string, targetId: string): string | null {
|
||||
if (sourceId === targetId) return '不能连接到自身';
|
||||
const source = findNode(def, sourceId);
|
||||
if (!source) return '源节点不存在';
|
||||
if (source.kind === 'end') return '结束节点没有出线';
|
||||
if (!nodeExists(def, targetId)) return '目标节点不存在';
|
||||
if (findNode(def, targetId)?.kind === 'start') return '不能连到开始节点';
|
||||
if (source.kind === 'branch') {
|
||||
if (source.next.some((r) => r.target === targetId)) return '该路由已存在';
|
||||
source.next.push({ target: targetId, condition: '' });
|
||||
return null;
|
||||
}
|
||||
if (source.kind === 'review' && source.rejectTo === targetId) {
|
||||
return '该目标已是驳回路由(红线),先断开驳回路由';
|
||||
}
|
||||
source.next = targetId;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 分支已占用出桩拖新线 = 修改该桩的流向(替换该桩当前目标)。
|
||||
* slotIndex 是桩位序号(按对端方位排序,与画布渲染的 out-i 一致)。
|
||||
* 返回错误信息,null 为成功。
|
||||
*/
|
||||
export function replaceBranchTarget(def: WorkflowDef, sourceId: string, slotIndex: number, newTarget: string): string | null {
|
||||
const source = findNode(def, sourceId);
|
||||
if (!source) return '源节点不存在';
|
||||
if (source.kind !== 'branch') return '只有分支节点有多出桩';
|
||||
if (!nodeExists(def, newTarget)) return '目标节点不存在';
|
||||
if (findNode(def, newTarget)?.kind === 'start') return '不能连到开始节点';
|
||||
const sorted = [...source.next].sort((a, b) => nodeY(def, a.target) - nodeY(def, b.target));
|
||||
const oldRoute = sorted[slotIndex];
|
||||
if (oldRoute === undefined) return '该连接点是空桩,拖线即新增出线';
|
||||
if (oldRoute.target === newTarget) return null; // 幂等
|
||||
if (source.next.some((r) => r.target === newTarget)) return '该目标已在出线中';
|
||||
// 保序替换(条件描述保留):数组序不变,桩位在下次渲染时按新目标方位重排
|
||||
const route = source.next.find((r) => r.target === oldRoute.target);
|
||||
if (route) route.target = newTarget;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 驳回连线(菱形上/下连桩拉出,语义相同仅方向不同)。单值替换语义。 */
|
||||
export function connectRejectTo(def: WorkflowDef, sourceId: string, targetId: string): string | null {
|
||||
if (sourceId === targetId) return '不能驳回至自身';
|
||||
const source = findNode(def, sourceId);
|
||||
if (!source) return '源节点不存在';
|
||||
if (source.kind !== 'review') return '只有审核节点(菱形)有驳回路由';
|
||||
if (!nodeExists(def, targetId)) return '目标节点不存在';
|
||||
if (findNode(def, targetId)?.kind === 'start') return '不能驳回至开始节点';
|
||||
if (source.next === targetId) return '该目标已是通过路由(蓝线)';
|
||||
source.rejectTo = targetId;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function disconnectNext(def: WorkflowDef, sourceId: string, targetId: string): void {
|
||||
const source = findNode(def, sourceId);
|
||||
if (!source || source.kind === 'end') return;
|
||||
if (source.kind === 'branch') {
|
||||
source.next = source.next.filter((r) => r.target !== targetId);
|
||||
} else if (source.next === targetId) {
|
||||
source.next = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function disconnectRejectTo(def: WorkflowDef, sourceId: string): void {
|
||||
const source = findNode(def, sourceId);
|
||||
if (!source || source.kind !== 'review') return;
|
||||
source.rejectTo = null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 校验
|
||||
|
||||
/** 校验消息用的节点显示名 */
|
||||
function nodeNameOfLocal(def: WorkflowDef, id: string): string {
|
||||
return findNode(def, id)?.name ?? id;
|
||||
}
|
||||
|
||||
export function validateWorkflow(def: WorkflowDef): WorkflowIssue[] {
|
||||
const issues: WorkflowIssue[] = [];
|
||||
if (!def.name.trim()) {
|
||||
issues.push({ level: 'error', message: '工作流缺少 name' });
|
||||
}
|
||||
if (!def.description.trim()) {
|
||||
issues.push({ level: 'warning', message: '工作流缺少 description(激活选择时靠它辨认)' });
|
||||
}
|
||||
if (def.nodes.length === 0) {
|
||||
issues.push({ level: 'error', message: '至少需要一个开始节点和一个结束节点' });
|
||||
return issues;
|
||||
}
|
||||
|
||||
const targetValid = (target: string) => nodeExists(def, target);
|
||||
const isStart = (id: string) => findNode(def, id)?.kind === 'start';
|
||||
|
||||
const starts = def.nodes.filter((n) => n.kind === 'start');
|
||||
const ends = def.nodes.filter((n) => n.kind === 'end');
|
||||
if (starts.length === 0) {
|
||||
issues.push({ level: 'error', message: '缺少开始节点(顶栏「添加节点 → 开始」)' });
|
||||
} else if (starts.length > 1) {
|
||||
issues.push({ level: 'error', message: `开始节点只能有一个(当前 ${starts.length} 个)`, nodeId: starts[1].id });
|
||||
}
|
||||
if (ends.length === 0) {
|
||||
issues.push({ level: 'error', message: '缺少结束节点(顶栏「添加节点 → 结束」)' });
|
||||
}
|
||||
|
||||
const ids = new Set<string>();
|
||||
for (const n of def.nodes) {
|
||||
if (ids.has(n.id)) {
|
||||
issues.push({ level: 'error', message: `节点 id 重复:${n.id}`, nodeId: n.id });
|
||||
}
|
||||
ids.add(n.id);
|
||||
if (!n.name.trim()) {
|
||||
issues.push({ level: 'error', message: `节点 ${n.id} 缺少名称`, nodeId: n.id });
|
||||
}
|
||||
switch (n.kind) {
|
||||
case 'start':
|
||||
if (n.next === null) {
|
||||
issues.push({ level: 'error', message: '开始节点未连接(从右侧连桩拖线到首个节点)', nodeId: n.id });
|
||||
} else if (!targetValid(n.next)) {
|
||||
issues.push({ level: 'error', message: `开始节点指向不存在的节点:${n.next}`, nodeId: n.id });
|
||||
}
|
||||
break;
|
||||
case 'end':
|
||||
break;
|
||||
case 'stage':
|
||||
if (!n.goal.trim()) {
|
||||
issues.push({ level: 'warning', message: `阶段「${n.name || n.id}」缺少目标描述`, nodeId: n.id });
|
||||
}
|
||||
if (n.next === null) {
|
||||
issues.push({ level: 'error', message: `阶段「${n.name || n.id}」未连接后续(连到下一节点或结束节点)`, nodeId: n.id });
|
||||
} else if (!targetValid(n.next)) {
|
||||
issues.push({ level: 'error', message: `阶段「${n.name}」指向不存在的节点:${n.next}`, nodeId: n.id });
|
||||
} else if (isStart(n.next)) {
|
||||
issues.push({ level: 'error', message: `阶段「${n.name}」不能连到开始节点`, nodeId: n.id });
|
||||
}
|
||||
break;
|
||||
case 'review':
|
||||
if (!n.prompt.trim()) {
|
||||
issues.push({ level: 'warning', message: `审核「${n.name || n.id}」缺少审核关注点`, nodeId: n.id });
|
||||
}
|
||||
if (n.next === null) {
|
||||
issues.push({ level: 'error', message: `审核「${n.name || n.id}」未连接通过路由(蓝线)`, nodeId: n.id });
|
||||
} else if (!targetValid(n.next)) {
|
||||
issues.push({ level: 'error', message: `审核「${n.name}」指向不存在的节点:${n.next}`, nodeId: n.id });
|
||||
}
|
||||
if (n.rejectTo === null) {
|
||||
issues.push({ level: 'error', message: `审核「${n.name || n.id}」缺少驳回路由(红线必须连接)`, nodeId: n.id });
|
||||
} else if (!targetValid(n.rejectTo)) {
|
||||
issues.push({ level: 'error', message: `审核「${n.name}」驳回至不存在的节点:${n.rejectTo}`, nodeId: n.id });
|
||||
} else if (isStart(n.rejectTo)) {
|
||||
issues.push({ level: 'error', message: `审核「${n.name}」不能驳回至开始节点`, nodeId: n.id });
|
||||
}
|
||||
if (!Number.isFinite(n.maxRejects) || n.maxRejects < 1) {
|
||||
issues.push({ level: 'error', message: `审核「${n.name || n.id}」驳回上限必须 ≥ 1`, nodeId: n.id });
|
||||
}
|
||||
break;
|
||||
case 'branch':
|
||||
if (n.next.length === 0) {
|
||||
issues.push({ level: 'warning', message: `分支「${n.name || n.id}」没有出线(死端)`, nodeId: n.id });
|
||||
}
|
||||
for (const route of n.next) {
|
||||
if (!targetValid(route.target)) {
|
||||
issues.push({ level: 'error', message: `分支「${n.name}」指向不存在的节点:${route.target}`, nodeId: n.id });
|
||||
} else if (isStart(route.target)) {
|
||||
issues.push({ level: 'error', message: `分支「${n.name}」不能连到开始节点`, nodeId: n.id });
|
||||
}
|
||||
}
|
||||
// 多条出线 = AI 决策点,每条都必须写条件,否则 AI 无法选择
|
||||
if (n.next.length >= 2) {
|
||||
for (const route of n.next) {
|
||||
if (!route.condition.trim()) {
|
||||
issues.push({
|
||||
level: 'warning',
|
||||
message: `分支「${n.name}」到「${nodeNameOfLocal(def, route.target)}」的出线缺少条件描述`,
|
||||
nodeId: n.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 从开始节点出发的可达性(驳回路由也算可达路径)
|
||||
const start = starts[0];
|
||||
if (start && start.kind === 'start' && start.next && targetValid(start.next)) {
|
||||
const reachable = new Set<string>([start.id]);
|
||||
const queue = [start.next];
|
||||
while (queue.length) {
|
||||
const cur = queue.shift() as string;
|
||||
if (reachable.has(cur)) continue;
|
||||
reachable.add(cur);
|
||||
const curNode = findNode(def, cur);
|
||||
if (!curNode || curNode.kind === 'end') continue;
|
||||
const outs: string[] =
|
||||
curNode.kind === 'branch'
|
||||
? curNode.next.map((r) => r.target)
|
||||
: [curNode.next, curNode.kind === 'review' ? curNode.rejectTo : null].filter((t): t is string => Boolean(t));
|
||||
for (const t of outs) {
|
||||
if (!reachable.has(t)) queue.push(t);
|
||||
}
|
||||
}
|
||||
for (const n of def.nodes) {
|
||||
if (!reachable.has(n.id)) {
|
||||
issues.push({ level: 'warning', message: `节点「${n.name}」从开始节点不可达`, nodeId: n.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 自动排版(dagre 有向图布局,从左到右)
|
||||
|
||||
/**
|
||||
* dagre 负责分层、层内交叉最小化与回边路由通道。审核节点(菱形,带驳回
|
||||
* 路由)布局后下移一行:分层算法对循环只能把回边反转后直线分层,下移让
|
||||
* 驳回回路与主线上下并列,红线上出上进不穿主线。
|
||||
*/
|
||||
export function autoLayout(def: WorkflowDef): void {
|
||||
if (def.nodes.length === 0) return;
|
||||
// 分支/结束节点高度随桩数:先统计入边数(只计前进线)
|
||||
const incomingCount = new Map<string, number>();
|
||||
for (const n of def.nodes) {
|
||||
if (n.kind === 'end') continue;
|
||||
const outs: string[] =
|
||||
n.kind === 'branch' ? n.next.map((r) => r.target) : n.next ? [n.next] : [];
|
||||
for (const t of outs) {
|
||||
if (nodeExists(def, t)) incomingCount.set(t, (incomingCount.get(t) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
const sizeOf = (n: WorkflowNodeDef): { width: number; height: number } => {
|
||||
switch (n.kind) {
|
||||
case 'start':
|
||||
case 'end':
|
||||
return { width: BOUNDARY_WIDTH, height: BOUNDARY_HEIGHT };
|
||||
case 'review':
|
||||
return { width: REVIEW_WIDTH, height: REVIEW_HEIGHT };
|
||||
case 'branch': {
|
||||
const rows = Math.max(incomingCount.get(n.id) ?? 1, n.next.length, 2);
|
||||
return { width: BRANCH_WIDTH, height: Math.max(BRANCH_HEIGHT_MIN, rows * 24 + 40) };
|
||||
}
|
||||
default:
|
||||
return { width: NODE_WIDTH, height: NODE_HEIGHT };
|
||||
}
|
||||
};
|
||||
|
||||
const g = new dagre.graphlib.Graph();
|
||||
g.setGraph({ rankdir: 'LR', nodesep: 70, ranksep: 130, marginx: 60, marginy: 60 });
|
||||
g.setDefaultEdgeLabel(() => ({}));
|
||||
for (const n of def.nodes) {
|
||||
g.setNode(n.id, sizeOf(n));
|
||||
}
|
||||
for (const n of def.nodes) {
|
||||
if (n.kind === 'end') continue;
|
||||
const outs: string[] =
|
||||
n.kind === 'branch'
|
||||
? n.next.map((r) => r.target)
|
||||
: [n.next, n.kind === 'review' ? n.rejectTo : null].filter((t): t is string => Boolean(t));
|
||||
for (const t of outs) {
|
||||
if (nodeExists(def, t)) g.setEdge(n.id, t);
|
||||
}
|
||||
}
|
||||
dagre.layout(g);
|
||||
for (const n of def.nodes) {
|
||||
const info = g.node(n.id);
|
||||
const { width, height } = sizeOf(n);
|
||||
// dagre 输出节点中心坐标,转为 Vue Flow 左上角坐标
|
||||
n.position = { x: Math.round(info.x - width / 2), y: Math.round(info.y - height / 2) };
|
||||
}
|
||||
// 审核节点下移一行,驳回红线(上出上进)与主线上下并列
|
||||
for (const n of def.nodes) {
|
||||
if (n.kind === 'review' && n.rejectTo !== null && n.position) {
|
||||
n.position = { x: n.position.x, y: n.position.y + 170 };
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -788,13 +788,19 @@ export async function initializeLegacySocket(ctx: any) {
|
||||
|
||||
// 初始化期间不修改 currentConversationId,避免与 bootstrapRoute 冲突
|
||||
if (ctx.initialRouteResolved) {
|
||||
// 独立全屏路由(工作流编辑器等)不归对话体系:不接管 id、不清消息、不改 URL,
|
||||
// 仅同步下方列表数据(标题更新/置顶/列表刷新)。
|
||||
const onIndependentRoute =
|
||||
typeof ctx.isConversationIndependentRoute === 'function' &&
|
||||
ctx.isConversationIndependentRoute();
|
||||
// 用户停留在显式新建对话页(/new、/multiagent/new)时,不被其他对话的
|
||||
// 广播事件(如运行中任务的标题生成)拽走视图,仅同步列表数据。
|
||||
const stayOnNewRoute =
|
||||
!data.cleared &&
|
||||
!onIndependentRoute &&
|
||||
typeof ctx.isExplicitNewConversationRoute === 'function' &&
|
||||
ctx.isExplicitNewConversationRoute();
|
||||
if (!stayOnNewRoute) {
|
||||
if (!stayOnNewRoute && !onIndependentRoute) {
|
||||
ctx.currentConversationId = data.conversation_id;
|
||||
ctx.currentConversationTitle = data.title || '';
|
||||
}
|
||||
@ -809,8 +815,8 @@ export async function initializeLegacySocket(ctx: any) {
|
||||
|
||||
ctx.promoteConversationToTop(data.conversation_id);
|
||||
|
||||
if (data.cleared) {
|
||||
// 对话被清空
|
||||
if (data.cleared && !onIndependentRoute) {
|
||||
// 对话被清空(独立全屏路由下不动对话状态与 URL)
|
||||
ctx.logMessageState?.('socket:conversation_changed-clearing', { event: data });
|
||||
ctx.messages = [];
|
||||
ctx.logMessageState?.('socket:conversation_changed-cleared', { event: data });
|
||||
@ -834,8 +840,12 @@ export async function initializeLegacySocket(ctx: any) {
|
||||
}
|
||||
const convId = data.conversation_id;
|
||||
|
||||
// 初始化期间不修改 currentConversationId,避免与 bootstrapRoute 冲突
|
||||
if (ctx.initialRouteResolved) {
|
||||
// 初始化期间不修改 currentConversationId,避免与 bootstrapRoute 冲突;
|
||||
// 独立全屏路由(工作流编辑器等)下不恢复对话、不改写 URL
|
||||
const onIndependentRoute =
|
||||
typeof ctx.isConversationIndependentRoute === 'function' &&
|
||||
ctx.isConversationIndependentRoute();
|
||||
if (ctx.initialRouteResolved && !onIndependentRoute) {
|
||||
ctx.currentConversationId = convId;
|
||||
if (data.title) {
|
||||
ctx.currentConversationTitle = data.title;
|
||||
@ -927,10 +937,14 @@ export async function initializeLegacySocket(ctx: any) {
|
||||
const onExplicitNewRoute =
|
||||
typeof ctx.isExplicitNewConversationRoute === 'function' &&
|
||||
ctx.isExplicitNewConversationRoute();
|
||||
// 独立全屏路由(工作流编辑器等)同样不归对话体系,快照不得接管当前对话
|
||||
const onIndependentRoute =
|
||||
typeof ctx.isConversationIndependentRoute === 'function' &&
|
||||
ctx.isConversationIndependentRoute();
|
||||
// 只有在初始化完成后,才允许 status_update 修改 currentConversationId
|
||||
// 避免与 bootstrapRoute 冲突
|
||||
if (status.conversation && status.conversation.current_id) {
|
||||
if (ctx.initialRouteResolved && !ctx.currentConversationId && !onExplicitNewRoute) {
|
||||
if (ctx.initialRouteResolved && !ctx.currentConversationId && !onExplicitNewRoute && !onIndependentRoute) {
|
||||
// 初始化完成且当前没有对话ID,才设置
|
||||
ctx.currentConversationId = status.conversation.current_id;
|
||||
}
|
||||
|
||||
@ -213,15 +213,15 @@
|
||||
color-scheme: dark;
|
||||
/* 推理强度滑槽(深色下需可见灰,不用近黑 surface-muted) */
|
||||
--effort-track-bg: #3a3a3c;
|
||||
/* Surface 表面层级 */
|
||||
/* Surface 表面层级(深色下层次向提亮方向拉开,禁近黑压暗,见 AGENTS.md §5.5-11) */
|
||||
--surface-base: #1a1a1a;
|
||||
--surface-panel: #1a1a1a;
|
||||
--surface-rail: #181818;
|
||||
--surface-sidebar: #181818;
|
||||
--surface-card: #0a0a0a;
|
||||
--surface-card: #262626;
|
||||
--surface-raised: #1a1a1a;
|
||||
--surface-soft: #0f0f0f;
|
||||
--surface-muted: #141414;
|
||||
--surface-soft: #212121;
|
||||
--surface-muted: #2b2b2b;
|
||||
/* Text 文字 */
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #a0a0a0;
|
||||
|
||||
@ -179,6 +179,11 @@
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.workflow-icon svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.monitor-mode-btn.blocked {
|
||||
color: var(--text-tertiary);
|
||||
opacity: 0.6;
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
export const ICONS = Object.freeze({
|
||||
alignLeft: '/static/icons/align-left.svg',
|
||||
arrowLeft: '/static/icons/arrow-left.svg',
|
||||
bot: '/static/icons/bot.svg',
|
||||
book: '/static/icons/book.svg',
|
||||
brain: '/static/icons/brain.svg',
|
||||
@ -9,6 +10,7 @@ export const ICONS = Object.freeze({
|
||||
chatBubble: '/static/icons/chat-bubble.svg',
|
||||
messageQuestion: '/static/icons/message-circle-question-mark.svg',
|
||||
checkbox: '/static/icons/checkbox.svg',
|
||||
chevronDown: '/static/icons/chevron-down.svg',
|
||||
circleAlert: '/static/icons/circle-alert.svg',
|
||||
clipboard: '/static/icons/clipboard.svg',
|
||||
copy: '/static/icons/copy.svg',
|
||||
@ -20,12 +22,14 @@ export const ICONS = Object.freeze({
|
||||
folder: '/static/icons/folder.svg',
|
||||
folderClosed: '/static/icons/folder-closed.svg',
|
||||
folderOpen: '/static/icons/folder-open.svg',
|
||||
gitBranch: '/static/icons/git-branch.svg',
|
||||
globe: '/static/icons/globe.svg',
|
||||
hammer: '/static/icons/hammer.svg',
|
||||
info: '/static/icons/info.svg',
|
||||
laptop: '/static/icons/laptop.svg',
|
||||
layers: '/static/icons/layers.svg',
|
||||
keyboard: '/static/icons/keyboard.svg',
|
||||
layoutGrid: '/static/icons/layout-grid.svg',
|
||||
menu: '/static/icons/menu.svg',
|
||||
mic: '/static/icons/mic.svg',
|
||||
mcpLogo: '/static/icons/mcp-logo.svg',
|
||||
@ -36,6 +40,8 @@ export const ICONS = Object.freeze({
|
||||
notebookSearch: '/static/icons/notebook-search.svg',
|
||||
octagon: '/static/icons/octagon.svg',
|
||||
pencil: '/static/icons/pencil.svg',
|
||||
play: '/static/icons/play.svg',
|
||||
plus: '/static/icons/plus.svg',
|
||||
python: '/static/icons/python.svg',
|
||||
recycle: '/static/icons/recycle.svg',
|
||||
refreshCw: '/static/icons/refresh-cw.svg',
|
||||
@ -50,6 +56,7 @@ export const ICONS = Object.freeze({
|
||||
bell: '/static/icons/bell.svg',
|
||||
user: '/static/icons/user.svg',
|
||||
userPen: '/static/icons/user-pen.svg',
|
||||
workflow: '/static/icons/workflow.svg',
|
||||
wrench: '/static/icons/wrench.svg',
|
||||
x: '/static/icons/x.svg',
|
||||
zap: '/static/icons/zap.svg'
|
||||
|
||||
280
workflow_research/paradigm_elements/paradigm_elements.md
Normal file
@ -0,0 +1,280 @@
|
||||
# 工作流/流程图编排范式元素体系与「审核」建模方式调研报告
|
||||
|
||||
> 调研目的:为「AI 智能体工作流编辑器(类 ComfyUI 拖拽画布、节点为 LLM 执行阶段)」的数据模型设计做前期调研,重点回答:**「审核」应建模为独立节点、节点的属性、还是边的属性?**
|
||||
>
|
||||
> 调研范围:BPMN 2.0(权威标准)、国内审批流产品(钉钉/飞书/企业微信等)、AI 工作流产品(Dify/Coze/n8n/LangGraph)、状态机理论(XState)。
|
||||
> 本报告基于公开文档与第三方文章整理,**所有结论标注了来源**;官方文档与第三方文章分别注明。
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
1. [各范式的元素体系清单(表格)](#1-各范式的元素体系清单表格)
|
||||
2. [审核/审批建模方式的产品对比(表格)](#2-审核审批建模方式的产品对比表格)
|
||||
3. [三种审核建模方式的利弊分析](#3-三种审核建模方式的利弊分析)
|
||||
4. [对「AI 智能体工作流」场景的建模建议](#4-对ai-智能体工作流场景的建模建议)
|
||||
5. [来源 URL 汇总](#5-来源-url-汇总)
|
||||
|
||||
---
|
||||
|
||||
## 1. 各范式的元素体系清单(表格)
|
||||
|
||||
### 1.1 BPMN 2.0 元素体系(权威标准,重点)
|
||||
|
||||
BPMN 2.0 是 OMG 制定的标准(ISO/IEC 19510),元素分五大类:**流程对象(Flow Objects)、数据(Data)、连接对象(Connecting Objects)、泳道(Swimlanes)、制品(Artifacts)**。以下按官方符号参考整理。
|
||||
|
||||
| 类别 | 元素 | 细分类型 | 语义要点 |
|
||||
| --- | --- | --- | --- |
|
||||
| **事件 Events** | Start / Intermediate / End(按位置分) | 如图标所示再按类型分 | 事件是流程中"发生的事",是圆;分为 **catching(等待触发)** 与 **throwing(主动发出)** 两类 |
|
||||
| | 事件类型(与位置组合) | None(空白)、Message(消息)、Timer(定时)、Conditional(条件)、Link(链接)、Signal(信号)、Error(错误)、Escalation(升级)、Termination(终止)、Compensation(补偿)、Cancel(取消)、Multiple(多选)、Multiple Parallel(并行多选) | 消息=发给特定接收方;信号=广播式通知;定时=时间触发;错误=异常处理(只能做边界/结束);补偿=撤销已完成的活动的副作用 |
|
||||
| | 边界事件(Attached/Boundary) | 中断式(实线)与非中断式(虚线) | 挂在活动边界上;中断式:事件触发即取消当前活动走异常流;非中断式:克隆 token 并行继续 |
|
||||
| **活动 Activities** | Task(任务,原子活动) | **Undefined(未定义)/ Manual(人工,无系统辅助)/ User(用户任务,有系统辅助)/ Receive(接收)/ Send(发送)/ Script(脚本)/ Service(服务)/ Business Rule(业务规则)** | User Task 即"审批/表单填写"等需要人与系统交互的任务(Camunda 8 官方文档:user task = 需要人工完成、由工作流引擎/软件辅助的工作);Service Task = 自动化服务调用;Script Task = 执行内联/外部脚本 |
|
||||
| | Subprocess(子流程,复合活动) | 内嵌 Subprocess(embedded)/全局 Subprocess 通过 Call Activity(调用活动,粗边框)引用 | 子流程内部有独立的起止事件;父流程 token 等待子流程完成。另有 **Event Subprocess**(虚线框,由事件触发,可中断/非中断)与 **Ad-hoc Subprocess**(波浪线标记,内部活动任意顺序/跳过) |
|
||||
| | 标记(Markers) | Loop(循环)、Multiple Instance(多实例,可并行/串行)、Compensation(补偿) | 附着在任务/子流程上,扩展其执行语义 |
|
||||
| **网关 Gateways** | 排他网关 Exclusive(XOR) | — | **恰好走一条路径**(数据条件互斥时用);经典"是/否、通过/拒绝"决策 |
|
||||
| | 并行网关 Parallel(AND) | — | **所有路径同时走**,合并时等待所有分支到达(漏掉 AND 合并会导致活动重复执行) |
|
||||
| | 包容网关 Inclusive(OR) | — | **一条或多条**条件为真则都走,合并时等待所有激活分支 |
|
||||
| | 基于事件网关 Event-based | — | 不按数据路由,而是**等待先发生的那个事件**(如收到消息 vs 计时器超时) |
|
||||
| | 复杂网关 Complex | — | 复杂的组合条件规则(ProcessMind 指南) |
|
||||
| **流向/连接对象** | Sequence Flow(顺序流) | — | 连接流程对象,表达执行顺序;**可在每条出边上写条件表达式**(如 Camunda/Flowable:`#{approved}`、ProcessMaker:`@@DocumentationReview == 'yes'`、`@#amount >= 1000`),条件为 true 的边被选中 |
|
||||
| | Message Flow(消息流) | — | 跨参与方(泳道/池)之间的消息传递 |
|
||||
| | Association(关联) | — | 把注释/数据对象关联到元素 |
|
||||
| **泳道** | Pool(池)、Lane(泳道) | — | Pool=一个参与方/编排边界;Lane=责任划分(角色/部门/人员) |
|
||||
| **数据** | Data Object / Data Store / Input / Output | — | 建模数据的输入输出与持久化状态 |
|
||||
| **制品** | Group(分组)、Annotation(注释) | — | 辅助说明 |
|
||||
|
||||
> 关键认知:**网关不是任务**——它不做任何工作,只是根据已有数据/事件决定走哪条路(Camunda 官方最佳实践:把决策问题放在网关前,各答案分别建模为一条出边)。
|
||||
|
||||
### 1.2 国内审批流产品的节点模型(钉钉/飞书/企业微信等)
|
||||
|
||||
| 产品 | 节点类型清单 | 审批是否独立节点 | 多人审批规则 | 特色操作 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| **钉钉宜搭**(官方帮助中心) | 发起人节点、**审批人节点**、抄送人节点、执行人(办理人)节点、条件分支节点、结束节点;节点上可配置"条件模式"(不同条件路由到不同审批人) | 是,审批人是独立一等节点 | 会签(全部同意)/ 或签(一人同意)/ 依次审批;或签时第一个提交结果者决定结果 | 审批按钮:**同意、拒绝、保存、转交、加签、退回、收回**;超时处理;审批人为空处理 |
|
||||
| **飞书审批**(官方帮助中心) | 发起节点、**审批人节点**、抄送人节点、办理人节点、条件分支节点、结束节点 | 是,审批人是独立一等节点 | 会签 / 或签 / 依次审批 | 审批类型:人工审批 / **自动通过 / 自动拒绝**;审批人类型 11 种(上级、部门负责人、角色、用户组、指定成员、提交人自选、提交人本人、节点审批人、连续多级上级、表单内联系人、表单内部门);操作权限:允许转交、允许加/减签(前/后/并加签)、允许回退;审批人为空、审批人与发起人相同时的特殊处理;抄送:发起时/中间/结束时,支持"仅同意时抄送" |
|
||||
| **企业微信审批**(官方开发者文档) | 节点类型 `node_type`:1=审批人、2=抄送人、3=办理人;多人办理方式 `apv_rel`:1=会签、2=或签、3=依次审批 | 是,节点类型字典中审批人是独立类型 | 会签 / 或签 / 依次审批 | 每个节点有状态 `sp_status`:1 审批中、2 同意、3 驳回、4 转审、11 退回给指定审批人、12 加签、13 同意并加签、14 办理、15 转交——**「同意/驳回/退回」是节点级状态** |
|
||||
| **明道云**(官方帮助) | 发起审批节点、审批节点、抄送节点、条件分支等 | 是 | 会签 / 或签 / 按通过比例 | 审批按钮:同意、拒绝、退回;转审;加签(通过后加签 / 审批前加签);退回范围:可退回到所有节点 / 仅上个节点 / 指定节点 / 发起节点;**退回流程不结束,驳回(拒绝)流程结束** |
|
||||
| **简道云**(官方帮助) | 发起节点、审批节点、抄送节点、条件分支、结束节点 | 是 | — | 节点操作:保存草稿、流程回退、流程否决、流程暂存、流程加签、流程转交、批量处理;**回退**:可回退到「上一节点」或「指定范围内节点(多选)」;回退节点重新提交时可选:按流程顺序审批 / 直达当前节点 / 由回退人决定;**节点回退 ≠ 流程撤回** |
|
||||
| **氚云**(官方帮助) | 发起(经办)节点、**审批节点**、抄送节点、办理(经办)节点、子流程、汇合点、连接线 | 是 | 会签 / 或签(可设同意比例、驳回人数) | **明确区分「退回」与「驳回」**:驳回=按节点流转规则(会签/或签)返回到前面节点或终止流程,审批人不可自由调节点;退回=由单个审批人**指定任意前序节点**退回 |
|
||||
|
||||
### 1.3 AI 工作流产品的节点类型体系
|
||||
|
||||
| 产品 | 节点/块类型(代表性) | 条件分支形态 | 人工审批(HITL)有无与建模方式 | 执行模型 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| **Dify**(官方产品页 + 第三方源码解析) | 开始(用户输入/定时触发/Webhook)、结束、LLM、知识检索、问题分类器、条件分支、迭代、工具、代码执行、HTTP 请求、变量聚合器、模板转换、参数提取、直接回复、**人工介入** | **一等公民节点**(IF_ELSE 节点,条件组 cases 判定后返回 `edge_source_handle` 决定走哪条出边) | **有,「人工介入」是独立节点**(官方:在敏感数据/权限/合规操作前暂停,由专人**审批、修改、评论、转交或超时处理**后再继续运行) | 图引擎:节点+边(edge),节点可多输出 |
|
||||
| **Coze(扣子)**(官方知识库 + 第三方教程) | 开始、结束、大模型(LLM)、插件、代码、知识库、条件判断、循环(数组/次数/无限)、变量、数据库、信息、意图识别、子工作流、HTTP | **一等公民节点**(条件判断节点,IF-ELSE,true/false 两出口) | **无专门审批节点**;人工介入通常靠「结束节点返回 + 对话式确认」或外部系统实现 | 可视化节点编排,节点输入输出引用(`{{node.output}}`)连接 |
|
||||
| **n8n**(官方节点 + 第三方深度分析) | Trigger、AI Agent、LLM、IF / Switch / Merge、Wait(等待)、Form(表单)、Code / Function、HTTP Request、Sub-workflow、**Human in the Loop(Human Review)**、Chat | **一等公民节点**(IF/Switch 节点负责分支;Merge 汇总) | **有,两层 HITL**:① workflow 级——Wait 节点暂停到指定时间/Webhook/表单到达;② **AI 工具调用级**——AI Agent 执行特定工具前要求人工批准(更接近 LangGraph interrupts)。审批模式=Human Review 节点(approve-only 或 approve-and-disapprove)+ 之后的 IF 节点按 true/false 分流 | 节点流(可并行、子工作流嵌套) |
|
||||
| **LangGraph**(官方文档 + 第三方指南) | Node(节点,任意函数)、StateGraph/edges(边)、conditional edges(条件边/路由器)、checkpoint(检查点持久化)、**interrupt(中断,HITL)**、Command(动态续跑) | **条件分支是「边的一等属性」**(`add_conditional_edges` 路由器,写代码定义,不是画布节点) | **有,HITL 是第一公民**:在节点内调用 `interrupt()` 暂停并持久化状态,外部(人)输入后通过 `Command` 恢复;典型模式="get_approval 节点 + 条件边 router"(同意→继续,拒绝→取消/改稿) | 有向图(节点+边)+ 共享 State + checkpoint 持久化 |
|
||||
|
||||
> 小结:
|
||||
> - **可视化工作流产品(Dify/Coze/n8n):条件分支是一等公民节点**;只有 LangGraph(代码框架)把条件分支放在边的层面(conditional edges)。
|
||||
> - **人工审批节点**:Dify 有「人工介入」节点、n8n 有「Human Review」节点均为一等公民节点;Coze 无专门审批节点;LangGraph 用**节点内 interrupt(暂停原语)+ 条件边**实现,本质上是"审批作为节点内事件 + 条件边路由"。
|
||||
> - 这些产品在这一点上高度一致:**"人工介入"需要有一个明确的载体(节点或节点内暂停点),因为要挂接审批人、表单、意见、暂停/恢复等多类状态**。
|
||||
|
||||
### 1.4 状态机视角(XState / 状态机理论)
|
||||
|
||||
| 概念 | 定义 | 与"审核"的对应关系 |
|
||||
| --- | --- | --- |
|
||||
| **状态 State** | 系统在某一时刻所处的确定配置;状态机任一时刻**只能处于一个状态** | "待审核 / 审核中(pending_approval)"是独立状态 |
|
||||
| **事件 Event** | 触发状态转换的信号(如 `APPROVE`、`REJECT`、`REVISE`) | 审批人的"同意/拒绝/退回"动作 = 事件 |
|
||||
| **转换 Transition** | 状态+事件 → 确定的下一个状态(**确定性**:state+event 永远指向同一目标) | 审核通过 → 进入下一执行阶段;审核拒绝 → 进入终止/修改状态 |
|
||||
| **守卫 Guard** | 转换前的条件(`cond` 谓词,为 true 才允许该转换发生) | "审批人是否具有权限""失败重试次数上限"等条件 |
|
||||
| **动作 Action** | 转换发生时执行的副作用 | 发送通知、写入审批记录 |
|
||||
| **扩展状态 Context / 层级状态 / 并行状态** | 上下文变量、嵌套状态、并行区域 | 审批意见/审批人/超时计时都放 context;"执行中"可嵌套"等待审批"子状态 |
|
||||
|
||||
**「审核」在状态机里通常的表达**:将审核建模为**一个独立状态(等待审批/审核中)+ 事件驱动的转换**——`APPROVE → next`、`REJECT → cancelled/rework`、`REVISE → 回到某前置阶段`,必要时用 guard 控制转换合法性。它与工作流图是**同构**的:工作流的"节点+边"可无损映射为状态机的"状态+转换",节点间的条件边就是带 guard 的转换。
|
||||
|
||||
---
|
||||
|
||||
## 2. 审核/审批建模方式的产品对比(表格)
|
||||
|
||||
| 产品/范式 | 审核建模方式 | 通过路由 | 拒绝路由 | 回退/驳回语义 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| **BPMN 2.0(Camunda/Flowable)** | **User Task(活动)+ Exclusive Gateway(网关)** 的经典组合:`Submit for approval` → XOR 网关 → 出边各写条件 | XOR 网关的 "approved == true" 出边 → 下一活动(ProcessMind/Gliffy/ProcessMaker 官方示例) | XOR 网关 "approved == false" 出边 → 结束事件或修正路径(Gliffy 明确:approval 后加 XOR 网关分 approved/rejected 两条互斥路径) | **原生 BPMN 无回退原语**:走向先前节点需建模为显式序列流(loop 回边);动态"退回任意节点"需引擎级处理(Camunda 官方论坛:用 Process Instance Modification 的 cancelActivityInstance + startTransition 实现回退;Flowable 官方论坛:同样要靠引擎 API/迁移实现,官方称"process instance migration API") |
|
||||
| **钉钉宜搭**(官方) | **审批人节点 = 独立一等节点**;审批操作 = 节点内按钮(同意/拒绝/转交/加签/退回/收回) | 审批人点「同意」→ 节点流转到下一节点(条件模式可路由到不同审批人) | 点「拒绝」→ 节点状态为拒绝,流程按规则终止(或签时第一人拒绝即终止) | 「退回」按钮可退回指定前序节点;「转交」转给他人;加签分前/后加签;支撑驳回终止与回退重审 |
|
||||
| **飞书审批**(官方) | **审批人节点 = 独立一等节点**;节点类型含"自动通过/自动拒绝" | 同意 → 下一节点/结束;可配"仅同意时抄送" | 拒绝 → 对应节点拒绝,流程按设计流转(通常是终止) | 操作权限勾选"允许回退"后,审批人可**回退到 1 个或多个前序节点**(所勾选节点均重新审批,重审完回到当前节点继续);支持转交、前/后/并加签 |
|
||||
| **企业微信审批**(官方 API) | **审批人节点 = 独立节点类型**(node_type=1);审批结果是**节点级状态**(同意/驳回/退回等 10+ 种 sp_status) | 节点状态=同意 → 流转下一节点(或结束) | 节点状态=驳回 → 申请单状态=已驳回 | 状态机里显式建模:11=退回给指定审批人、15=转交、12=加签、13=同意并加签——**回退是节点状态而非图边** |
|
||||
| **明道云**(官方) | **审批节点 = 独立节点**;审批操作:同意/拒绝/退回/转审/加签 | 同意 → 下一节点 | 拒绝(否决)→ 整个审批流程结束,需重新发起新流程 | 退回 → 流程**不结束**,可退到:所有节点/仅上个节点/指定节点/发起节点;退回意见必填;被退回节点重新提交后按流程顺序或直达当前节点 |
|
||||
| **简道云**(官方) | **审批节点 = 独立节点**;节点操作含"流程否决/流程回退" | 同意 → 按流程顺序流转 | 流程否决 → 流程终止 | 回退:可退「上一节点」或「指定范围内多个节点」;重提设置:按流程顺序审批 / 直达当前节点 / 由回退人确定;发起/结束节点不可配置回退 |
|
||||
| **氚云**(官方) | **审批节点 = 独立节点**;功能按钮:同意、不同意、暂存、转交、退回 | 同意 → 下一节点 | 驳回=按**流转规则**(会签/或签、驳回人数)返回前面节点或终止,不可自由选节点 | 退回=由单个审批人**指定任意前序节点**(不受流转规则限制) |
|
||||
| **Dify**(官方) | **「人工介入」= 独立节点**;节点负责收集审批意见、修改、评论、转交或超时处理 | 人工放行 → 该节点完成,沿出边继续下一个节点 | 节点内被驳回/修改后,由**条件分支节点**按结果路由(或按输出变量走不同出边) | 超时处理内建(官方列出的能力);"转交"内建;暂未见"退回任意节点"的图级原语,通常靠条件边回跳实现 |
|
||||
| **n8n**(官方+社区) | **Human Review 节点(独立)**;配置 approve-only 或 approve-and-disapprove、超时 | 审批值 true → 后续 IF 节点 true 分支继续 | 审批值 false → IF 节点 false 分支(do nothing / 通知 / 改稿) | Wait 节点暂停 + resume URL 恢复;无图级"回退任意节点",回退逻辑靠条件边回跳子工作流 |
|
||||
| **LangGraph**(官方) | **节点内 `interrupt()` 暂停(半独立节点语义)+ 条件边路由**;官方模式:get_approval 节点 + router 条件边 | 条件边读取 resume 载荷,approved → 继续执行 | rejected/revised → 条件边路由到取消或修改分支 | checkpoint 持久化使"任意点恢复/编辑状态"成为第一公民(可在中断点修改应用状态再续跑),最接近"任意节点回退" |
|
||||
| **XState(状态机)** | **独立状态**(如 review/pending)+ **事件驱动转换**(APPROVE/REJECT/REVISE)+ guard | `APPROVE` 事件 → 转换到下一阶段状态 | `REJECT` 事件 → 转换到终止/返工状态 | 回退 = 转换到任意前置状态(只要该转换被显式定义);guard 控制哪些回退合法 |
|
||||
|
||||
**提炼共识**:
|
||||
1. **审批几乎总是被建模为"独立节点/独立状态"**(唯一例外是把审核当普通活动属性的场景,未见主流产品这么做)。
|
||||
2. **通过/拒绝的路由表达**分两派:① 节点多个出口/节点状态值 + 下游条件分支(国内审批产品、n8n IF、LangGraph 条件边);② 显式排他网关 + 带条件出边(BPMN 正统,与 ① 等价)。
|
||||
3. **回退(驳回退回)是超图语义**:主流做法是把"退回目标"作为审批节点的**内建操作/节点属性**(可退回发起人/上一节点/任意指定前序节点/多个节点),由引擎在执行期计算目标节点,而不是在画布上画一条固定的回边——因为退回目标是**运行期动态选择**的。
|
||||
4. **驳回(reject)与退回(rollback)是两回事**:驳回=终止或按规则回退、流程可能结束;退回=流程不结束、退到指定节点重审后继续(明道云、氚云、简道云、飞书均如此区分)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 三种审核建模方式的利弊分析
|
||||
|
||||
核心问题:**「审核」应建模为独立节点、节点的属性、还是边的属性?**
|
||||
|
||||
### 方案 A:独立节点(审核把关节点 / Review Node / User Task / Human Review)
|
||||
|
||||
| 维度 | 评价 |
|
||||
| --- | --- |
|
||||
| 可视化清晰度 | **★★★ 最高**。审核点在画布上一眼可见,阶段间"人机交接"的位置明确;审核人、意见、按钮(同意/拒绝/退回)都直观挂在一个节点上 |
|
||||
| 数据模型简洁性 | **★★☆ 中等**。需要引入一个节点子类型(或节点 kind=review),节点 schema 需含审批人配置、审批表单、超时、多出口定义;但**审核自身的复杂度被封装在节点内部**,主图模型保持统一(node + edge) |
|
||||
| 表达力 | **★★★ 最高**。通过→正常出边;拒绝→终止/改稿出边;退回→节点内建操作(退回目标可运行期计算);可附加抄送、会签/或签、超时自动通过等复杂规则而不污染图结构 |
|
||||
| 执行引擎复杂度 | **★★☆ 中等**。引擎需要支持"暂停-等待-恢复"(HITL 暂停原语)与多出口路由;但由于暂停语义集中在一个节点类型里,实现可控 |
|
||||
| 业界采用 | **主流**:钉钉/飞书/企业微信/明道云/简道云/氚云(审批节点)、Dify(人工介入节点)、n8n(Human Review 节点)、BPMN(User Task + 网关)、LangGraph(interrupt 节点内暂停 + 条件边) |
|
||||
|
||||
### 方案 B:节点的属性(审核配置挂在某个"阶段/任务"节点上)
|
||||
|
||||
| 维度 | 评价 |
|
||||
| --- | --- |
|
||||
| 可视化清晰度 | **★☆ 最低**。审核藏在属性面板里,画布上看不出"这个阶段有人工把关";阶段多时难以区分哪些要审、哪些不要 |
|
||||
| 数据模型简洁性 | **★★★ 最高**。不新增节点类型,阶段节点上多一个 `needsApproval` 配置即可 |
|
||||
| 表达力 | **★★☆ 中**。单点审核勉强可行;但"审核通过后走分支A、拒绝走分支B"这类路由仍需要额外条件节点/条件边;多个审核结果(通过/拒绝/退回不同目标)表达复杂;"阶段间审核"变成"阶段内嵌审核",颗粒度不符合"阶段间把关"的诉求 |
|
||||
| 执行引擎复杂度 | **★★☆ 中**。暂停语义仍在(任何节点都可能要暂停),但引擎要为"几乎所有节点"支持 HITL 挂起,暂停点分散,审计/追踪困难 |
|
||||
| 业界采用 | **极少作为主方案**;仅在轻量场景(如一条消息内审)出现;主流产品都选择独立节点以换取可视化与表达能力 |
|
||||
|
||||
### 方案 C:边的属性(通过/拒绝 = 出边上的条件)
|
||||
|
||||
| 维度 | 评价 |
|
||||
| --- | --- |
|
||||
| 可视化清晰度 | **★★☆ 中**。路由本身画得清楚(一条边标 approved、一条标 rejected,等价于 BPMN 网关的出边条件),但"审核"这个**活动本身**(谁审、暂停、意见、超时)没有载体,审核过程不可见 |
|
||||
| 数据模型简洁性 | **★★☆ 中**。边需要挂条件表达式;"审核"仍需要一个执行体(否则谁产生 approved 变量?),通常退化为"某个节点输出审核结果变量 + 条件边路由"(即 LangGraph 模式) |
|
||||
| 表达力 | **★★☆ 中**。对"审核结果路由"表达很好(条件边天然支持);但对"审核的交互语义"(审批人指派、表单、意见、退回动态目标、抄送)几乎无法承载 |
|
||||
| 执行引擎复杂度 | **★★★ 最低(仅就路由而言)**。无需暂停原语的话就是普通条件跳转;但**只要有真实人工介入,就必须在某处暂停**——那时其实又建了一个"伪节点"(LangGraph 的 interrupt 正是如此:暂停点在节点内,路由在边上) |
|
||||
| 业界采用 | **作为路由机制广泛使用**(BPMN 序列流条件、LangGraph conditional edges、Dify 的 edge_source_handle),但**从不单独承载"审核"语义**——都是"审核节点/事件 + 条件边"组合 |
|
||||
|
||||
### 综合对比表
|
||||
|
||||
| 评判维度 | 方案A 独立节点 | 方案B 节点属性 | 方案C 边的属性 |
|
||||
| --- | --- | --- | --- |
|
||||
| 可视化清晰度 | ★★★ | ★☆ | ★★(仅路由) |
|
||||
| 数据模型简洁性 | ★★☆ | ★★★ | ★★☆ |
|
||||
| 表达力(审核结果路由) | ★★★ | ★★ | ★★★(路由部分) |
|
||||
| 表达力(审核交互:人/表单/意见/超时/抄送) | ★★★ | ★★ | ★☆ |
|
||||
| 回退/驳回动态目标表达 | ★★★(节点内建操作) | ★☆ | ★☆ |
|
||||
| 执行引擎复杂度 | ★★☆(暂停点集中、可控) | ★★☆(暂停点分散) | ★★★(路由简单,但真实暂停仍需节点载体) |
|
||||
| 业界主流 | ✅ 主流 | ❌ 罕见 | 部分(仅作为路由层,与A组合使用) |
|
||||
|
||||
**结论(对核心问题的回答)**:
|
||||
> **「审核」应当建模为独立节点(一等公民),并采用"节点多出口/节点输出决策变量 + 条件边路由"的组合式建模**——即方案 A 为主体、C 作为路由机制配合(等价于 BPMN 的 User Task + Exclusive Gateway 经典模式)。
|
||||
>
|
||||
> 理由:
|
||||
> 1. **审核同时是"活动"与"决策点"**:它是需要暂停、需要人做事、需要挂表单/意见/指派的活动,同时它的结果决定后续路由。独立节点能同时承载这两个属性;边只能承载路由、属性只能承载配置,二者都承担不了"暂停等人"的交互语义。
|
||||
> 2. **业界共识**:从最严谨的 BPMN(User Task + XOR)到国内审批产品(审批人节点),到 AI 工作流产品(Dify 人工介入、n8n Human Review、LangGraph interrupt+条件边),全部采用"审核有独立载体(节点/状态)+ 结果驱动路由"的同一模式。这是被检验过的工程共识。
|
||||
> 3. **回退/驳回是超图语义**,必须放在节点内建操作或引擎能力里(退回到发起人/上一节点/任意前序节点是运行期动态选择),无法用静态边表达。
|
||||
|
||||
---
|
||||
|
||||
## 4. 对「AI 智能体工作流」场景的建模建议
|
||||
|
||||
场景画像:类似 ComfyUI 的拖拽画布,节点是 **LLM 执行阶段**(Agent 自主执行),需要在**阶段之间插入人工审核把关**(阶段产物需人工确认后才能进入下一阶段)。以下为综合上述调研的建模建议。
|
||||
|
||||
### 4.1 总体建议:审核把关节点作为一等公民节点
|
||||
|
||||
```
|
||||
[开始] → [阶段A(LLM/Agent)] → [审核把关节点] ──通过──→ [阶段B(LLM/Agent)] → [结束]
|
||||
│
|
||||
├──拒绝/终止──→ [结束(失败)]
|
||||
└──退回──→ [指定前序阶段重跑](节点内建操作,运行期计算目标)
|
||||
```
|
||||
|
||||
- **审核把关节点(Review Gate Node)**:独立节点类型,语义 = "暂停执行,等待人工审核,输出审核结论"。字段建议:`approvers`(审批人/角色)、`review_form`(审核表单:展示的阶段产物 + 意见输入)、`decision_outputs`(通过的决策变量:approved/rejected/revised)、`timeout_policy`(超时:提醒/自动通过/自动拒绝)、`escalation`(转交)、`notify`(抄送,可关"仅通过时通知")、`allow_rollback`(退回目标范围:发起阶段/上一阶段/任意前序阶段/多节点)。
|
||||
- **通过/拒绝/退回的出口表达**:审核节点定义 **2~3 个命名出端口**(pass / reject / revise)或输出 `decision` 变量 + 下游条件边(推荐两者都支持:默认出端口直连,复杂分支交给条件节点/条件边,对齐 Dify 的 `edge_source_handle` 与 LangGraph 的条件边)。
|
||||
- **回退语义**:放在审核节点的内建操作("退回"按钮 + 可选目标列表),由引擎执行"取消当前分支 token → 在目标阶段创建重跑 token",借鉴 Camunda Process Instance Modification(cancelActivityInstance + startTransition)与飞书/简道云"可退到多个指定前序节点"的做法。退回重跑后的再提交语义参考简道云两种模式:**按流程顺序重走 / 直达当前审核节点**。
|
||||
- **与条件分支节点的关系**:条件分支节点保留为通用路由节点(AI 自主判断的路由用 if/else 或 LLM 判断);审核把关节点**不是条件分支的替代品**——它是"暂停+人工",条件分支是"即时+规则/模型"。两者分工明确。
|
||||
|
||||
### 4.2 数据模型要点(供后续设计参考)
|
||||
|
||||
| 实体 | 建议 | 依据 |
|
||||
| --- | --- | --- |
|
||||
| 图模型 | `Graph = { nodes[], edges[ {source, sourceHandle, target, condition?} ] }`;边可带条件表达式(BPMN sequence flow 条件、Dify edge_source_handle) | BPMN / Dify / LangGraph |
|
||||
| 节点基类 | `node = { id, type, name, inputs, outputs }`;type 枚举含 `llm_stage / agent_stage / review_gate / condition / code / tool / start / end` | Dify / Coze / n8n 节点体系 |
|
||||
| 审核节点 | 作为 `review_gate` 独立子类型;**务必建模"暂停-恢复"状态**(pending_review → approved/rejected/rolled_back),与状态机视角一致 | XState(独立状态+事件转换)、企业微信节点状态字典 |
|
||||
| 条件分支 | 推荐作为**一等公民节点**(对齐 Dify/Coze/n8n 的用户习惯);同时**允许边带条件**(对齐 LangGraph 与 BPMN)作为高级能力 | Dify/Coze/n8n/LangGraph 对比 |
|
||||
| 执行引擎 | 需要三类原语:① 节点执行(LLM/工具/代码);② **暂停/等待人工(interrupt/resume,参考 LangGraph checkpoint + interrupt)**;③ 分支路由(条件边/多出口)。回退 = 引擎 API(参考 Camunda Process Instance Modification) | LangGraph / Camunda |
|
||||
| 运行状态 | 每次执行为 DAG 的一次遍历:记录每个节点的输入/输出/决策句柄;审核节点额外记录审批人、意见、时间戳(审计) | Dify WorkflowNodeExecutionModel |
|
||||
|
||||
### 4.3 为什么这个建议适合"AI 自主执行 + 阶段间审核"场景
|
||||
|
||||
- **AI 阶段是"黑箱自动化"**,阶段间审核是把控点,必须**显式、可见**——独立节点让"人在回路的位置"一目了然,符合 Dify 官方"在敏感操作前暂停、关键判断交给人"的实践。
|
||||
- **暂停必须有一等公民支持**:AI 阶段时长不定(一次 LLM 调用秒级,但一个 agent 阶段可能分钟级),真实人工审核更可能数小时——需要 checkpoint 持久化 + 恢复机制(LangGraph 已验证该模式:interrupt + Command + checkpoint)。
|
||||
- **审核结果驱动路由**:通过→下一阶段、拒绝→终止或改稿、退回→指定前序阶段重跑——用"多出口 + 条件边"表达,与 LangGraph 的 get_approval + router、n8n 的 Human Review + IF 完全同构,参考实现成本最低。
|
||||
- **避免过度设计**:不建议"节点的属性"方案(审核不可见、暂停点分散);不建议纯"边的属性"方案(没有载体承载审批交互与回退语义)。**独立节点 + 条件边路由**是三方案中可视化、表达力与实现复杂度平衡最优的。
|
||||
|
||||
### 4.4 建议的最小节点清单(供数据模型落地)
|
||||
|
||||
| 节点类型 | 用途 | 对应参考 |
|
||||
| --- | --- | --- |
|
||||
| Start / End | 输入与输出 | Dify、Coze、BPMN 事件 |
|
||||
| LLM / Agent 阶段节点(可含工具调用) | AI 自主执行阶段(可 loop/多实例) | Dify LLM/Agent、Coze 大模型、n8n AI Agent、LangGraph node |
|
||||
| **审核把关节点(Review Gate)** | 阶段间人工审核,多出口(pass/reject/revise),内建退回/转交/超时 | BPMN User Task、钉钉/飞书/企微审批节点、Dify 人工介入、n8n Human Review、LangGraph interrupt |
|
||||
| 条件分支节点(if/else) | AI 或规则驱动的路由 | Dify/Coze/n8n |
|
||||
| 代码/工具/HTTP 节点 | 通用能力 | Dify、Coze、n8n、BPMN Service/Script Task |
|
||||
| 变量聚合/模板(可选) | 数据整理 | Dify、Coze |
|
||||
|
||||
---
|
||||
|
||||
## 5. 来源 URL 汇总
|
||||
|
||||
### 官方文档(权威来源)
|
||||
- Camunda — BPMN 2.0 Symbols Reference(官方符号大全,事件/活动/网关/流向语义):https://camunda.com/bpmn/reference
|
||||
- Camunda 8 Docs — User Tasks(用户任务语义、监听、Job worker 实现):https://docs.camunda.io/docs/components/modeler/bpmn/user-tasks
|
||||
- Camunda — BPMN Tasks(Task 类型参考):https://camunda.com/bpmn/reference/#activities (活动章节)
|
||||
- Camunda Blog — Events: Basic Concepts(catching/throwing、边界事件、非中断事件):https://camunda.com/bpmn/reference/#events
|
||||
- Camunda Forum — "Go back to previous user task"(官方论坛:回退需 Process Instance Modification 的 cancelActivityInstance + startTransition):https://forum.camunda.io/t/go-back-to-previous-user-task/29485
|
||||
- Flowable Forum — "How to set the completed task to active to achieve the rollback"(官方论坛:Flowable 无原生回退,靠引擎 API/迁移):https://forum.flowable.org/t/how-to-set-the-completed-task-to-active-to-achieve-the-rollback-in-flowable/7335
|
||||
- 钉钉宜搭帮助中心 — 审批人节点(节点类型、审批按钮、会签/或签/依次、条件模式):https://docs.aliwork.com/docs/yida_support/_2/trbqg6/rq8i94
|
||||
- 飞书审批帮助中心 — 管理员设计审批流程(节点类型、审批人 11 类、会签/或签/依次):https://www.feishu.cn/hc/zh-CN/articles/360036163653-管理员设计审批流程
|
||||
- 飞书审批帮助中心 — 管理员设置转交、加减签、回退审批(回退/加签语义):https://www.feishu.cn/hc/zh-CN/articles/360049067381-管理员设置转交、加减签、回退审批
|
||||
- 飞书审批帮助中心 — 管理员设置抄送人(抄送节点、仅同意时抄送):https://www.feishu.cn/hc/zh-CN/articles/360041749473-管理员设置抄送人
|
||||
- 企业微信开发者中心 — 获取审批申请详情(node_type / sp_status 节点状态字典:同意/驳回/退回给指定审批人/加签/转交):https://developer.work.weixin.qq.com/document/path/92634
|
||||
- 企业微信开发者中心 — 审批申请状态变化回调通知:https://developer.work.weixin.qq.com/document/path/96508
|
||||
- 明道云帮助中心 — 发起审批流程-审批节点(同意/拒绝/退回、转审、加签、退回范围):https://help.mingdao.com/workflow/node-approve
|
||||
- 简道云帮助中心 — 流程回退(上一节点/指定范围内节点、重提设置、节点回退 vs 流程撤回):https://hc.jiandaoyun.com/doc/12524
|
||||
- 氚云帮助中心 — 流程节点和连接线(审批/经办/抄送/子流程/汇合点;退回与驳回区别;加签):https://help.h3yun.com/contents/818/2482.html
|
||||
- Dify 官方产品页 — Workflow Studio(节点列表含「人工介入」:审批、修改、评论、转交、超时处理):https://dify.ai/zh/workflows
|
||||
- LangGraph 官方文档 — Interrupts(暂停/持久化/Command 恢复/条件边校验模式):https://docs.langchain.com/oss/python/langgraph/interrupts
|
||||
- LangChain Blog — "Making it easier to build human-in-the-loop agents with interrupt":https://www.langchain.com/blog/making-it-easier-to-build-human-in-the-loop-agents-with-interrupt
|
||||
- Stately(XState 官方) — Events and transitions(状态/事件/转换确定性):https://stately.ai/docs/transitions
|
||||
|
||||
### 第三方文章/教程(补充说明,标记为第三方)
|
||||
- Dokuflex — BPMN 2.0 guide(四大元素族、网关语义):https://www.dokuflex.com/en/resources/bpmn-guide.html (第三方)
|
||||
- Lucid — BPMN Tutorial and Templates(五大类元素):https://lucid.co/diagram/bpmn/tutorial (第三方)
|
||||
- ProcessMaker Wiki — Gateways(XOR 网关条件表达式示例 `@@DocumentationReview`):https://wiki.processmaker.com/3.1/Gateways (第三方)
|
||||
- ProcessMind — BPMN Gateway Types & Usage Guide(审批示例:approved 继续 / rejected 结束):https://processmind.com/resources/docs/bpmn-building-blocks/gateways (第三方)
|
||||
- Gliffy — How to Read and Use BPMN Gateways(提交审批 + XOR 网关 approved/rejected 两互斥路径):https://www.gliffy.com/blog/bpmn-gateways (第三方)
|
||||
- BA Copilot — Gateway (BPMN)(XOR 用于 yes/no、approved/declined 分支):https://ba-copilot.com/glossary/gateway-bpmn (第三方)
|
||||
- eduMAX — Exclusive vs Parallel vs Inclusive Gateways(路径数对比表):https://www.edumax.pro/blog/exclusive-vs-parallel-vs-inclusive-gateway-in-bpmn-whats-the-difference (第三方)
|
||||
- Medium(Sebastian Lesser)— BPMN Gateways Explained:https://medium.com/@sebastian.lesser/bpmn-gateways-explained-2fbea236b0fa (第三方)
|
||||
- ProcessCamp — BPMN Elements Reference(元素分类计数):https://processcamp.io/bpmn-elements (第三方)
|
||||
- Beyond Engineering — BPMN 2.0 Elements:http://www.beyondengineering.io/bpmn-elements (第三方)
|
||||
- 火山引擎开发者社区 — 从零开始学 Dify 工作流实现机制(节点类型/IF_ELSE 实现/edge_source_handle):https://developer.volcengine.com/articles/7538284296277229609 (第三方)
|
||||
- Dify 官网博客转述 — Dify Workflow 重磅上线(核心节点:LLM/工具/意图分类器/知识检索/代码/If-Else):https://www.53ai.com/news/dify/1866.html (第三方转载)
|
||||
- 阿里云开发者社区 — 使用 Dify 创建 AI 应用并详解节点(IF/ELIF/ELSE 条件:包含/开始是/为空等):https://developer.aliyun.com/article/1589591 (第三方)
|
||||
- 博客园 — Coze 智能体之工作流节点(开始/结束/大模型/插件/代码/条件分支/循环 节点表):https://www.cnblogs.com/fuminer/p/19377669 (第三方)
|
||||
- 掘金 — Coze 工作流与触发器(大模型节点配置、节点输入输出引用、工作流/对话流差异):https://juejin.cn/post/7517107495392575514 (第三方)
|
||||
- SkillHub(腾讯云)— Coze 节点清单(LLM/代码/知识库/插件/条件/循环/变量/HTTP):https://skillhub.cloud.tencent.com/skills/coze (第三方)
|
||||
- REBUILD 帮助文档 — 审批流程(发起人/条件分支/审批人/抄送人节点、加签/转审/会签或签/限时/自由审批):https://getrebuild.com/docs/admin/approval (第三方)
|
||||
- FlyFlow 更新日志(节点新增:投票节点/办理节点等,佐证国内审批流节点体系):https://www.flyflow.cc/upgrade (第三方)
|
||||
- ZenML Blog — LangGraph vs n8n(n8n 两层 HITL:Wait 节点 + AI 工具调用级批准;LangGraph interrupts):https://www.zenml.io/blog/langgraph-vs-n8n (第三方)
|
||||
- Peliqan — LangGraph vs n8n(HITL 对比、checkpointing):https://peliqan.io/blog/langgraph-vs-n8n (第三方)
|
||||
- LOW/CODE — n8n vs LangGraph(n8n 节点/400+ 集成/HITL 暂停审批):https://www.lowcode.agency/blog/n8n-vs-langgraph (第三方)
|
||||
- TopsInfoSolutions — n8n vs LangGraph(LangGraph checkpoint、HITL 暂停审批恢复):https://www.topsinfosolutions.com/blog/n8n-vs-langgraph (第三方)
|
||||
- jimmysong.io — Open Source AI Agent Platform Comparison(Dify/Coze/n8n/LangGraph 平台对比):https://jimmysong.io/blog/open-source-ai-agent-workflow-comparison (第三方)
|
||||
- CustomJS — n8n Human-in-the-Loop(Wait 节点/webhook 恢复、审批表单局限):https://www.customjs.space/blog/n8n-human-in-the-loop (第三方)
|
||||
- GrowwStacks — Human in the Loop in n8n Guide(Human Review 节点:approve-only / approve-and-disapprove、超时):https://growwstacks.com/blog/human-in-the-loop-n8n-guide (第三方)
|
||||
- nocodecreative.io — n8n Wait Node v2(Wait 节点 + 子工作流 HITL):https://blog.nocodecreative.io/n8n-v2-wait-node-hitl-sub-workflows (第三方)
|
||||
- DEV Community — LangGraph Interrupts and Commands(get_approval 节点 + router 条件边示例):https://dev.to/jamesbmour/interrupts-and-commands-in-langgraph-building-human-in-the-loop-workflows-4ngl (第三方)
|
||||
- Future AGI — What is LangGraph(StateGraph/节点/边/条件边/checkpoint/interrupt):https://futureagi.com/blog/what-is-langgraph-2026 (第三方)
|
||||
- sdust.dev — XState 101(状态/转换/事件/守卫):https://sdust.dev/posts/2023-04-12_xstate-101-quick-introduction-to-finite-state-machine (第三方)
|
||||
- egghead — State Transitions through Events(状态机确定性转换):https://egghead.io/lessons/javascript-handle-state-transitions-through-events-in-a-finite-state-machine-with-xstate (第三方)
|
||||
- DEV Community — State machine advent: Guard(guard 守卫语义):https://dev.to/codingdive/state-machine-advent-guard-state-transitions-guard-actions-14-24-oc3 (第三方)
|
||||
|
||||
---
|
||||
|
||||
### 附:本报告的确定性说明
|
||||
- **百分百确认**:BPMN 元素分类与网关语义、各产品官方文档中列出的节点类型与审批按钮/状态字典、Dify/n8n/LangGraph 官方 HITL 能力描述——均直接出自官方文档原文。
|
||||
- **很大概率**:对"业界主流选择是独立节点+条件路由"的归纳——基于上述 12+ 个产品的官方/权威资料的一致性得出;"审批应建模为独立节点"为综合分析结论,属合理推断而非任何官方标准的规定。
|
||||
- **可能**:"回退目标作为节点内建操作、由引擎在执行期计算"的工程建议——综合 Camunda/Flowable 官方论坛做法与国内审批产品的一致设计推断。
|
||||
|
||||
(调研日期:2026-08-20;未修改项目任何代码文件。)
|
||||
85
workflows/bug-fix-triage/WORKFLOW.md
Normal file
@ -0,0 +1,85 @@
|
||||
---
|
||||
name: bug-fix-triage
|
||||
description: 缺陷分诊修复流程:复现确认、按严重程度三向分流、热修/常规双轨、统一回归把关
|
||||
review_mode: active
|
||||
max_stage_rounds: 30
|
||||
end_conditions: 修复通过回归审核且更新日志落盘;或分诊为不跟进直接归档
|
||||
nodes:
|
||||
- id: start-1
|
||||
kind: start
|
||||
name: 开始
|
||||
next: reproduce
|
||||
- id: reproduce
|
||||
kind: stage
|
||||
name: 复现确认
|
||||
goal: 稳定复现缺陷并确认影响面
|
||||
instructions: 记录复现步骤与环境;无法复现的缺陷退回报告人补充信息。
|
||||
next: severity-branch
|
||||
- id: severity-branch
|
||||
kind: branch
|
||||
name: 严重程度分诊
|
||||
next:
|
||||
- target: hotfix
|
||||
condition: 线上崩溃、数据丢失或安全漏洞,必须立即修复
|
||||
- target: fix
|
||||
condition: 功能异常但存在变通方法,随正常节奏修复
|
||||
- target: end-1
|
||||
condition: 影响极低、无法复现或重复反馈,记录后不再跟进
|
||||
- id: hotfix
|
||||
kind: stage
|
||||
name: 紧急热修
|
||||
goal: 以最小改动止血
|
||||
instructions: 只修阻断点,不做顺手重构;附带最小复现用例验证。
|
||||
next: hotfix-gate
|
||||
- id: hotfix-gate
|
||||
kind: review
|
||||
name: 热修审核
|
||||
prompt: 热修是否最小化且无副作用,复现用例是否真实跑过
|
||||
next: merge
|
||||
reject_to: hotfix
|
||||
max_rejects: 2
|
||||
- id: fix
|
||||
kind: stage
|
||||
name: 常规修复
|
||||
goal: 根治缺陷并补齐防护
|
||||
instructions: 定位根因后修复;必须补回归测试覆盖该缺陷路径。
|
||||
next: fix-gate
|
||||
- id: fix-gate
|
||||
kind: review
|
||||
name: 修复审核
|
||||
prompt: 是否根治而非掩盖症状,回归测试是否真实运行过
|
||||
next: merge
|
||||
reject_to: fix
|
||||
max_rejects: 3
|
||||
- id: merge
|
||||
kind: branch
|
||||
name: 汇总
|
||||
next:
|
||||
- target: regress-gate
|
||||
condition: ''
|
||||
- id: regress-gate
|
||||
kind: review
|
||||
name: 回归审核
|
||||
prompt: 整体回归是否通过,修复是否引入新问题
|
||||
next: document
|
||||
reject_to: severity-branch
|
||||
max_rejects: 2
|
||||
- id: document
|
||||
kind: stage
|
||||
name: 更新日志
|
||||
goal: 记录缺陷根因与修复内容
|
||||
instructions: 写入更新日志;标注受影响版本范围。
|
||||
next: end-1
|
||||
- id: end-1
|
||||
kind: end
|
||||
name: 结束
|
||||
---
|
||||
|
||||
## 工作方式
|
||||
先复现再分诊,按严重程度走对应修复轨道。
|
||||
|
||||
## 验证方式
|
||||
热修必须有最小复现用例;常规修复必须补回归测试。
|
||||
|
||||
## 结束方式
|
||||
回归审核通过后更新更新日志并归档。
|
||||
56
workflows/code-review-pipeline/WORKFLOW.md
Normal file
@ -0,0 +1,56 @@
|
||||
---
|
||||
name: code-review-pipeline
|
||||
description: 代码评审标准流程:探索改动、逐项评审、输出结构化报告
|
||||
review_mode: active
|
||||
max_stage_rounds: 20
|
||||
end_conditions: 评审报告落盘且最终审核通过
|
||||
nodes:
|
||||
- id: start-1
|
||||
kind: start
|
||||
name: 开始
|
||||
next: explore
|
||||
- id: explore
|
||||
kind: stage
|
||||
name: 代码探索
|
||||
goal: 理解改动范围与相关模块,产出影响面清单
|
||||
instructions: 先读 git diff 总览,再按模块逐个深入;禁止通读整文件。
|
||||
next: review
|
||||
- id: review
|
||||
kind: stage
|
||||
name: 逐项评审
|
||||
goal: 按 checklist 评审每个改动文件
|
||||
instructions: 关注边界条件、错误处理、安全问题;每条意见标注文件与行号。
|
||||
next: review-gate
|
||||
- id: review-gate
|
||||
kind: review
|
||||
name: 评审审核
|
||||
prompt: 检查是否遗漏边界条件和安全问题
|
||||
next: report
|
||||
reject_to: explore
|
||||
max_rejects: 3
|
||||
- id: report
|
||||
kind: stage
|
||||
name: 输出报告
|
||||
goal: 生成结构化评审报告并落盘
|
||||
instructions: 按「严重/建议/可选」三档组织;给出明确结论。
|
||||
next: report-gate
|
||||
- id: report-gate
|
||||
kind: review
|
||||
name: 报告审核
|
||||
prompt: 报告结论是否与评审意见一致
|
||||
next: end-1
|
||||
reject_to: review
|
||||
max_rejects: 3
|
||||
- id: end-1
|
||||
kind: end
|
||||
name: 结束
|
||||
---
|
||||
|
||||
## 工作方式
|
||||
按阶段推进,每个阶段完成后调用阶段汇报工具。
|
||||
|
||||
## 验证方式
|
||||
关键结论必须有代码行号或命令输出佐证。
|
||||
|
||||
## 结束方式
|
||||
报告写入 output/ 目录并汇报完成。
|
||||
69
workflows/feature-development/WORKFLOW.md
Normal file
@ -0,0 +1,69 @@
|
||||
---
|
||||
name: feature-development
|
||||
description: 功能开发流程:需求理解、按策略分支实现、汇总验证、文档收尾
|
||||
review_mode: active
|
||||
max_stage_rounds: 30
|
||||
end_conditions: 验证通过且文档更新完成
|
||||
nodes:
|
||||
- id: start-1
|
||||
kind: start
|
||||
name: 开始
|
||||
next: understand
|
||||
- id: understand
|
||||
kind: stage
|
||||
name: 需求理解
|
||||
goal: 明确需求边界与涉及模块
|
||||
instructions: 产出改动清单与影响面;不确定处先问用户。
|
||||
next: impl-branch
|
||||
- id: impl-branch
|
||||
kind: branch
|
||||
name: 实现策略
|
||||
next:
|
||||
- target: prototype
|
||||
condition: 方案不确定或风险高,先低成本验证
|
||||
- target: full-impl
|
||||
condition: 需求明确、改动范围清晰,直接正式实现
|
||||
- id: prototype
|
||||
kind: stage
|
||||
name: 快速原型
|
||||
goal: 最小成本验证方案可行性
|
||||
instructions: 只写关键路径,不做边界打磨。
|
||||
next: merge
|
||||
- id: full-impl
|
||||
kind: stage
|
||||
name: 完整实现
|
||||
goal: 完成正式代码修改
|
||||
instructions: 小步快跑,优先根治不打补丁。
|
||||
next: merge
|
||||
- id: merge
|
||||
kind: branch
|
||||
name: 汇总
|
||||
next:
|
||||
- target: verify-gate
|
||||
condition: ''
|
||||
- id: verify-gate
|
||||
kind: review
|
||||
name: 验证审核
|
||||
prompt: 验证证据是否真实可复现:构建与测试输出必须实际运行过
|
||||
next: document
|
||||
reject_to: impl-branch
|
||||
max_rejects: 3
|
||||
- id: document
|
||||
kind: stage
|
||||
name: 文档收尾
|
||||
goal: 更新相关文档
|
||||
instructions: 同步 AGENTS.md 与相关 doc。
|
||||
next: end-1
|
||||
- id: end-1
|
||||
kind: end
|
||||
name: 结束
|
||||
---
|
||||
|
||||
## 工作方式
|
||||
先理解再动手,禁止跳过验证。
|
||||
|
||||
## 验证方式
|
||||
构建通过 + 相关测试通过。
|
||||
|
||||
## 结束方式
|
||||
更新文档并汇报。
|
||||
62
workflows/research-report/WORKFLOW.md
Normal file
@ -0,0 +1,62 @@
|
||||
---
|
||||
name: research-report
|
||||
description: 调研报告流程:搜集、分析、撰写、校对
|
||||
review_mode: readonly
|
||||
max_stage_rounds: 15
|
||||
end_conditions: 报告定稿且事实核查通过
|
||||
nodes:
|
||||
- id: start-1
|
||||
kind: start
|
||||
name: 开始
|
||||
next: collect
|
||||
- id: collect
|
||||
kind: stage
|
||||
name: 资料搜集
|
||||
goal: 收集目标主题的权威资料
|
||||
instructions: 官方文档优先,其次第三方报道;记录 URL。
|
||||
next: analyze
|
||||
- id: analyze
|
||||
kind: stage
|
||||
name: 分析整理
|
||||
goal: 归纳核心观点与分歧
|
||||
instructions: 按主题分组,标注共识与争议点。
|
||||
next: draft
|
||||
- id: draft
|
||||
kind: stage
|
||||
name: 撰写初稿
|
||||
goal: 输出结构化报告初稿
|
||||
instructions: 先骨架后填充;每个结论标注来源类型。
|
||||
next: draft-gate
|
||||
- id: draft-gate
|
||||
kind: review
|
||||
name: 初稿审核
|
||||
prompt: 事实是否都有来源标注
|
||||
next: polish
|
||||
reject_to: collect
|
||||
max_rejects: 3
|
||||
- id: polish
|
||||
kind: stage
|
||||
name: 校对定稿
|
||||
goal: 事实核查与文字打磨
|
||||
instructions: 逐条核对来源;压缩冗余表述。
|
||||
next: polish-gate
|
||||
- id: polish-gate
|
||||
kind: review
|
||||
name: 终稿审核
|
||||
prompt: 结论确定性分级是否准确
|
||||
next: end-1
|
||||
reject_to: draft
|
||||
max_rejects: 3
|
||||
- id: end-1
|
||||
kind: end
|
||||
name: 结束
|
||||
---
|
||||
|
||||
## 工作方式
|
||||
信息必须标注来源。
|
||||
|
||||
## 验证方式
|
||||
关键事实至少两个来源交叉验证。
|
||||
|
||||
## 结束方式
|
||||
输出 markdown 报告。
|
||||