From d071292c81fc13f39c2253cf935a20690628c581 Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Fri, 21 Aug 2026 01:38:03 +0800 Subject: [PATCH] =?UTF-8?q?feat(workflow):=20=E5=B7=A5=E4=BD=9C=E6=B5=81?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E5=99=A8=E2=80=94=E2=80=94=E5=8F=AF=E8=A7=86?= =?UTF-8?q?=E5=8C=96=E7=94=BB=E5=B8=83=E7=BC=96=E8=BE=91=20+=20WORKFLOW.md?= =?UTF-8?q?=20=E8=90=BD=E7=9B=98=20+=20REST=20CRUD=20+=20=E4=BE=A7?= =?UTF-8?q?=E8=BE=B9=E6=A0=8F=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 编辑器:/workflows 库页 + /workflow/ 编辑器(Vue Flow 画布,开始/结束/阶段/审核/分支五类节点,白前进/蓝通过/红驳回三色语义连线,dagre 自动排版) - 落盘:modules/workflow_manager.py;host 存运行态根 workflows/,web/docker 存 users//personal/workflows/;源码树 workflows/ 为内置种子,用户库同名覆盖、内置不可删 - REST:GET/PUT/DELETE /api/workflows[/],保存时后端强制 error 级结构校验 - 路由根治:新增 isConversationIndependentRoute() 统一谓词(URL 派生),收敛全部 9 处对话接管/URL 写回判断点,根治 /workflows 下地址栏被拽回对话的问题 - 深色主题 token 提亮(surface-soft/card/muted 去近黑色阶) --- docs/workflow_feature_plan.md | 122 +- modules/workflow_manager.py | 328 ++++ package-lock.json | 269 +++ package.json | 5 + requirements.txt | 1 + server/app_legacy.py | 2 + server/workflow_page.py | 96 ++ static/icons/arrow-left.svg | 14 + static/icons/chevron-down.svg | 13 + static/icons/git-branch.svg | 16 + static/icons/layout-grid.svg | 16 + static/icons/play.svg | 13 + static/icons/plus.svg | 14 + static/icons/workflow.svg | 16 + static/src/App.vue | 4 + static/src/app/components.ts | 6 +- static/src/app/methods/conversation/load.ts | 3 +- .../app/methods/taskPolling/compression.ts | 17 +- .../src/app/methods/taskPolling/lifecycle.ts | 12 +- static/src/app/methods/taskPolling/sync.ts | 2 + static/src/app/methods/ui/route.ts | 36 +- static/src/app/methods/ui/socket.ts | 8 +- static/src/app/state.ts | 2 + .../sidebar/ConversationSidebar.vue | 29 + .../workflow/WorkflowBoundaryNode.vue | 116 ++ .../workflow/WorkflowBranchNode.vue | 142 ++ .../components/workflow/WorkflowDemoShell.vue | 234 +++ .../workflow/WorkflowEditorView.vue | 1478 +++++++++++++++++ .../workflow/WorkflowLibraryView.vue | 361 ++++ .../workflow/WorkflowReviewNode.vue | 127 ++ .../components/workflow/WorkflowStageNode.vue | 174 ++ static/src/components/workflow/api.ts | 55 + .../src/components/workflow/workflowModel.ts | 692 ++++++++ static/src/composables/useLegacySocket.ts | 26 +- static/src/styles/base/_tokens.scss | 8 +- .../components/sidebar/_conversation.scss | 5 + static/src/utils/icons.ts | 7 + .../paradigm_elements/paradigm_elements.md | 280 ++++ workflows/bug-fix-triage/WORKFLOW.md | 85 + workflows/code-review-pipeline/WORKFLOW.md | 56 + workflows/feature-development/WORKFLOW.md | 69 + workflows/research-report/WORKFLOW.md | 62 + 42 files changed, 4950 insertions(+), 71 deletions(-) create mode 100644 modules/workflow_manager.py create mode 100644 server/workflow_page.py create mode 100644 static/icons/arrow-left.svg create mode 100644 static/icons/chevron-down.svg create mode 100644 static/icons/git-branch.svg create mode 100644 static/icons/layout-grid.svg create mode 100644 static/icons/play.svg create mode 100644 static/icons/plus.svg create mode 100644 static/icons/workflow.svg create mode 100644 static/src/components/workflow/WorkflowBoundaryNode.vue create mode 100644 static/src/components/workflow/WorkflowBranchNode.vue create mode 100644 static/src/components/workflow/WorkflowDemoShell.vue create mode 100644 static/src/components/workflow/WorkflowEditorView.vue create mode 100644 static/src/components/workflow/WorkflowLibraryView.vue create mode 100644 static/src/components/workflow/WorkflowReviewNode.vue create mode 100644 static/src/components/workflow/WorkflowStageNode.vue create mode 100644 static/src/components/workflow/api.ts create mode 100644 static/src/components/workflow/workflowModel.ts create mode 100644 workflow_research/paradigm_elements/paradigm_elements.md create mode 100644 workflows/bug-fix-triage/WORKFLOW.md create mode 100644 workflows/code-review-pipeline/WORKFLOW.md create mode 100644 workflows/feature-development/WORKFLOW.md create mode 100644 workflows/research-report/WORKFLOW.md diff --git a/docs/workflow_feature_plan.md b/docs/workflow_feature_plan.md index dd0235b8..107542cd 100644 --- a/docs/workflow_feature_plan.md +++ b/docs/workflow_feature_plan.md @@ -1,24 +1,28 @@ # 工作流(Workflow)功能设计方案 -> 状态:设计已讨论定稿(2026-08-18),待实施。 +> 状态:设计讨论中(2026-08-18 更新,纳入阶段汇报工具 / 非线性路由 / 可视化编辑决策)。 > 调研依据:`workflow_research/external_products/agentic_workflow_research.md`(外部产品)、`workflow_research/code_mount_points/code_mount_points.md`(代码挂载点)。 ## 1. 功能定义 用户把「一套既定流程:工作方式 → 验证方式 → 结束方式」存为一个**工作流**(workflow),之后在任何普通对话中激活它,让主智能体复用现有循环与执行引擎按流程推进;阶段结束/整体结束时可由审核智能体软审核。 -核心原则(来自外部调研共识):**结构在边上,自主在节点内**——工作流只约束阶段顺序与进出契约,阶段内主智能体完全自主(自由调工具、跑脚本)。 +核心原则(外部调研共识):**结构在边上,自主在节点内**——工作流只约束阶段拓扑与进出契约,阶段内主智能体完全自主(自由调工具、跑脚本)。 ## 2. 已拍板的设计决策 | 决策点 | 结论 | |---|---| | 运行方式 | **不做独立模式/特殊 URL/新对话类型**,只能在正常对话中激活 | -| 激活入口 | **/ 菜单(slash 菜单)**,新增 workflows 模式选择要激活的工作流(仿 skills 模式) | +| 激活入口 | **/ 菜单(slash 菜单)** 人工激活 + **AI 工具 `activate_workflow`** 自主激活 | +| 阶段边界 | **显式化**:AI 调**阶段汇报工具**声明阶段完成(期间汇报触发审核与推进;无后续阶段时该汇报即停止信号) | +| 阶段模型 | **非线性**:阶段间为候选路由集(结构约束候选、AI 自主选择、审核把关),非纯线性 | +| 可视化编辑 | 拖拽画布编辑器(类似 ComfyUI 流程图)纳入设计范围,编辑对象是 WORKFLOW.md 的阶段拓扑 | +| 与目标模式关系 | **不互斥**:工作流可以是目标模式中的一个小步骤;目标模式后续将重做,本期不考虑两者冲突 | +| 审核配置 | **独立**:`workflow_review.json`(走 resolve_deploy_config 回退链)+ frontmatter `review_mode` 自声明 | | 前后端通信 | **轮询**(复用 task 轮询 session_data 快照链路,不新增 websocket 通道) | | 状态隔离 | **对话级状态**,多对话可同时激活不同工作流,进度互不串扰 | -| 审核机制 | 复用审核智能体架构(`goal_review_agent.py` 模式),审核智能体可调用只读 run_command 取证 | -| 硬校验 | 不做步骤级硬校验;只做归档时结构校验(frontmatter 合法、阶段 id 唯一等) | +| 硬校验 | 不做步骤级硬校验;归档时只做结构校验(frontmatter 合法、阶段 id 唯一、路由目标存在) | | 存储形态 | 工作流文件夹 = `WORKFLOW.md`(frontmatter 结构 + 自然语言正文)+ `scripts/`(已验证脚本)+ `references/`(可选),与 skill 对齐 | ## 3. 存储与落盘位置 @@ -28,11 +32,11 @@ | host | `~/.astrion/astrion/host/workflows/` | 统一,不按用户拆(对齐 `CUSTOM_SKILLS_DIR`) | | docker/web | `users//personal/workflows/` | 每用户私有,多项目共享(对齐 `infer_private_skills_dir`) | -- 源码树 `workflows/` 只放内置示例种子,双源合并(对齐 `agentskills/` + `CUSTOM_SKILLS_DIR`)。 +- 源码树 `workflows/` 只放内置示例种子,双源合并。 - 归档工具 `create_workflow`:校验 → `shutil.move` → 已存在拒绝覆盖(复用 `archive_skill_directory` 模式)。 -- 运行时状态:`{workspace.data_dir}/workflow_states/.json`(对话级,压缩 handoff 点处理 key 迁移)。 +- 运行时状态:`{workspace.data_dir}/workflow_states/.json`(对话级,对齐已修复的 goal 对话级方案)。 -## 4. WORKFLOW.md 格式(草案,实施时可微调) +## 4. WORKFLOW.md 格式(非线性候选路由) ```markdown --- @@ -40,70 +44,98 @@ name: code-review-pipeline description: 代码评审标准流程 review_mode: active # 审核智能体是否可调用只读 run_command 取证 max_stage_rounds: 20 # 单阶段最大轮数,防死循环 +entry: explore # 入口阶段 stages: - id: explore name: 代码探索 goal: 理解改动范围与相关模块 review: false + next: [review] # 候选路由集:AI 汇报时从中选择下一站 - id: review name: 逐项评审 goal: 按 checklist 评审每个文件 - review: true + review: true # 阶段汇报先触发审核 review_prompt: 检查是否遗漏边界条件和安全问题 + next: [report, explore] # 审核通过后可进报告,也可回探索补充 - id: report name: 输出报告 goal: 生成结构化评审报告 review: true + next: [] # 空 = 终点,汇报即触发整体结束 end_conditions: 报告落盘且审核通过 --- # 工作方式 / 验证方式 / 结束方式(自然语言正文,随阶段上下文注入) ``` -- 阶段默认线性推进;`on_pass` / `on_fail` 可跳转指定阶段(一期线性+有限跳转,不做完整 DAG)。 -- 结构校验在归档时做(name/description/stages 存在、id 唯一、跳转目标存在);内容质量交给审核智能体。 +- **路由语义**:`next` 是候选集(结构约束);AI 在阶段汇报工具中传 `next_stage_id` 自主选择;审核智能体可否决路由选择(打回时附带建议去向)。 +- **结构校验**(归档时):name/description/entry/stages 齐全、id 唯一、next 引用存在、entry 可达终点。 +- `position` 字段(可选)仅记录画布坐标,供可视化编辑器使用,不影响执行。 -## 5. 运行时机制(全部复用现有引擎) +## 5. 运行时机制(复用现有引擎 + 显式阶段边界) -- **主循环不新增**:复用 `process_message_task` 门闸 + `handle_task_with_sender` 迭代。 -- **拦截点**:`server/chat_flow_task_main.py` 的 `if not tool_calls:` 分支,与 goal 分支平排新增 workflow 分支: - - 当前阶段 `review=false` → 推进下一阶段,注入新阶段上下文,`continue` 主循环 - - `review=true` → `WorkflowReviewAgent` 审核 → pass 推进 / retry 则 `inject_runtime_user_message` 续命 - - 最后阶段通过 → 整体结束审核 → `mark_done` - - 边界保护:`max_stage_rounds`、空转保护(仿 `REASON_IDLE_NO_TOOL`)、用户取消 -- **压缩维持**:状态落盘 + `handle_task_with_sender` 入口检查活跃状态并重注入当前阶段上下文(对齐 goal 的 `inject_goal_prompt` 重注入模式)。 -- **编排模块**:新增 `server/workflow_flow.py`(仿 `server/goal_flow.py`),减少对主循环侵入。 -- **审核智能体**:新增 `modules/workflow_review_agent.py`(fork `goal_review_agent.py`),内部工具 `report_workflow_stage_status`(pass/retry/complete),active 模式注入只读 run_command;配置走 `resolve_deploy_config("workflow_review.json")` 回退链。 +### 主路径:阶段汇报工具驱动 + +``` +对话激活工作流 → 注入入口阶段上下文(goal + 工作方式 + 候选路由) + ↓ +主智能体正常跑(现有引擎不变,自由调工具、跑 scripts/) + ↓ +AI 调 report_workflow_stage(summary, next_stage_id?) ← 阶段边界显式声明 + ↓ +当前阶段 review=true?──否──→ 校验 next_stage_id 在候选集 → 推进,注入新阶段上下文 + │是 + ↓ +WorkflowReviewAgent 审核(active 模式可只读取证) + ├─ pass → 推进(next 为空 → 整体结束审核 → done,停止) + └─ retry → 工具结果返回整改反馈,本阶段继续 +``` + +### 兜底:无 tool_calls 拦截(降级为提醒) + +主循环 `if not tool_calls:` 分支仍保留工作流检查,但语义降为**提醒**:工作流活跃且当前阶段有产出却未汇报时,注入「你还有进行中的工作流阶段 X,请用阶段汇报工具汇报或继续推进」提示并 continue 一轮;连续提醒无响应则按空转保护停止。阶段推进的主路径永远是显式汇报。 + +### 其他 + +- **结束**三种:汇报至终点点(done)、撞边界(max_stage_rounds / 空转)、用户取消(slash 菜单或 deactivate)。 +- **压缩维持**:状态落盘 + `handle_task_with_sender` 入口重注入当前阶段上下文(对齐 goal 重注入模式)。 +- **编排模块**:新增 `server/workflow_flow.py`(仿 `server/goal_flow.py`)。 +- **审核智能体**:`modules/workflow_review_agent.py`(fork `goal_review_agent.py`),内部工具 `report_workflow_stage_status`(pass/retry/complete);配置 `resolve_deploy_config("workflow_review.json")`。 +- **与目标模式并存**:不互斥;目标模式后续重做时统一考虑两者关系。 ## 6. 通信与前端 -- **轮询链路**(现成):sender 事件 → `session_data` 快照(`server/tasks/models.py` L763 模式)→ REST 轮询透传(`server/tasks/api.py` L269 模式)→ 前端 `taskPolling/lifecycle.ts` case 消费。 - - 新增事件:`workflow_progress` / `workflow_review_progress` / `workflow_completed` / `workflow_stopped`。 - - socket 事件 data 必须带 `conversation_id`,前端按当前对话过滤,保证多对话不串。 -- **激活**:`InputComposer.vue` slash 菜单新增 `workflows` 模式(数据 `GET /api/workflows`),root 菜单加「工作流」入口;激活/退出走 REST(`POST /api/workflow/activate|deactivate`)。 -- **进度展示**:仿 goal 进度组件——当前阶段 x/N · 阶段名 · 审核状态 · 轮数。 +- **轮询链路**(现成):sender 事件 → `session_data` 快照 → REST 轮询透传 → 前端 `taskPolling/lifecycle.ts` case 消费。 + - 新增事件:`workflow_progress` / `workflow_review_progress` / `workflow_completed` / `workflow_stopped`,快照自带 `conversation_id`(对齐 goal 修复后的过滤机制)。 +- **激活**:slash 菜单新增 `workflows` 模式(数据 `GET /api/workflows`);`POST /api/workflow/activate|deactivate`。 +- **进度展示**:仿 goal 进度组件——当前阶段名 · 已完成阶段列表 · 审核状态 · 轮数。 - **状态 API**:`GET /api/workflow/status?conversation_id=` 供轮询/刷新恢复。 -## 7. 新增工具与文件清单 +## 7. 可视化拖拽编辑器(设计讨论中) -| 项 | 类型 | 参照 | +- **技术选型**:Vue Flow(项目为 Vue 3,React Flow 的 Vue 移植,API 同构)。 +- **编辑对象**:WORKFLOW.md frontmatter 的 stages 拓扑;节点 = 阶段,边 = next 候选路由;`position` 仅存坐标,与执行解耦。 +- **与 AI 生成协同**:AI `create_workflow` 生成归档 → 画布打开可视化/微调;画布编辑 → 序列化回 WORKFLOW.md。 +- **页面形态与一期范围**:待定(见 §9)。 + +## 8. 新增工具与文件清单 + +| 项 | 类型 | 说明 | |---|---|---| -| `create_workflow` | AI 工具(生成并归档) | `create_skill` | -| `read_workflow` | AI 工具(读定义) | `read_skill` | -| `report_workflow_stage_status` | 审核智能体内部工具 | `report_goal_status` | -| `modules/workflows_manager.py` | 校验/归档/目录合并 | `modules/skills_manager.py` | -| `modules/workflow_state_manager.py` | 对话级状态落盘 | `modules/goal_state_manager.py` | -| `server/workflow_flow.py` | 编排(启动/注入/审核/推进) | `server/goal_flow.py` | -| `prompts/workflow.txt` | 提示词模板 | `prompts/` 现有模板 | -| `server/tasks/workflows.py` | REST API(列表/激活/状态) | `server/tasks/skills.py` | +| `create_workflow` | AI 工具 | 生成并校验归档(仿 create_skill) | +| `read_workflow` | AI 工具 | 读工作流定义(仿 read_skill) | +| `activate_workflow` / `deactivate_workflow` | AI 工具 | 开启/退出当前对话的工作流 | +| `report_workflow_stage` | AI 工具 | **阶段汇报**:期间汇报触发审核推进;无后续阶段时即停止信号 | +| `report_workflow_stage_status` | 审核智能体内部工具 | pass/retry/complete(仿 report_goal_status) | +| `modules/workflows_manager.py` | 模块 | 校验/归档/目录合并(仿 skills_manager) | +| `modules/workflow_state_manager.py` | 模块 | 对话级状态落盘(对齐 goal_state_manager 对话级方案) | +| `modules/workflow_review_agent.py` | 模块 | 阶段/整体审核 | +| `server/workflow_flow.py` | 模块 | 编排:激活/注入/汇报处理/推进/兜底提醒 | +| `server/tasks/workflows.py` | REST API | 列表/激活/状态/画布读写 | +| `prompts/workflow.txt` | 提示词 | 工作流上下文模板 | -## 8. 分期 +## 9. 待确认 -- **一期(本次)**:格式 + 归档工具 + 提示词注入 + 阶段状态机与审核 + 轮询进度 + slash 菜单激活 + 对话级状态隔离。 -- **二期**:拖拽画布编辑器(React Flow),本质是 stages frontmatter 的可视化编辑器(坐标只作视觉)。 -- **三期**:分支/DAG、并行阶段、子工作流嵌套。 - -## 9. 待确认(实施前如需变更再讨论) - -- 工作流与目标模式是否互斥激活(建议互斥,避免双「防停止」机制叠加)。 -- `activate_workflow`/`deactivate_workflow` 是否做成 AI 工具(slash 菜单人工激活为一期必须)。 +- 可视化编辑器页面形态:独立路由页面(如 `/workflows` 库 + 编辑器)还是对话内弹层/抽屉? +- 编辑器一期范围:完整拖拽编辑(增删节点/连线/改参数/保存)还是先做只读可视化 + 文本编辑? +- 兜底提醒的容忍轮数(建议连续 2 轮无响应转空转停止)。 diff --git a/modules/workflow_manager.py b/modules/workflow_manager.py new file mode 100644 index 00000000..b81c1595 --- /dev/null +++ b/modules/workflow_manager.py @@ -0,0 +1,328 @@ +"""工作流(Workflow)存储管理:WORKFLOW.md 的加载、保存、列举、删除。 + +存储布局(对齐 skills 的双源合并模式): +- 内置示例:源码树 ``workflows//WORKFLOW.md``(只读种子) +- 用户库:host 模式 ``/host/workflows/``;web/docker 模式 + ``users//personal/workflows/``(从 workspace data_dir 推断) + +文件格式:YAML frontmatter(snake_case 结构)+ markdown 正文(body)。 +API 层传输使用 camelCase dict,本模块负责双向转换。 +""" + +from __future__ import annotations + +import os +import re +import shutil +import tempfile +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +import yaml + +from config.paths import CUSTOM_SKILLS_DIR, IS_HOST_MODE + +# 内置示例种子目录(源码树 workflows/) +BUILTIN_WORKFLOWS_DIR = Path(__file__).resolve().parent.parent / "workflows" + +WORKFLOW_FILENAME = "WORKFLOW.md" + +# host 模式用户库:与 CUSTOM_SKILLS_DIR 平级(//workflows) +CUSTOM_WORKFLOWS_DIR = str(Path(CUSTOM_SKILLS_DIR).parent / "workflows") + +_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$") + + +# ---------------------------------------------------------------- 路径推断 + + +def infer_user_workflows_dir(data_dir: str | Path | None) -> Optional[Path]: + """从 workspace data_dir 推断用户工作流库目录(对齐 infer_private_skills_dir)。 + + host 模式:统一运行态根下的 workflows/,不按用户拆分。 + web/docker:users//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) diff --git a/package-lock.json b/package-lock.json index a537e3bc..c621c9cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,12 +8,17 @@ "name": "astrion-frontend", "version": "4.1.0", "dependencies": { + "@dagrejs/dagre": "^3.1.1", "@tiptap/extension-mention": "^3.23.6", "@tiptap/extension-placeholder": "^3.23.6", "@tiptap/starter-kit": "^3.23.6", "@tiptap/suggestion": "^3.23.6", "@tiptap/vue-3": "^3.23.6", "@types/html2canvas": "^0.5.35", + "@vue-flow/background": "^1.3.2", + "@vue-flow/controls": "^1.1.3", + "@vue-flow/core": "^1.48.2", + "@vue-flow/minimap": "^1.5.4", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-web-links": "^0.12.0", "diff": "^8.0.3", @@ -279,6 +284,21 @@ "@csstools/css-tokenizer": "^3.0.4" } }, + "node_modules/@dagrejs/dagre": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-3.1.1.tgz", + "integrity": "sha512-zroZB1dFOFiGgv4Xcrn1DckB1o4aOikPqD2NDQPV0WM//CXGcS6xiD0rNkqHmw6FEg4tabt4nxPLwgCWT+Vb2A==", + "license": "MIT", + "dependencies": { + "@dagrejs/graphlib": "4.0.5" + } + }, + "node_modules/@dagrejs/graphlib": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@dagrejs/graphlib/-/graphlib-4.0.5.tgz", + "integrity": "sha512-7xrBTqIts3o+PMUZX97wSc+7TUbW+/rULzGNCTP6yooNVDXbzw4Wutg/H/xOutTB/c/k0YqOAavgPh4/Zk9PFA==", + "license": "MIT" + }, "node_modules/@dual-bundle/import-meta-resolve": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/@dual-bundle/import-meta-resolve/-/import-meta-resolve-4.2.1.tgz", @@ -2421,6 +2441,12 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.20", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz", + "integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz", @@ -2670,6 +2696,56 @@ "path-browserify": "^1.0.1" } }, + "node_modules/@vue-flow/background": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@vue-flow/background/-/background-1.3.2.tgz", + "integrity": "sha512-eJPhDcLj1wEo45bBoqTXw1uhl0yK2RaQGnEINqvvBsAFKh/camHJd5NPmOdS1w+M9lggc9igUewxaEd3iCQX2w==", + "license": "MIT", + "peerDependencies": { + "@vue-flow/core": "^1.23.0", + "vue": "^3.3.0" + } + }, + "node_modules/@vue-flow/controls": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@vue-flow/controls/-/controls-1.1.3.tgz", + "integrity": "sha512-XCf+G+jCvaWURdFlZmOjifZGw3XMhN5hHlfMGkWh9xot+9nH9gdTZtn+ldIJKtarg3B21iyHU8JjKDhYcB6JMw==", + "license": "MIT", + "peerDependencies": { + "@vue-flow/core": "^1.23.0", + "vue": "^3.3.0" + } + }, + "node_modules/@vue-flow/core": { + "version": "1.48.2", + "resolved": "https://registry.npmjs.org/@vue-flow/core/-/core-1.48.2.tgz", + "integrity": "sha512-raxhgKWE+G/mcEvXJjGFUDYW9rAI3GOtiHR3ZkNpwBWuIaCC1EYiBmKGwJOoNzVFgwO7COgErnK7i08i287AFA==", + "license": "MIT", + "dependencies": { + "@vueuse/core": "^10.5.0", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + }, + "peerDependencies": { + "vue": "^3.3.0" + } + }, + "node_modules/@vue-flow/minimap": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@vue-flow/minimap/-/minimap-1.5.4.tgz", + "integrity": "sha512-l4C+XTAXnRxsRpUdN7cAVFBennC1sVRzq4bDSpVK+ag7tdMczAnhFYGgbLkUw3v3sY6gokyWwMl8CDonp8eB2g==", + "license": "MIT", + "dependencies": { + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + }, + "peerDependencies": { + "@vue-flow/core": "^1.23.0", + "vue": "^3.3.0" + } + }, "node_modules/@vue/compiler-core": { "version": "3.5.35", "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.35.tgz", @@ -2881,6 +2957,94 @@ "integrity": "sha512-zSbjL7gRXwks2ZQLRGCajBtBXEOXW9Ddhn/HvSdrGkE2dqGnumzW8XtusRrxrE9LvqtiqDXQ+A60Hp6mvdYxfA==", "license": "MIT" }, + "node_modules/@vueuse/core": { + "version": "10.11.1", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-10.11.1.tgz", + "integrity": "sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.20", + "@vueuse/metadata": "10.11.1", + "@vueuse/shared": "10.11.1", + "vue-demi": ">=0.14.8" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/core/node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/@vueuse/metadata": { + "version": "10.11.1", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-10.11.1.tgz", + "integrity": "sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "10.11.1", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-10.11.1.tgz", + "integrity": "sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==", + "license": "MIT", + "dependencies": { + "vue-demi": ">=0.14.8" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared/node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, "node_modules/@xterm/addon-fit": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", @@ -3335,6 +3499,111 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/de-indent": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", diff --git a/package.json b/package.json index 89101463..57b6952c 100644 --- a/package.json +++ b/package.json @@ -15,12 +15,17 @@ "cli:typecheck": "npm --prefix cli run typecheck" }, "dependencies": { + "@dagrejs/dagre": "^3.1.1", "@tiptap/extension-mention": "^3.23.6", "@tiptap/extension-placeholder": "^3.23.6", "@tiptap/starter-kit": "^3.23.6", "@tiptap/suggestion": "^3.23.6", "@tiptap/vue-3": "^3.23.6", "@types/html2canvas": "^0.5.35", + "@vue-flow/background": "^1.3.2", + "@vue-flow/controls": "^1.1.3", + "@vue-flow/core": "^1.48.2", + "@vue-flow/minimap": "^1.5.4", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-web-links": "^0.12.0", "diff": "^8.0.3", diff --git a/requirements.txt b/requirements.txt index 7a4667b3..fd2fa577 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,3 +9,4 @@ openai cryptography pillow websockets +pyyaml>=6.0 diff --git a/server/app_legacy.py b/server/app_legacy.py index 7e617e55..2fea6c52 100644 --- a/server/app_legacy.py +++ b/server/app_legacy.py @@ -37,6 +37,7 @@ from server.status import status_bp from server.tasks import tasks_bp from server.api_v1 import api_v1_bp from server.multi_agent import multi_agent_bp +from server.workflow_page import workflow_page_bp from server.conversation_bootstrap import conversation_bootstrap_bp from server.socket_handlers import socketio from server.security import attach_security_hooks @@ -301,6 +302,7 @@ app.register_blueprint(status_bp) app.register_blueprint(tasks_bp) app.register_blueprint(api_v1_bp) app.register_blueprint(multi_agent_bp) +app.register_blueprint(workflow_page_bp) app.register_blueprint(conversation_bootstrap_bp) # 安全钩子(CSRF 校验 + 响应头) diff --git a/server/workflow_page.py b/server/workflow_page.py new file mode 100644 index 00000000..6171a830 --- /dev/null +++ b/server/workflow_page.py @@ -0,0 +1,96 @@ +"""工作流页面路由 + REST API。 + +- `/workflows` 工作流库列表页,返回主 SPA 入口,由前端 bootstrapRoute 识别路径 +- `/workflow/` 工作流编辑器页,同上 +- `/api/workflows` 系列:工作流 CRUD(WORKFLOW.md 落盘,见 modules/workflow_manager.py) +""" +from __future__ import annotations + +from flask import Blueprint, current_app, jsonify, request + +from modules.workflow_manager import ( + delete_workflow, + list_workflows, + load_workflow, + save_workflow, +) +from server.auth_helpers import api_login_required, login_required +from server.context import with_terminal + +workflow_page_bp = Blueprint("workflow_page", __name__) + + +@workflow_page_bp.route("/workflows") +@login_required +def workflow_library_page(): + """工作流库入口,返回与 /new 相同的 SPA index.html。""" + return current_app.send_static_file("index.html") + + +@workflow_page_bp.route("/workflow/") +@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/", 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/", 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/", 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 diff --git a/static/icons/arrow-left.svg b/static/icons/arrow-left.svg new file mode 100644 index 00000000..b40bff85 --- /dev/null +++ b/static/icons/arrow-left.svg @@ -0,0 +1,14 @@ + + + + diff --git a/static/icons/chevron-down.svg b/static/icons/chevron-down.svg new file mode 100644 index 00000000..e70d30b8 --- /dev/null +++ b/static/icons/chevron-down.svg @@ -0,0 +1,13 @@ + + + diff --git a/static/icons/git-branch.svg b/static/icons/git-branch.svg new file mode 100644 index 00000000..f916ff40 --- /dev/null +++ b/static/icons/git-branch.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/static/icons/layout-grid.svg b/static/icons/layout-grid.svg new file mode 100644 index 00000000..f8eca3a7 --- /dev/null +++ b/static/icons/layout-grid.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/static/icons/play.svg b/static/icons/play.svg new file mode 100644 index 00000000..5bd511c1 --- /dev/null +++ b/static/icons/play.svg @@ -0,0 +1,13 @@ + + + diff --git a/static/icons/plus.svg b/static/icons/plus.svg new file mode 100644 index 00000000..41f92bb9 --- /dev/null +++ b/static/icons/plus.svg @@ -0,0 +1,14 @@ + + + + diff --git a/static/icons/workflow.svg b/static/icons/workflow.svg new file mode 100644 index 00000000..17b5c162 --- /dev/null +++ b/static/icons/workflow.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/static/src/App.vue b/static/src/App.vue index b0509720..21524b13 100644 --- a/static/src/App.vue +++ b/static/src/App.vue @@ -96,6 +96,7 @@ @rename-workspace="handleRenameWorkspaceFromSidebar" @pin-workspace="handlePinWorkspaceFromSidebar" @conversation-type-change="handleSidebarConversationTypeChange" + @open-workflows="openWorkflowsPage" />
+ + + diff --git a/static/src/app/components.ts b/static/src/app/components.ts index b6d9ca3c..566fb6c7 100644 --- a/static/src/app/components.ts +++ b/static/src/app/components.ts @@ -42,6 +42,9 @@ const NewUserTutorialPrompt = defineAsyncComponent( const GoalProgressDialog = defineAsyncComponent( () => import('../components/overlay/GoalProgressDialog.vue') ); +const WorkflowDemoShell = defineAsyncComponent( + () => import('../components/workflow/WorkflowDemoShell.vue') +); export const appComponents = { ChatArea, @@ -65,5 +68,6 @@ export const appComponents = { PlanApprovalDialog, TutorialOverlay, NewUserTutorialPrompt, - GoalProgressDialog + GoalProgressDialog, + WorkflowDemoShell }; diff --git a/static/src/app/methods/conversation/load.ts b/static/src/app/methods/conversation/load.ts index 9005de17..175e560a 100644 --- a/static/src/app/methods/conversation/load.ts +++ b/static/src/app/methods/conversation/load.ts @@ -92,7 +92,8 @@ export const loadMethods = { queryOffset === 0 && !this.currentConversationId && cache.list.length > 0 && - !this.isExplicitNewConversationRoute() + !this.isExplicitNewConversationRoute() && + !this.isConversationIndependentRoute() ) { // 只有在初始化完成后,才自动加载第一个对话 // 避免与 bootstrapRoute 冲突 diff --git a/static/src/app/methods/taskPolling/compression.ts b/static/src/app/methods/taskPolling/compression.ts index 4a7f1114..299f02b5 100644 --- a/static/src/app/methods/taskPolling/compression.ts +++ b/static/src/app/methods/taskPolling/compression.ts @@ -117,15 +117,20 @@ export const compressionMethods = { messagesLen: Array.isArray(this.messages) ? this.messages.length : -1 }); - // 显式新建对话路由(/new、/multiagent/new):运行中的任务属于上一个对话, - // 用户主动选择了空白页。这里不能接管任务/重放事件/恢复轮询—— - // loadRunningTask(null) 会匹配任意运行中任务,一旦接管,该对话的 - // 用户输入/AI输出/工具调用会被全量重放到空白页(刷新也复现)。 + // 显式新建对话路由(/new、/multiagent/new)与独立全屏路由(工作流编辑器等): + // 前者是用户主动选择的空白页,后者不归对话体系。这里不能接管任务/重放事件/ + // 恢复轮询——loadRunningTask(null) 会匹配任意运行中任务,一旦接管,该对话的 + // 用户输入/AI输出/工具调用会被全量重放到空白页/被覆盖的隐藏视图(刷新也复现), + // 压缩完成等回调还会 loadConversation 改写 URL。 // 侧边栏运行标识由 refreshRunningWorkspaceTasks 独立维护,不受影响。 + const onIndependentRoute = + typeof this.isConversationIndependentRoute === 'function' && + this.isConversationIndependentRoute(); if ( !this.currentConversationId && - typeof this.isExplicitNewConversationRoute === 'function' && - this.isExplicitNewConversationRoute() + ((typeof this.isExplicitNewConversationRoute === 'function' && + this.isExplicitNewConversationRoute()) || + onIndependentRoute) ) { restoreDebugLog('restore:skip-explicit-new-route', { path: typeof window !== 'undefined' ? window.location.pathname : '' diff --git a/static/src/app/methods/taskPolling/lifecycle.ts b/static/src/app/methods/taskPolling/lifecycle.ts index 961acf81..e032032d 100644 --- a/static/src/app/methods/taskPolling/lifecycle.ts +++ b/static/src/app/methods/taskPolling/lifecycle.ts @@ -71,17 +71,21 @@ export const lifecycleMethods = { 'shallow_compression', 'compression_finished' ]); - // 显式新建路由(/new、/multiagent/new)上 currentConversationId 为空是常态: - // 空 id 不等于“无归属”,此时任何携带 conversation_id 的事件都属于其他对话, - // 必须丢弃,否则运行中对话的用户输入/AI输出/工具调用会渲染到空白页。 + // 显式新建路由(/new、/multiagent/new)与独立全屏路由(工作流编辑器等)上 + // currentConversationId 为空是常态:空 id 不等于“无归属”,此时任何携带 + // conversation_id 的事件都属于其他对话,必须丢弃,否则运行中对话的 + // 用户输入/AI输出/工具调用会渲染到空白页/被覆盖的隐藏视图。 const onExplicitNewRoute = typeof this.isExplicitNewConversationRoute === 'function' && this.isExplicitNewConversationRoute(); + const onIndependentRoute = + typeof this.isConversationIndependentRoute === 'function' && + this.isConversationIndependentRoute(); const eventConversationMismatch = !!( eventData.conversation_id && (this.currentConversationId ? eventData.conversation_id !== this.currentConversationId - : onExplicitNewRoute) + : onExplicitNewRoute || onIndependentRoute) ); if (!crossConversationAllowed.has(eventType) && eventConversationMismatch) { { diff --git a/static/src/app/methods/taskPolling/sync.ts b/static/src/app/methods/taskPolling/sync.ts index 6d228b76..e20a2048 100644 --- a/static/src/app/methods/taskPolling/sync.ts +++ b/static/src/app/methods/taskPolling/sync.ts @@ -105,6 +105,8 @@ export const syncMethods = { } }, handleConversationResolved(data: any) { + // 独立全屏路由(工作流编辑器等)下不恢复对话、不改写 URL + if (this.isConversationIndependentRoute()) return; if (data && data.conversation_id) { this.currentConversationId = data.conversation_id; if (data.title) { diff --git a/static/src/app/methods/ui/route.ts b/static/src/app/methods/ui/route.ts index dc194827..90859669 100644 --- a/static/src/app/methods/ui/route.ts +++ b/static/src/app/methods/ui/route.ts @@ -30,7 +30,7 @@ export const routeMethods = { this.titleReady = false; this.currentConversationTitle = ''; this.titleTypingText = ''; - let path = window.location.pathname.replace(/^\/+/, ''); + let path = window.location.pathname.replace(/^\/+|\/+$/g, ''); // 兼容重定向:旧多智能体路由统一收敛到裸路径。 // 对话类型是 metadata 属性(创建时确定),不再是路由概念。 if (path === 'multiagent/new' || path === 'multiagent') { @@ -42,6 +42,21 @@ export const routeMethods = { history.replaceState({}, '', `/${bareId}`); path = bareId; } + // 工作流编辑器:独立于对话体系的全屏路由,直接交给前端组件 + if (this.isConversationIndependentRoute()) { + if (path === 'workflow') { + history.replaceState({}, '', '/workflows'); + path = 'workflows'; + } + this.workflowDemoRoute = path; + this.currentConversationId = null; + this.currentConversationTitle = ''; + this.messages = []; + this.titleReady = true; + this.suppressTitleTyping = false; + this.initialRouteResolved = true; + return; + } if (!path || this.isExplicitNewConversationRoute()) { this.currentConversationId = null; this.currentConversationTitle = '新对话'; @@ -136,6 +151,25 @@ export const routeMethods = { // 多智能体旧路由在 bootstrapRoute 已被重定向到 /new,这里只判裸新建路由 return normalizedPath === 'new'; }, + /** + * 当前 URL 是否为独立于对话体系的全屏路由(当前:/workflows、/workflow/*)。 + * 与 isExplicitNewConversationRoute 一样直接读 location.pathname,不依赖 + * bootstrap 阶段写入的状态字段,任意时机调用结果都正确。 + * 对话体系的自动恢复/广播接管/任务接管/URL 写回在这些路由下必须全部禁用, + * 这是唯一判定入口,新增同类路由(全屏独立页)时在此登记。 + */ + isConversationIndependentRoute() { + const normalizedPath = window.location.pathname.replace(/^\/+|\/+$/g, ''); + return ( + normalizedPath === 'workflows' || + normalizedPath === 'workflow' || + normalizedPath.startsWith('workflow/') + ); + }, + openWorkflowsPage() { + // 工作流编辑器是 bootstrap 级全屏路由,与退出方向(/new)对称使用整页跳转,保证状态干净 + window.location.assign('/workflows'); + }, stripConversationPrefix(conversationId) { if (!conversationId) return ''; return conversationId.startsWith('conv_') ? conversationId.slice(5) : conversationId; diff --git a/static/src/app/methods/ui/socket.ts b/static/src/app/methods/ui/socket.ts index 7412b6db..a4fc7801 100644 --- a/static/src/app/methods/ui/socket.ts +++ b/static/src/app/methods/ui/socket.ts @@ -371,13 +371,15 @@ export const socketMethods = { const currentPath = window.location.pathname.replace(/^\/+/, ''); const isMultiAgentNewRoute = currentPath === 'multiagent/new' || currentPath === 'multiagent'; - // 显式新建对话路由(/new、/multiagent/new)永不自动恢复运行中的对话: - // 用户主动点击"新建对话"就是明确要空白页,运行中任务可从侧边栏随时切回。 + // 显式新建对话路由(/new、/multiagent/new)与独立全屏路由(工作流编辑器等) + // 永不自动恢复运行中的对话:前者是用户明确要空白页,后者不归对话体系, + // 运行中任务可从侧边栏随时切回。 if ( resumeConversationId && !this.currentConversationId && !isMultiAgentNewRoute && - !isExplicitNewRoute + !isExplicitNewRoute && + !this.isConversationIndependentRoute() ) { this.skipConversationHistoryReload = true; // 首次从状态恢复对话时,避免 socket 的 conversation_loaded 再次触发历史加载 diff --git a/static/src/app/state.ts b/static/src/app/state.ts index 4c297fbd..b28a4d46 100644 --- a/static/src/app/state.ts +++ b/static/src/app/state.ts @@ -52,6 +52,8 @@ export function dataState() { // 路由相关 initialRouteResolved: false, dropToolEvents: false, + // 工作流编辑器 demo 路由('workflows' | 'workflow/',空串 = 不在 demo 视图) + workflowDemoRoute: '', // 当前打开对话的类型('normal' | 'multi_agent',创建时确定、不可变); // 空对话态为 null。权威来源是对话 metadata.multi_agent_mode。 currentConversationType: null, diff --git a/static/src/components/sidebar/ConversationSidebar.vue b/static/src/components/sidebar/ConversationSidebar.vue index 17df2943..e3d5b32d 100644 --- a/static/src/components/sidebar/ConversationSidebar.vue +++ b/static/src/components/sidebar/ConversationSidebar.vue @@ -94,6 +94,34 @@ {{ workspaceKind === 'project' ? '项目' : '工作区' }} + +