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 去近黑色阶)
This commit is contained in:
JOJO 2026-08-21 01:38:03 +08:00
parent 833961766f
commit d071292c81
42 changed files with 4950 additions and 71 deletions

View File

@ -1,24 +1,28 @@
# 工作流Workflow功能设计方案 # 工作流Workflow功能设计方案
> 状态:设计已讨论定稿2026-08-18待实施 > 状态:设计讨论中2026-08-18 更新,纳入阶段汇报工具 / 非线性路由 / 可视化编辑决策)
> 调研依据:`workflow_research/external_products/agentic_workflow_research.md`(外部产品)、`workflow_research/code_mount_points/code_mount_points.md`(代码挂载点)。 > 调研依据:`workflow_research/external_products/agentic_workflow_research.md`(外部产品)、`workflow_research/code_mount_points/code_mount_points.md`(代码挂载点)。
## 1. 功能定义 ## 1. 功能定义
用户把「一套既定流程:工作方式 → 验证方式 → 结束方式」存为一个**工作流**workflow之后在任何普通对话中激活它让主智能体复用现有循环与执行引擎按流程推进阶段结束/整体结束时可由审核智能体软审核。 用户把「一套既定流程:工作方式 → 验证方式 → 结束方式」存为一个**工作流**workflow之后在任何普通对话中激活它让主智能体复用现有循环与执行引擎按流程推进阶段结束/整体结束时可由审核智能体软审核。
核心原则(来自外部调研共识):**结构在边上,自主在节点内**——工作流只约束阶段顺序与进出契约,阶段内主智能体完全自主(自由调工具、跑脚本)。 核心原则(外部调研共识):**结构在边上,自主在节点内**——工作流只约束阶段拓扑与进出契约,阶段内主智能体完全自主(自由调工具、跑脚本)。
## 2. 已拍板的设计决策 ## 2. 已拍板的设计决策
| 决策点 | 结论 | | 决策点 | 结论 |
|---|---| |---|---|
| 运行方式 | **不做独立模式/特殊 URL/新对话类型**,只能在正常对话中激活 | | 运行方式 | **不做独立模式/特殊 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 通道) | | 前后端通信 | **轮询**(复用 task 轮询 session_data 快照链路,不新增 websocket 通道) |
| 状态隔离 | **对话级状态**,多对话可同时激活不同工作流,进度互不串扰 | | 状态隔离 | **对话级状态**,多对话可同时激活不同工作流,进度互不串扰 |
| 审核机制 | 复用审核智能体架构(`goal_review_agent.py` 模式),审核智能体可调用只读 run_command 取证 | | 硬校验 | 不做步骤级硬校验归档时只做结构校验frontmatter 合法、阶段 id 唯一、路由目标存在) |
| 硬校验 | 不做步骤级硬校验只做归档时结构校验frontmatter 合法、阶段 id 唯一等) |
| 存储形态 | 工作流文件夹 = `WORKFLOW.md`frontmatter 结构 + 自然语言正文)+ `scripts/`(已验证脚本)+ `references/`(可选),与 skill 对齐 | | 存储形态 | 工作流文件夹 = `WORKFLOW.md`frontmatter 结构 + 自然语言正文)+ `scripts/`(已验证脚本)+ `references/`(可选),与 skill 对齐 |
## 3. 存储与落盘位置 ## 3. 存储与落盘位置
@ -28,11 +32,11 @@
| host | `~/.astrion/astrion/host/workflows/` | 统一,不按用户拆(对齐 `CUSTOM_SKILLS_DIR` | | host | `~/.astrion/astrion/host/workflows/` | 统一,不按用户拆(对齐 `CUSTOM_SKILLS_DIR` |
| docker/web | `users/<user>/personal/workflows/` | 每用户私有,多项目共享(对齐 `infer_private_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` 模式)。 - 归档工具 `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 ```markdown
--- ---
@ -40,70 +44,98 @@ name: code-review-pipeline
description: 代码评审标准流程 description: 代码评审标准流程
review_mode: active # 审核智能体是否可调用只读 run_command 取证 review_mode: active # 审核智能体是否可调用只读 run_command 取证
max_stage_rounds: 20 # 单阶段最大轮数,防死循环 max_stage_rounds: 20 # 单阶段最大轮数,防死循环
entry: explore # 入口阶段
stages: stages:
- id: explore - id: explore
name: 代码探索 name: 代码探索
goal: 理解改动范围与相关模块 goal: 理解改动范围与相关模块
review: false review: false
next: [review] # 候选路由集AI 汇报时从中选择下一站
- id: review - id: review
name: 逐项评审 name: 逐项评审
goal: 按 checklist 评审每个文件 goal: 按 checklist 评审每个文件
review: true review: true # 阶段汇报先触发审核
review_prompt: 检查是否遗漏边界条件和安全问题 review_prompt: 检查是否遗漏边界条件和安全问题
next: [report, explore] # 审核通过后可进报告,也可回探索补充
- id: report - id: report
name: 输出报告 name: 输出报告
goal: 生成结构化评审报告 goal: 生成结构化评审报告
review: true review: true
next: [] # 空 = 终点,汇报即触发整体结束
end_conditions: 报告落盘且审核通过 end_conditions: 报告落盘且审核通过
--- ---
# 工作方式 / 验证方式 / 结束方式(自然语言正文,随阶段上下文注入) # 工作方式 / 验证方式 / 结束方式(自然语言正文,随阶段上下文注入)
``` ```
- 阶段默认线性推进;`on_pass` / `on_fail` 可跳转指定阶段(一期线性+有限跳转,不做完整 DAG - **路由语义**`next` 是候选集结构约束AI 在阶段汇报工具中传 `next_stage_id` 自主选择;审核智能体可否决路由选择(打回时附带建议去向)。
- 结构校验在归档时做name/description/stages 存在、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` 续命 对话激活工作流 → 注入入口阶段上下文goal + 工作方式 + 候选路由)
- 最后阶段通过 → 整体结束审核 → `mark_done`
- 边界保护:`max_stage_rounds`、空转保护(仿 `REASON_IDLE_NO_TOOL`)、用户取消 主智能体正常跑(现有引擎不变,自由调工具、跑 scripts/
- **压缩维持**:状态落盘 + `handle_task_with_sender` 入口检查活跃状态并重注入当前阶段上下文(对齐 goal 的 `inject_goal_prompt` 重注入模式)。
- **编排模块**:新增 `server/workflow_flow.py`(仿 `server/goal_flow.py`),减少对主循环侵入。 AI 调 report_workflow_stage(summary, next_stage_id?) ← 阶段边界显式声明
- **审核智能体**:新增 `modules/workflow_review_agent.py`fork `goal_review_agent.py`),内部工具 `report_workflow_stage_status`pass/retry/completeactive 模式注入只读 run_command配置走 `resolve_deploy_config("workflow_review.json")` 回退链。
当前阶段 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. 通信与前端 ## 6. 通信与前端
- **轮询链路**现成sender 事件 → `session_data` 快照(`server/tasks/models.py` L763 模式)→ REST 轮询透传(`server/tasks/api.py` L269 模式)→ 前端 `taskPolling/lifecycle.ts` case 消费。 - **轮询链路**现成sender 事件 → `session_data` 快照 → REST 轮询透传 → 前端 `taskPolling/lifecycle.ts` case 消费。
- 新增事件:`workflow_progress` / `workflow_review_progress` / `workflow_completed` / `workflow_stopped` - 新增事件:`workflow_progress` / `workflow_review_progress` / `workflow_completed` / `workflow_stopped`,快照自带 `conversation_id`(对齐 goal 修复后的过滤机制)。
- socket 事件 data 必须带 `conversation_id`,前端按当前对话过滤,保证多对话不串。 - **激活**slash 菜单新增 `workflows` 模式(数据 `GET /api/workflows``POST /api/workflow/activate|deactivate`。
- **激活**`InputComposer.vue` slash 菜单新增 `workflows` 模式(数据 `GET /api/workflows`root 菜单加「工作流」入口;激活/退出走 REST`POST /api/workflow/activate|deactivate`)。 - **进度展示**:仿 goal 进度组件——当前阶段名 · 已完成阶段列表 · 审核状态 · 轮数。
- **进度展示**:仿 goal 进度组件——当前阶段 x/N · 阶段名 · 审核状态 · 轮数。
- **状态 API**`GET /api/workflow/status?conversation_id=` 供轮询/刷新恢复。 - **状态 API**`GET /api/workflow/status?conversation_id=` 供轮询/刷新恢复。
## 7. 新增工具与文件清单 ## 7. 可视化拖拽编辑器(设计讨论中)
| 项 | 类型 | 参照 | - **技术选型**Vue Flow项目为 Vue 3React Flow 的 Vue 移植API 同构)。
- **编辑对象**WORKFLOW.md frontmatter 的 stages 拓扑;节点 = 阶段,边 = next 候选路由;`position` 仅存坐标,与执行解耦。
- **与 AI 生成协同**AI `create_workflow` 生成归档 → 画布打开可视化/微调;画布编辑 → 序列化回 WORKFLOW.md。
- **页面形态与一期范围**:待定(见 §9
## 8. 新增工具与文件清单
| 项 | 类型 | 说明 |
|---|---|---| |---|---|---|
| `create_workflow` | AI 工具(生成并归档) | `create_skill` | | `create_workflow` | AI 工具 | 生成并校验归档(仿 create_skill |
| `read_workflow` | AI 工具(读定义) | `read_skill` | | `read_workflow` | AI 工具 | 读工作流定义(仿 read_skill |
| `report_workflow_stage_status` | 审核智能体内部工具 | `report_goal_status` | | `activate_workflow` / `deactivate_workflow` | AI 工具 | 开启/退出当前对话的工作流 |
| `modules/workflows_manager.py` | 校验/归档/目录合并 | `modules/skills_manager.py` | | `report_workflow_stage` | AI 工具 | **阶段汇报**:期间汇报触发审核推进;无后续阶段时即停止信号 |
| `modules/workflow_state_manager.py` | 对话级状态落盘 | `modules/goal_state_manager.py` | | `report_workflow_stage_status` | 审核智能体内部工具 | pass/retry/complete仿 report_goal_status |
| `server/workflow_flow.py` | 编排(启动/注入/审核/推进) | `server/goal_flow.py` | | `modules/workflows_manager.py` | 模块 | 校验/归档/目录合并(仿 skills_manager |
| `prompts/workflow.txt` | 提示词模板 | `prompts/` 现有模板 | | `modules/workflow_state_manager.py` | 模块 | 对话级状态落盘(对齐 goal_state_manager 对话级方案) |
| `server/tasks/workflows.py` | REST API列表/激活/状态) | `server/tasks/skills.py` | | `modules/workflow_review_agent.py` | 模块 | 阶段/整体审核 |
| `server/workflow_flow.py` | 模块 | 编排:激活/注入/汇报处理/推进/兜底提醒 |
| `server/tasks/workflows.py` | REST API | 列表/激活/状态/画布读写 |
| `prompts/workflow.txt` | 提示词 | 工作流上下文模板 |
## 8. 分期 ## 9. 待确认
- **一期(本次)**:格式 + 归档工具 + 提示词注入 + 阶段状态机与审核 + 轮询进度 + slash 菜单激活 + 对话级状态隔离。 - 可视化编辑器页面形态:独立路由页面(如 `/workflows` 库 + 编辑器)还是对话内弹层/抽屉?
- **二期**拖拽画布编辑器React Flow本质是 stages frontmatter 的可视化编辑器(坐标只作视觉)。 - 编辑器一期范围:完整拖拽编辑(增删节点/连线/改参数/保存)还是先做只读可视化 + 文本编辑?
- **三期**:分支/DAG、并行阶段、子工作流嵌套。 - 兜底提醒的容忍轮数(建议连续 2 轮无响应转空转停止)。
## 9. 待确认(实施前如需变更再讨论)
- 工作流与目标模式是否互斥激活(建议互斥,避免双「防停止」机制叠加)。
- `activate_workflow`/`deactivate_workflow` 是否做成 AI 工具slash 菜单人工激活为一期必须)。

328
modules/workflow_manager.py Normal file
View 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 frontmattersnake_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/dockerusers/<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
View File

@ -8,12 +8,17 @@
"name": "astrion-frontend", "name": "astrion-frontend",
"version": "4.1.0", "version": "4.1.0",
"dependencies": { "dependencies": {
"@dagrejs/dagre": "^3.1.1",
"@tiptap/extension-mention": "^3.23.6", "@tiptap/extension-mention": "^3.23.6",
"@tiptap/extension-placeholder": "^3.23.6", "@tiptap/extension-placeholder": "^3.23.6",
"@tiptap/starter-kit": "^3.23.6", "@tiptap/starter-kit": "^3.23.6",
"@tiptap/suggestion": "^3.23.6", "@tiptap/suggestion": "^3.23.6",
"@tiptap/vue-3": "^3.23.6", "@tiptap/vue-3": "^3.23.6",
"@types/html2canvas": "^0.5.35", "@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-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0", "@xterm/addon-web-links": "^0.12.0",
"diff": "^8.0.3", "diff": "^8.0.3",
@ -279,6 +284,21 @@
"@csstools/css-tokenizer": "^3.0.4" "@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": { "node_modules/@dual-bundle/import-meta-resolve": {
"version": "4.2.1", "version": "4.2.1",
"resolved": "https://registry.npmjs.org/@dual-bundle/import-meta-resolve/-/import-meta-resolve-4.2.1.tgz", "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==", "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
"license": "MIT" "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": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "6.21.0", "version": "6.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz",
@ -2670,6 +2696,56 @@
"path-browserify": "^1.0.1" "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": { "node_modules/@vue/compiler-core": {
"version": "3.5.35", "version": "3.5.35",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.35.tgz", "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.35.tgz",
@ -2881,6 +2957,94 @@
"integrity": "sha512-zSbjL7gRXwks2ZQLRGCajBtBXEOXW9Ddhn/HvSdrGkE2dqGnumzW8XtusRrxrE9LvqtiqDXQ+A60Hp6mvdYxfA==", "integrity": "sha512-zSbjL7gRXwks2ZQLRGCajBtBXEOXW9Ddhn/HvSdrGkE2dqGnumzW8XtusRrxrE9LvqtiqDXQ+A60Hp6mvdYxfA==",
"license": "MIT" "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": { "node_modules/@xterm/addon-fit": {
"version": "0.11.0", "version": "0.11.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", "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==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT" "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": { "node_modules/de-indent": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",

View File

@ -15,12 +15,17 @@
"cli:typecheck": "npm --prefix cli run typecheck" "cli:typecheck": "npm --prefix cli run typecheck"
}, },
"dependencies": { "dependencies": {
"@dagrejs/dagre": "^3.1.1",
"@tiptap/extension-mention": "^3.23.6", "@tiptap/extension-mention": "^3.23.6",
"@tiptap/extension-placeholder": "^3.23.6", "@tiptap/extension-placeholder": "^3.23.6",
"@tiptap/starter-kit": "^3.23.6", "@tiptap/starter-kit": "^3.23.6",
"@tiptap/suggestion": "^3.23.6", "@tiptap/suggestion": "^3.23.6",
"@tiptap/vue-3": "^3.23.6", "@tiptap/vue-3": "^3.23.6",
"@types/html2canvas": "^0.5.35", "@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-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0", "@xterm/addon-web-links": "^0.12.0",
"diff": "^8.0.3", "diff": "^8.0.3",

View File

@ -9,3 +9,4 @@ openai
cryptography cryptography
pillow pillow
websockets websockets
pyyaml>=6.0

View File

@ -37,6 +37,7 @@ from server.status import status_bp
from server.tasks import tasks_bp from server.tasks import tasks_bp
from server.api_v1 import api_v1_bp from server.api_v1 import api_v1_bp
from server.multi_agent import multi_agent_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.conversation_bootstrap import conversation_bootstrap_bp
from server.socket_handlers import socketio from server.socket_handlers import socketio
from server.security import attach_security_hooks from server.security import attach_security_hooks
@ -301,6 +302,7 @@ app.register_blueprint(status_bp)
app.register_blueprint(tasks_bp) app.register_blueprint(tasks_bp)
app.register_blueprint(api_v1_bp) app.register_blueprint(api_v1_bp)
app.register_blueprint(multi_agent_bp) app.register_blueprint(multi_agent_bp)
app.register_blueprint(workflow_page_bp)
app.register_blueprint(conversation_bootstrap_bp) app.register_blueprint(conversation_bootstrap_bp)
# 安全钩子CSRF 校验 + 响应头) # 安全钩子CSRF 校验 + 响应头)

96
server/workflow_page.py Normal file
View File

@ -0,0 +1,96 @@
"""工作流页面路由 + REST API。
- `/workflows` 工作流库列表页返回主 SPA 入口由前端 bootstrapRoute 识别路径
- `/workflow/<name>` 工作流编辑器页同上
- `/api/workflows` 系列工作流 CRUDWORKFLOW.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

View 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

View 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

View 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

View 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
View 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
View 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
View 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

View File

@ -96,6 +96,7 @@
@rename-workspace="handleRenameWorkspaceFromSidebar" @rename-workspace="handleRenameWorkspaceFromSidebar"
@pin-workspace="handlePinWorkspaceFromSidebar" @pin-workspace="handlePinWorkspaceFromSidebar"
@conversation-type-change="handleSidebarConversationTypeChange" @conversation-type-change="handleSidebarConversationTypeChange"
@open-workflows="openWorkflowsPage"
/> />
<main <main
@ -817,6 +818,9 @@
</div> </div>
</div> </div>
</transition> </transition>
<!-- 工作流编辑器 demo/workflows /workflow/<name> 全屏覆盖层 -->
<WorkflowDemoShell v-if="workflowDemoRoute" :route-path="workflowDemoRoute" />
</template> </template>
</AppShell> </AppShell>
</template> </template>

View File

@ -42,6 +42,9 @@ const NewUserTutorialPrompt = defineAsyncComponent(
const GoalProgressDialog = defineAsyncComponent( const GoalProgressDialog = defineAsyncComponent(
() => import('../components/overlay/GoalProgressDialog.vue') () => import('../components/overlay/GoalProgressDialog.vue')
); );
const WorkflowDemoShell = defineAsyncComponent(
() => import('../components/workflow/WorkflowDemoShell.vue')
);
export const appComponents = { export const appComponents = {
ChatArea, ChatArea,
@ -65,5 +68,6 @@ export const appComponents = {
PlanApprovalDialog, PlanApprovalDialog,
TutorialOverlay, TutorialOverlay,
NewUserTutorialPrompt, NewUserTutorialPrompt,
GoalProgressDialog GoalProgressDialog,
WorkflowDemoShell
}; };

View File

@ -92,7 +92,8 @@ export const loadMethods = {
queryOffset === 0 && queryOffset === 0 &&
!this.currentConversationId && !this.currentConversationId &&
cache.list.length > 0 && cache.list.length > 0 &&
!this.isExplicitNewConversationRoute() !this.isExplicitNewConversationRoute() &&
!this.isConversationIndependentRoute()
) { ) {
// 只有在初始化完成后,才自动加载第一个对话 // 只有在初始化完成后,才自动加载第一个对话
// 避免与 bootstrapRoute 冲突 // 避免与 bootstrapRoute 冲突

View File

@ -117,15 +117,20 @@ export const compressionMethods = {
messagesLen: Array.isArray(this.messages) ? this.messages.length : -1 messagesLen: Array.isArray(this.messages) ? this.messages.length : -1
}); });
// 显式新建对话路由(/new、/multiagent/new运行中的任务属于上一个对话 // 显式新建对话路由(/new、/multiagent/new与独立全屏路由工作流编辑器等
// 用户主动选择了空白页。这里不能接管任务/重放事件/恢复轮询—— // 前者是用户主动选择的空白页,后者不归对话体系。这里不能接管任务/重放事件/
// loadRunningTask(null) 会匹配任意运行中任务,一旦接管,该对话的 // 恢复轮询——loadRunningTask(null) 会匹配任意运行中任务,一旦接管,该对话的
// 用户输入/AI输出/工具调用会被全量重放到空白页(刷新也复现)。 // 用户输入/AI输出/工具调用会被全量重放到空白页/被覆盖的隐藏视图(刷新也复现),
// 压缩完成等回调还会 loadConversation 改写 URL。
// 侧边栏运行标识由 refreshRunningWorkspaceTasks 独立维护,不受影响。 // 侧边栏运行标识由 refreshRunningWorkspaceTasks 独立维护,不受影响。
const onIndependentRoute =
typeof this.isConversationIndependentRoute === 'function' &&
this.isConversationIndependentRoute();
if ( if (
!this.currentConversationId && !this.currentConversationId &&
typeof this.isExplicitNewConversationRoute === 'function' && ((typeof this.isExplicitNewConversationRoute === 'function' &&
this.isExplicitNewConversationRoute() this.isExplicitNewConversationRoute()) ||
onIndependentRoute)
) { ) {
restoreDebugLog('restore:skip-explicit-new-route', { restoreDebugLog('restore:skip-explicit-new-route', {
path: typeof window !== 'undefined' ? window.location.pathname : '' path: typeof window !== 'undefined' ? window.location.pathname : ''

View File

@ -71,17 +71,21 @@ export const lifecycleMethods = {
'shallow_compression', 'shallow_compression',
'compression_finished' 'compression_finished'
]); ]);
// 显式新建路由(/new、/multiagent/new上 currentConversationId 为空是常态: // 显式新建路由(/new、/multiagent/new与独立全屏路由工作流编辑器等
// 空 id 不等于“无归属”,此时任何携带 conversation_id 的事件都属于其他对话, // currentConversationId 为空是常态:空 id 不等于“无归属”,此时任何携带
// 必须丢弃,否则运行中对话的用户输入/AI输出/工具调用会渲染到空白页。 // conversation_id 的事件都属于其他对话,必须丢弃,否则运行中对话的
// 用户输入/AI输出/工具调用会渲染到空白页/被覆盖的隐藏视图。
const onExplicitNewRoute = const onExplicitNewRoute =
typeof this.isExplicitNewConversationRoute === 'function' && typeof this.isExplicitNewConversationRoute === 'function' &&
this.isExplicitNewConversationRoute(); this.isExplicitNewConversationRoute();
const onIndependentRoute =
typeof this.isConversationIndependentRoute === 'function' &&
this.isConversationIndependentRoute();
const eventConversationMismatch = !!( const eventConversationMismatch = !!(
eventData.conversation_id && eventData.conversation_id &&
(this.currentConversationId (this.currentConversationId
? eventData.conversation_id !== this.currentConversationId ? eventData.conversation_id !== this.currentConversationId
: onExplicitNewRoute) : onExplicitNewRoute || onIndependentRoute)
); );
if (!crossConversationAllowed.has(eventType) && eventConversationMismatch) { if (!crossConversationAllowed.has(eventType) && eventConversationMismatch) {
{ {

View File

@ -105,6 +105,8 @@ export const syncMethods = {
} }
}, },
handleConversationResolved(data: any) { handleConversationResolved(data: any) {
// 独立全屏路由(工作流编辑器等)下不恢复对话、不改写 URL
if (this.isConversationIndependentRoute()) return;
if (data && data.conversation_id) { if (data && data.conversation_id) {
this.currentConversationId = data.conversation_id; this.currentConversationId = data.conversation_id;
if (data.title) { if (data.title) {

View File

@ -30,7 +30,7 @@ export const routeMethods = {
this.titleReady = false; this.titleReady = false;
this.currentConversationTitle = ''; this.currentConversationTitle = '';
this.titleTypingText = ''; this.titleTypingText = '';
let path = window.location.pathname.replace(/^\/+/, ''); let path = window.location.pathname.replace(/^\/+|\/+$/g, '');
// 兼容重定向:旧多智能体路由统一收敛到裸路径。 // 兼容重定向:旧多智能体路由统一收敛到裸路径。
// 对话类型是 metadata 属性(创建时确定),不再是路由概念。 // 对话类型是 metadata 属性(创建时确定),不再是路由概念。
if (path === 'multiagent/new' || path === 'multiagent') { if (path === 'multiagent/new' || path === 'multiagent') {
@ -42,6 +42,21 @@ export const routeMethods = {
history.replaceState({}, '', `/${bareId}`); history.replaceState({}, '', `/${bareId}`);
path = 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()) { if (!path || this.isExplicitNewConversationRoute()) {
this.currentConversationId = null; this.currentConversationId = null;
this.currentConversationTitle = '新对话'; this.currentConversationTitle = '新对话';
@ -136,6 +151,25 @@ export const routeMethods = {
// 多智能体旧路由在 bootstrapRoute 已被重定向到 /new这里只判裸新建路由 // 多智能体旧路由在 bootstrapRoute 已被重定向到 /new这里只判裸新建路由
return normalizedPath === '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) { stripConversationPrefix(conversationId) {
if (!conversationId) return ''; if (!conversationId) return '';
return conversationId.startsWith('conv_') ? conversationId.slice(5) : conversationId; return conversationId.startsWith('conv_') ? conversationId.slice(5) : conversationId;

View File

@ -371,13 +371,15 @@ export const socketMethods = {
const currentPath = window.location.pathname.replace(/^\/+/, ''); const currentPath = window.location.pathname.replace(/^\/+/, '');
const isMultiAgentNewRoute = currentPath === 'multiagent/new' || currentPath === 'multiagent'; const isMultiAgentNewRoute = currentPath === 'multiagent/new' || currentPath === 'multiagent';
// 显式新建对话路由(/new、/multiagent/new永不自动恢复运行中的对话 // 显式新建对话路由(/new、/multiagent/new与独立全屏路由工作流编辑器等
// 用户主动点击"新建对话"就是明确要空白页,运行中任务可从侧边栏随时切回。 // 永不自动恢复运行中的对话:前者是用户明确要空白页,后者不归对话体系,
// 运行中任务可从侧边栏随时切回。
if ( if (
resumeConversationId && resumeConversationId &&
!this.currentConversationId && !this.currentConversationId &&
!isMultiAgentNewRoute && !isMultiAgentNewRoute &&
!isExplicitNewRoute !isExplicitNewRoute &&
!this.isConversationIndependentRoute()
) { ) {
this.skipConversationHistoryReload = true; this.skipConversationHistoryReload = true;
// 首次从状态恢复对话时,避免 socket 的 conversation_loaded 再次触发历史加载 // 首次从状态恢复对话时,避免 socket 的 conversation_loaded 再次触发历史加载

View File

@ -52,6 +52,8 @@ export function dataState() {
// 路由相关 // 路由相关
initialRouteResolved: false, initialRouteResolved: false,
dropToolEvents: false, dropToolEvents: false,
// 工作流编辑器 demo 路由('workflows' | 'workflow/<name>',空串 = 不在 demo 视图)
workflowDemoRoute: '',
// 当前打开对话的类型('normal' | 'multi_agent',创建时确定、不可变); // 当前打开对话的类型('normal' | 'multi_agent',创建时确定、不可变);
// 空对话态为 null。权威来源是对话 metadata.multi_agent_mode。 // 空对话态为 null。权威来源是对话 metadata.multi_agent_mode。
currentConversationType: null, currentConversationType: null,

View File

@ -94,6 +94,34 @@
</span> </span>
<span class="sidebar-nav-label">{{ workspaceKind === 'project' ? '项目' : '工作区' }}</span> <span class="sidebar-nav-label">{{ workspaceKind === 'project' ? '项目' : '工作区' }}</span>
</button> </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>
<div class="conversation-search"> <div class="conversation-search">
@ -751,6 +779,7 @@ const emit = defineEmits<{
(event: 'rename-workspace', payload: { workspaceId: string; label: string }): void; (event: 'rename-workspace', payload: { workspaceId: string; label: string }): void;
(event: 'pin-workspace', workspaceId: string): void; (event: 'pin-workspace', workspaceId: string): void;
(event: 'conversation-type-change', type: 'normal' | 'multi_agent'): void; (event: 'conversation-type-change', type: 'normal' | 'multi_agent'): void;
(event: 'open-workflows'): void;
}>(); }>();
const uiStore = useUiStore(); const uiStore = useUiStore();

View 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>

View 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 + 11 个空桩) */
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>

View 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>

File diff suppressed because it is too large Load Diff

View 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>

View File

@ -0,0 +1,127 @@
<template>
<div
class="review-node"
:class="{
'review-node--selected': selected,
'review-node--issue': data.hasIssue,
}"
>
<!-- 菱形衬底内联 SVG polygonclip-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>

View 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>

View 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, '删除工作流失败');
}

View File

@ -0,0 +1,692 @@
/**
*
*
*
* - start 1 1
* - end n 1
* - stageAI 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/conditionposition
* 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
* - 线 / 线 / 线
* - 线
* - 线//
* typeVue 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 };
}
}
}

View File

@ -788,13 +788,19 @@ export async function initializeLegacySocket(ctx: any) {
// 初始化期间不修改 currentConversationId避免与 bootstrapRoute 冲突 // 初始化期间不修改 currentConversationId避免与 bootstrapRoute 冲突
if (ctx.initialRouteResolved) { if (ctx.initialRouteResolved) {
// 独立全屏路由(工作流编辑器等)不归对话体系:不接管 id、不清消息、不改 URL
// 仅同步下方列表数据(标题更新/置顶/列表刷新)。
const onIndependentRoute =
typeof ctx.isConversationIndependentRoute === 'function' &&
ctx.isConversationIndependentRoute();
// 用户停留在显式新建对话页(/new、/multiagent/new不被其他对话的 // 用户停留在显式新建对话页(/new、/multiagent/new不被其他对话的
// 广播事件(如运行中任务的标题生成)拽走视图,仅同步列表数据。 // 广播事件(如运行中任务的标题生成)拽走视图,仅同步列表数据。
const stayOnNewRoute = const stayOnNewRoute =
!data.cleared && !data.cleared &&
!onIndependentRoute &&
typeof ctx.isExplicitNewConversationRoute === 'function' && typeof ctx.isExplicitNewConversationRoute === 'function' &&
ctx.isExplicitNewConversationRoute(); ctx.isExplicitNewConversationRoute();
if (!stayOnNewRoute) { if (!stayOnNewRoute && !onIndependentRoute) {
ctx.currentConversationId = data.conversation_id; ctx.currentConversationId = data.conversation_id;
ctx.currentConversationTitle = data.title || ''; ctx.currentConversationTitle = data.title || '';
} }
@ -809,8 +815,8 @@ export async function initializeLegacySocket(ctx: any) {
ctx.promoteConversationToTop(data.conversation_id); ctx.promoteConversationToTop(data.conversation_id);
if (data.cleared) { if (data.cleared && !onIndependentRoute) {
// 对话被清空 // 对话被清空(独立全屏路由下不动对话状态与 URL
ctx.logMessageState?.('socket:conversation_changed-clearing', { event: data }); ctx.logMessageState?.('socket:conversation_changed-clearing', { event: data });
ctx.messages = []; ctx.messages = [];
ctx.logMessageState?.('socket:conversation_changed-cleared', { event: data }); ctx.logMessageState?.('socket:conversation_changed-cleared', { event: data });
@ -834,8 +840,12 @@ export async function initializeLegacySocket(ctx: any) {
} }
const convId = data.conversation_id; const convId = data.conversation_id;
// 初始化期间不修改 currentConversationId避免与 bootstrapRoute 冲突 // 初始化期间不修改 currentConversationId避免与 bootstrapRoute 冲突;
if (ctx.initialRouteResolved) { // 独立全屏路由(工作流编辑器等)下不恢复对话、不改写 URL
const onIndependentRoute =
typeof ctx.isConversationIndependentRoute === 'function' &&
ctx.isConversationIndependentRoute();
if (ctx.initialRouteResolved && !onIndependentRoute) {
ctx.currentConversationId = convId; ctx.currentConversationId = convId;
if (data.title) { if (data.title) {
ctx.currentConversationTitle = data.title; ctx.currentConversationTitle = data.title;
@ -927,10 +937,14 @@ export async function initializeLegacySocket(ctx: any) {
const onExplicitNewRoute = const onExplicitNewRoute =
typeof ctx.isExplicitNewConversationRoute === 'function' && typeof ctx.isExplicitNewConversationRoute === 'function' &&
ctx.isExplicitNewConversationRoute(); ctx.isExplicitNewConversationRoute();
// 独立全屏路由(工作流编辑器等)同样不归对话体系,快照不得接管当前对话
const onIndependentRoute =
typeof ctx.isConversationIndependentRoute === 'function' &&
ctx.isConversationIndependentRoute();
// 只有在初始化完成后,才允许 status_update 修改 currentConversationId // 只有在初始化完成后,才允许 status_update 修改 currentConversationId
// 避免与 bootstrapRoute 冲突 // 避免与 bootstrapRoute 冲突
if (status.conversation && status.conversation.current_id) { if (status.conversation && status.conversation.current_id) {
if (ctx.initialRouteResolved && !ctx.currentConversationId && !onExplicitNewRoute) { if (ctx.initialRouteResolved && !ctx.currentConversationId && !onExplicitNewRoute && !onIndependentRoute) {
// 初始化完成且当前没有对话ID才设置 // 初始化完成且当前没有对话ID才设置
ctx.currentConversationId = status.conversation.current_id; ctx.currentConversationId = status.conversation.current_id;
} }

View File

@ -213,15 +213,15 @@
color-scheme: dark; color-scheme: dark;
/* 推理强度滑槽(深色下需可见灰,不用近黑 surface-muted */ /* 推理强度滑槽(深色下需可见灰,不用近黑 surface-muted */
--effort-track-bg: #3a3a3c; --effort-track-bg: #3a3a3c;
/* Surface 表面层级 */ /* Surface 表面层级(深色下层次向提亮方向拉开,禁近黑压暗,见 AGENTS.md §5.5-11 */
--surface-base: #1a1a1a; --surface-base: #1a1a1a;
--surface-panel: #1a1a1a; --surface-panel: #1a1a1a;
--surface-rail: #181818; --surface-rail: #181818;
--surface-sidebar: #181818; --surface-sidebar: #181818;
--surface-card: #0a0a0a; --surface-card: #262626;
--surface-raised: #1a1a1a; --surface-raised: #1a1a1a;
--surface-soft: #0f0f0f; --surface-soft: #212121;
--surface-muted: #141414; --surface-muted: #2b2b2b;
/* Text 文字 */ /* Text 文字 */
--text-primary: #ffffff; --text-primary: #ffffff;
--text-secondary: #a0a0a0; --text-secondary: #a0a0a0;

View File

@ -179,6 +179,11 @@
height: 18px; height: 18px;
} }
.workflow-icon svg {
width: 18px;
height: 18px;
}
.monitor-mode-btn.blocked { .monitor-mode-btn.blocked {
color: var(--text-tertiary); color: var(--text-tertiary);
opacity: 0.6; opacity: 0.6;

View File

@ -1,5 +1,6 @@
export const ICONS = Object.freeze({ export const ICONS = Object.freeze({
alignLeft: '/static/icons/align-left.svg', alignLeft: '/static/icons/align-left.svg',
arrowLeft: '/static/icons/arrow-left.svg',
bot: '/static/icons/bot.svg', bot: '/static/icons/bot.svg',
book: '/static/icons/book.svg', book: '/static/icons/book.svg',
brain: '/static/icons/brain.svg', brain: '/static/icons/brain.svg',
@ -9,6 +10,7 @@ export const ICONS = Object.freeze({
chatBubble: '/static/icons/chat-bubble.svg', chatBubble: '/static/icons/chat-bubble.svg',
messageQuestion: '/static/icons/message-circle-question-mark.svg', messageQuestion: '/static/icons/message-circle-question-mark.svg',
checkbox: '/static/icons/checkbox.svg', checkbox: '/static/icons/checkbox.svg',
chevronDown: '/static/icons/chevron-down.svg',
circleAlert: '/static/icons/circle-alert.svg', circleAlert: '/static/icons/circle-alert.svg',
clipboard: '/static/icons/clipboard.svg', clipboard: '/static/icons/clipboard.svg',
copy: '/static/icons/copy.svg', copy: '/static/icons/copy.svg',
@ -20,12 +22,14 @@ export const ICONS = Object.freeze({
folder: '/static/icons/folder.svg', folder: '/static/icons/folder.svg',
folderClosed: '/static/icons/folder-closed.svg', folderClosed: '/static/icons/folder-closed.svg',
folderOpen: '/static/icons/folder-open.svg', folderOpen: '/static/icons/folder-open.svg',
gitBranch: '/static/icons/git-branch.svg',
globe: '/static/icons/globe.svg', globe: '/static/icons/globe.svg',
hammer: '/static/icons/hammer.svg', hammer: '/static/icons/hammer.svg',
info: '/static/icons/info.svg', info: '/static/icons/info.svg',
laptop: '/static/icons/laptop.svg', laptop: '/static/icons/laptop.svg',
layers: '/static/icons/layers.svg', layers: '/static/icons/layers.svg',
keyboard: '/static/icons/keyboard.svg', keyboard: '/static/icons/keyboard.svg',
layoutGrid: '/static/icons/layout-grid.svg',
menu: '/static/icons/menu.svg', menu: '/static/icons/menu.svg',
mic: '/static/icons/mic.svg', mic: '/static/icons/mic.svg',
mcpLogo: '/static/icons/mcp-logo.svg', mcpLogo: '/static/icons/mcp-logo.svg',
@ -36,6 +40,8 @@ export const ICONS = Object.freeze({
notebookSearch: '/static/icons/notebook-search.svg', notebookSearch: '/static/icons/notebook-search.svg',
octagon: '/static/icons/octagon.svg', octagon: '/static/icons/octagon.svg',
pencil: '/static/icons/pencil.svg', pencil: '/static/icons/pencil.svg',
play: '/static/icons/play.svg',
plus: '/static/icons/plus.svg',
python: '/static/icons/python.svg', python: '/static/icons/python.svg',
recycle: '/static/icons/recycle.svg', recycle: '/static/icons/recycle.svg',
refreshCw: '/static/icons/refresh-cw.svg', refreshCw: '/static/icons/refresh-cw.svg',
@ -50,6 +56,7 @@ export const ICONS = Object.freeze({
bell: '/static/icons/bell.svg', bell: '/static/icons/bell.svg',
user: '/static/icons/user.svg', user: '/static/icons/user.svg',
userPen: '/static/icons/user-pen.svg', userPen: '/static/icons/user-pen.svg',
workflow: '/static/icons/workflow.svg',
wrench: '/static/icons/wrench.svg', wrench: '/static/icons/wrench.svg',
x: '/static/icons/x.svg', x: '/static/icons/x.svg',
zap: '/static/icons/zap.svg' zap: '/static/icons/zap.svg'

View 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子流程复合活动 | 内嵌 Subprocessembedded全局 Subprocess 通过 Call Activity调用活动粗边框引用 | 子流程内部有独立的起止事件;父流程 token 等待子流程完成。另有 **Event Subprocess**(虚线框,由事件触发,可中断/非中断)与 **Ad-hoc Subprocess**(波浪线标记,内部活动任意顺序/跳过) |
| | 标记Markers | Loop循环、Multiple Instance多实例可并行/串行、Compensation补偿 | 附着在任务/子流程上,扩展其执行语义 |
| **网关 Gateways** | 排他网关 ExclusiveXOR | — | **恰好走一条路径**(数据条件互斥时用);经典"是/否、通过/拒绝"决策 |
| | 并行网关 ParallelAND | — | **所有路径同时走**,合并时等待所有分支到达(漏掉 AND 合并会导致活动重复执行) |
| | 包容网关 InclusiveOR | — | **一条或多条**条件为真则都走,合并时等待所有激活分支 |
| | 基于事件网关 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-ELSEtrue/false 两出口) | **无专门审批节点**;人工介入通常靠「结束节点返回 + 对话式确认」或外部系统实现 | 可视化节点编排,节点输入输出引用(`{{node.output}}`)连接 |
| **n8n**(官方节点 + 第三方深度分析) | Trigger、AI Agent、LLM、IF / Switch / Merge、Wait等待、Form表单、Code / Function、HTTP Request、Sub-workflow、**Human in the LoopHuman 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.0Camunda/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人工介入节点、n8nHuman Review 节点、BPMNUser Task + 网关、LangGraphinterrupt 节点内暂停 + 条件边) |
### 方案 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. **业界共识**:从最严谨的 BPMNUser 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 ModificationcancelActivityInstance + 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 TasksTask 类型参考https://camunda.com/bpmn/reference/#activities (活动章节)
- Camunda Blog — Events: Basic Conceptscatching/throwing、边界事件、非中断事件https://camunda.com/bpmn/reference/#events
- Camunda Forum — "Go back to previous user task"(官方论坛:回退需 Process Instance Modification 的 cancelActivityInstance + startTransitionhttps://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
- StatelyXState 官方) — 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 — GatewaysXOR 网关条件表达式示例 `@@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 (第三方)
- MediumSebastian Lesser— BPMN Gateways Explainedhttps://medium.com/@sebastian.lesser/bpmn-gateways-explained-2fbea236b0fa (第三方)
- ProcessCamp — BPMN Elements Reference元素分类计数https://processcamp.io/bpmn-elements (第三方)
- Beyond Engineering — BPMN 2.0 Elementshttp://www.beyondengineering.io/bpmn-elements (第三方)
- 火山引擎开发者社区 — 从零开始学 Dify 工作流实现机制(节点类型/IF_ELSE 实现/edge_source_handlehttps://developer.volcengine.com/articles/7538284296277229609 (第三方)
- Dify 官网博客转述 — Dify Workflow 重磅上线核心节点LLM/工具/意图分类器/知识检索/代码/If-Elsehttps://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/代码/知识库/插件/条件/循环/变量/HTTPhttps://skillhub.cloud.tencent.com/skills/coze (第三方)
- REBUILD 帮助文档 — 审批流程(发起人/条件分支/审批人/抄送人节点、加签/转审/会签或签/限时/自由审批https://getrebuild.com/docs/admin/approval (第三方)
- FlyFlow 更新日志(节点新增:投票节点/办理节点等佐证国内审批流节点体系https://www.flyflow.cc/upgrade (第三方)
- ZenML Blog — LangGraph vs n8nn8n 两层 HITLWait 节点 + AI 工具调用级批准LangGraph interruptshttps://www.zenml.io/blog/langgraph-vs-n8n (第三方)
- Peliqan — LangGraph vs n8nHITL 对比、checkpointinghttps://peliqan.io/blog/langgraph-vs-n8n (第三方)
- LOW/CODE — n8n vs LangGraphn8n 节点/400+ 集成/HITL 暂停审批https://www.lowcode.agency/blog/n8n-vs-langgraph (第三方)
- TopsInfoSolutions — n8n vs LangGraphLangGraph checkpoint、HITL 暂停审批恢复https://www.topsinfosolutions.com/blog/n8n-vs-langgraph (第三方)
- jimmysong.io — Open Source AI Agent Platform ComparisonDify/Coze/n8n/LangGraph 平台对比https://jimmysong.io/blog/open-source-ai-agent-workflow-comparison (第三方)
- CustomJS — n8n Human-in-the-LoopWait 节点/webhook 恢复、审批表单局限https://www.customjs.space/blog/n8n-human-in-the-loop (第三方)
- GrowwStacks — Human in the Loop in n8n GuideHuman Review 节点approve-only / approve-and-disapprove、超时https://growwstacks.com/blog/human-in-the-loop-n8n-guide (第三方)
- nocodecreative.io — n8n Wait Node v2Wait 节点 + 子工作流 HITLhttps://blog.nocodecreative.io/n8n-v2-wait-node-hitl-sub-workflows (第三方)
- DEV Community — LangGraph Interrupts and Commandsget_approval 节点 + router 条件边示例https://dev.to/jamesbmour/interrupts-and-commands-in-langgraph-building-human-in-the-loop-workflows-4ngl (第三方)
- Future AGI — What is LangGraphStateGraph/节点/边/条件边/checkpoint/interrupthttps://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: Guardguard 守卫语义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未修改项目任何代码文件。

View 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: 结束
---
## 工作方式
先复现再分诊,按严重程度走对应修复轨道。
## 验证方式
热修必须有最小复现用例;常规修复必须补回归测试。
## 结束方式
回归审核通过后更新更新日志并归档。

View 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/ 目录并汇报完成。

View 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: 结束
---
## 工作方式
先理解再动手,禁止跳过验证。
## 验证方式
构建通过 + 相关测试通过。
## 结束方式
更新文档并汇报。

View 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 报告。