diff --git a/AGENTS.md b/AGENTS.md index 630ac960..ddd2ac8b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -356,11 +356,13 @@ AI 执行以下流程时,每一步都要向用户说明在做什么: - 不要在提示词里暴露内部实现细节(例如“命令文本猜测”等) - 文案应面向用户能力边界与操作建议,不描述内部判定算法 -### 10.5 自动审批智能体配置与调试 +### 10.5 审核智能体配置与调试 -- 自动审批配置文件:`config/auto_approval.json` - - 建议字段:`name` / `url` / `key` / `model` / `extra_params` - - 额外控制:`timeout_seconds` / `max_rounds` / `max_command_timeout` +- 审核智能体配置:统一在个人空间「审核智能体」页设置(2026-08 起),存于 personalization.json 的 `review_agents` 键 + - 三个审核智能体:`auto_approval`(自动审批)/ `goal_review`(目标审核)/ `workflow_review`(工作流审核) + - 字段:`model`(子智能体模型库条目名,留空=模型库 default_model)/ `thinking` / `timeout_seconds` / `max_rounds` / `max_command_timeout` + - 解析入口:`modules/review_agent_config.py::resolve_review_agent_config`(复用 `sub_agent_models.json` + `_build_sub_agent_profile`) + - 旧的独立 json 配置(`config/auto_approval.json` / `goal_review.json` / `workflow_review.json.example`)已彻底废除,无向后兼容 - 调试开关(代码变量):`modules/approval_agent.py` 中 `DEBUG_SAVE_APPROVAL_AGENT_TRANSCRIPT` - 开启后写入:`logs/approval_agent/` - 记录以累积 `messages` 为主,便于对齐主/子智能体会话格式 diff --git a/config/auto_approval.json.example b/config/auto_approval.json.example deleted file mode 100644 index 180fd92e..00000000 --- a/config/auto_approval.json.example +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "auto-approval-agent", - "url": "https://api.example.com/v1", - "key": "${API_KEY_DEEPSEEK}", - "model": "your-model-id", - "extra_params": { - "thinking": { - "type": "enabled" - } - }, - "timeout_seconds": 60, - "max_rounds": 3, - "max_command_timeout": 60 -} diff --git a/config/goal_review.json.example b/config/goal_review.json.example deleted file mode 100644 index 5595b882..00000000 --- a/config/goal_review.json.example +++ /dev/null @@ -1,13 +0,0 @@ -{ - "name": "goal-review-agent", - "url": "https://api.example.com/v1", - "key": "${API_KEY_DEEPSEEK}", - "model": "your-model-id", - "extra_params": { - "thinking": { - "type": "enabled" - } - }, - "timeout_seconds": 600, - "max_command_timeout": 60 -} diff --git a/core/main_terminal_parts/context/messages.py b/core/main_terminal_parts/context/messages.py index 945e547f..0ff76a21 100644 --- a/core/main_terminal_parts/context/messages.py +++ b/core/main_terminal_parts/context/messages.py @@ -319,6 +319,21 @@ class MessagesMixin: if skills_prompt: messages.append({"role": "system", "content": skills_prompt}) + # 工作流(Workflow)上下文:不冻结,每次按当前状态现生成。 + # 阶段推进时由 workflow_flow.refresh_workflow_system_segment 同步刷新, + # 压缩不影响(system 段不在压缩范围),天然免疫压缩丢失。 + def _build_workflow_system_prompt() -> str: + try: + from server.workflow_flow import build_workflow_system_prompt + conversation_id = getattr(self.context_manager, "current_conversation_id", None) + return build_workflow_system_prompt(data_dir=self.data_dir, conversation_id=conversation_id) or "" + except Exception: + return "" + + workflow_prompt = _build_workflow_system_prompt() + if workflow_prompt: + messages.append({"role": "system", "content": workflow_prompt}) + # 记忆系统(总体长期记忆 + 项目记忆) def _build_memory_system_prompt() -> str: return self._build_memory_system_content() diff --git a/core/main_terminal_parts/tools_definition/__init__.py b/core/main_terminal_parts/tools_definition/__init__.py index 7529452f..70d5c1e8 100644 --- a/core/main_terminal_parts/tools_definition/__init__.py +++ b/core/main_terminal_parts/tools_definition/__init__.py @@ -7,6 +7,7 @@ from core.main_terminal_parts.tools_definition.search_web_tools import ToolsDefi from core.main_terminal_parts.tools_definition.agent_tools import ToolsDefinitionAgentToolsMixin from core.main_terminal_parts.tools_definition.context_tools import ToolsDefinitionContextToolsMixin from core.main_terminal_parts.tools_definition.misc_tools import ToolsDefinitionMiscToolsMixin +from core.main_terminal_parts.tools_definition.workflow_tools import ToolsDefinitionWorkflowToolsMixin from core.main_terminal_parts.tools_definition.main import ToolsDefinitionMainMixin class MainTerminalToolsDefinitionMixin( @@ -19,6 +20,7 @@ class MainTerminalToolsDefinitionMixin( ToolsDefinitionAgentToolsMixin, ToolsDefinitionContextToolsMixin, ToolsDefinitionMiscToolsMixin, + ToolsDefinitionWorkflowToolsMixin, ToolsDefinitionMainMixin, ): pass diff --git a/core/main_terminal_parts/tools_definition/main.py b/core/main_terminal_parts/tools_definition/main.py index ddc851af..f0e3b868 100644 --- a/core/main_terminal_parts/tools_definition/main.py +++ b/core/main_terminal_parts/tools_definition/main.py @@ -96,6 +96,7 @@ class ToolsDefinitionMainMixin: tools.extend(self._build_agent_tools()) tools.extend(self._build_context_tools()) tools.extend(self._build_misc_tools()) + tools.extend(self._build_workflow_tools()) # 多模态模型自带能力,不再暴露 vlm_analyze,改为 view_image / view_video model_key = getattr(self, "model_key", None) if model_key and (model_supports_image(model_key) or model_supports_video(model_key)): diff --git a/core/main_terminal_parts/tools_definition/workflow_tools.py b/core/main_terminal_parts/tools_definition/workflow_tools.py new file mode 100644 index 00000000..687c425f --- /dev/null +++ b/core/main_terminal_parts/tools_definition/workflow_tools.py @@ -0,0 +1,118 @@ +"""工作流(Workflow)工具定义。 + +5 个主智能体工具(定稿 docs/workflow_feature_plan.md §5): +- activate_workflow / report_workflow_stage / choose_workflow_branch +- get_workflow_status / deactivate_workflow + +handler 分布: +- activate / get_status / deactivate 走 tools_execution.py 常规链(无需 sender) +- report_workflow_stage / choose_workflow_branch 走 chat_flow_tool_loop.py 特判 + (需要 sender 发审核进度事件、conversation_id 与 workspace) +""" +from typing import Any, Dict, List + + +class ToolsDefinitionWorkflowToolsMixin: + def _build_workflow_tools(self) -> List[Dict]: + return [ + { + "type": "function", + "function": { + "name": "activate_workflow", + "description": ( + "在当前对话中激活一个工作流,让后续工作按既定流程推进。" + "当用户明确要求按某个工作流执行(如「按代码评审流程走」)," + "或你判断当前任务适合套用某个已存在的工作流时调用。" + "同一对话同时只能激活一个工作流;重复激活同一工作流返回当前进度。" + "激活后按返回的当前阶段要求工作,完成后用 report_workflow_stage 汇报。" + ), + "parameters": { + "type": "object", + "properties": self._inject_intent({ + "name": { + "type": "string", + "description": "工作流名称(workflows 库中的目录名,如 code-review-pipeline)", + } + }), + "required": ["name"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "report_workflow_stage", + "description": ( + "汇报当前工作流阶段已完成并推进流程。仅当工作流激活且当前位于执行阶段时可调。" + "summary 写本阶段实际完成了什么、关键结论与证据(审核智能体会看到)。" + "返回内容视下一节点而定:下一阶段的目标与要求 / 审核结果" + "(通过则推进、驳回则带整改意见回到前序阶段)/ 分支菜单 / 工作流完成。" + ), + "parameters": { + "type": "object", + "properties": self._inject_intent({ + "summary": { + "type": "string", + "description": "本阶段完成内容摘要:做了什么、结论是什么、关键证据(文件/命令输出)。", + } + }), + "required": ["summary"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "choose_workflow_branch", + "description": ( + "在工作流分支点选择后续路径。仅当前停在分支节点时可调;" + "target_node_id 必须在分支菜单列出的候选路径中。" + ), + "parameters": { + "type": "object", + "properties": self._inject_intent({ + "target_node_id": { + "type": "string", + "description": "候选路径的目标节点 id(分支菜单中列出)。", + } + }), + "required": ["target_node_id"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "get_workflow_status", + "description": ( + "查询当前对话激活工作流的进度:已完成步骤、审核记录、当前位置与已进行轮数。" + "在长阶段中迷失进度、或用户询问工作流进展时调用。" + ), + "parameters": { + "type": "object", + "properties": self._inject_intent({}), + }, + }, + }, + { + "type": "function", + "function": { + "name": "deactivate_workflow", + "description": ( + "退出当前对话激活的工作流。当你判断流程不适用于当前情况、" + "流程定义有问题导致无法推进、或用户要求退出时调用。" + "退出后工作流摘牌,你可以继续自由工作。" + ), + "parameters": { + "type": "object", + "properties": self._inject_intent({ + "reason": { + "type": "string", + "description": "退出原因(简述,会记录在状态里)。", + } + }), + "required": ["reason"], + }, + }, + }, + ] diff --git a/core/main_terminal_parts/tools_execution.py b/core/main_terminal_parts/tools_execution.py index 0505ee59..255eec09 100644 --- a/core/main_terminal_parts/tools_execution.py +++ b/core/main_terminal_parts/tools_execution.py @@ -578,6 +578,51 @@ class MainTerminalToolsExecutionMixin: result.pop("target_dir", None) return result + def _handle_activate_workflow_tool(self, arguments: Dict[str, Any]) -> Dict[str, Any]: + """activate_workflow:加载定义+快照+初始化(幂等规则由编排层处理)。""" + from server.workflow_flow import activate_workflow + conversation_id = getattr(self.context_manager, "current_conversation_id", None) + if not conversation_id: + return {"success": False, "error": "当前没有打开的对话,无法激活工作流。"} + try: + msg_index = len(getattr(self.context_manager, "conversation_history", []) or []) + except Exception: + msg_index = 0 + result = activate_workflow( + data_dir=self.data_dir, + conversation_id=str(conversation_id), + name=str(arguments.get("name") or ""), + msg_index=msg_index, + ) + if result.get("success"): + return { + "success": True, + "already": bool(result.get("already")), + "message": result.get("text"), + } + return {"success": False, "error": result.get("error")} + + def _handle_get_workflow_status_tool(self, arguments: Dict[str, Any]) -> Dict[str, Any]: + from server.workflow_flow import build_status_text + conversation_id = getattr(self.context_manager, "current_conversation_id", None) + if not conversation_id: + return {"success": False, "error": "当前没有打开的对话。"} + return { + "success": True, + "message": build_status_text(data_dir=self.data_dir, conversation_id=str(conversation_id)), + } + + def _handle_deactivate_workflow_tool(self, arguments: Dict[str, Any]) -> Dict[str, Any]: + from server.workflow_flow import deactivate_workflow + conversation_id = getattr(self.context_manager, "current_conversation_id", None) + if not conversation_id: + return {"success": False, "error": "当前没有打开的对话。"} + return deactivate_workflow( + data_dir=self.data_dir, + conversation_id=str(conversation_id), + reason=str(arguments.get("reason") or ""), + ) + def _handle_update_project_memory(self, name: str, description: str, content: str) -> Dict[str, Any]: """处理 update_project_memory:写入 .astrion/memory/{name}.md""" safe_name = str(name).strip() @@ -1129,6 +1174,12 @@ class MainTerminalToolsExecutionMixin: self._mark_file_read_from_result(tool_name, arguments, result) elif tool_name == "create_skill": result = self._handle_create_skill_tool(arguments) + elif tool_name == "activate_workflow": + result = self._handle_activate_workflow_tool(arguments) + elif tool_name == "get_workflow_status": + result = self._handle_get_workflow_status_tool(arguments) + elif tool_name == "deactivate_workflow": + result = self._handle_deactivate_workflow_tool(arguments) elif tool_name in {"vlm_analyze", "ocr_image"}: path = arguments.get("path") prompt = arguments.get("prompt") diff --git a/docs/host_sandbox_and_permission_model.md b/docs/host_sandbox_and_permission_model.md index 9cb6df4a..ad177d63 100644 --- a/docs/host_sandbox_and_permission_model.md +++ b/docs/host_sandbox_and_permission_model.md @@ -152,9 +152,7 @@ ### 7.4 自动审批(approval agent) - 自动审批由独立审批智能体执行(与主智能体分离) -- 配置文件:`config/auto_approval.json` - - `name` / `url` / `key` / `model` / `extra_params` - - `timeout_seconds` / `max_rounds` / `max_command_timeout` +- 配置:个人空间「审核智能体」页统一设置(personalization.json 的 `review_agents.auto_approval`),模型复用子智能体模型库;旧的 `config/auto_approval.json` 已彻底废除 - 审批智能体可调用能力: - `run_command`(用于只读核查) - 审批决策工具(approved / rejected + reason) diff --git a/docs/workflow_feature_plan.md b/docs/workflow_feature_plan.md index 107542cd..22ee3000 100644 --- a/docs/workflow_feature_plan.md +++ b/docs/workflow_feature_plan.md @@ -1,141 +1,260 @@ -# 工作流(Workflow)功能设计方案 +# 工作流(Workflow)功能设计与实施方案 -> 状态:设计讨论中(2026-08-18 更新,纳入阶段汇报工具 / 非线性路由 / 可视化编辑决策)。 -> 调研依据:`workflow_research/external_products/agentic_workflow_research.md`(外部产品)、`workflow_research/code_mount_points/code_mount_points.md`(代码挂载点)。 +> 状态:**设计定稿(2026-08-21)**。工作流库页与可视化编辑器已实现;本文档是运行时实施的最终依据。 +> 调研依据:`workflow_research/` 三份报告(外部产品 / 范式要素 / 代码挂载点)。 +> 历史说明:本文档前身是 2026-08-18 的讨论稿;当时「审核作为阶段属性」「兜底提醒轮询」「整体结束审核」等设想已在定稿中推翻,以本文档为准。 -## 1. 功能定义 +## 1. 功能定义与核心理念 -用户把「一套既定流程:工作方式 → 验证方式 → 结束方式」存为一个**工作流**(workflow),之后在任何普通对话中激活它,让主智能体复用现有循环与执行引擎按流程推进;阶段结束/整体结束时可由审核智能体软审核。 +用户把「一套既定流程:工作方式 → 验证方式 → 结束方式」存为一个**工作流**(workflow),之后在任何普通对话中激活它,让主智能体复用现有循环与执行引擎按流程推进;阶段间可由审核智能体软审核。 -核心原则(外部调研共识):**结构在边上,自主在节点内**——工作流只约束阶段拓扑与进出契约,阶段内主智能体完全自主(自由调工具、跑脚本)。 +三条核心理念(定稿): -## 2. 已拍板的设计决策 +1. **结构在边上,自主在节点内**——画布拓扑(节点 + 路由)是结构约束;阶段内主智能体完全自主(自由调工具、跑脚本)。外部调研(LangGraph / Dify / CrewAI / n8n)一致验证的范式。 +2. **工作流是智能体的辅助流程,不是宿主**——任何工作流异常/终态都不得掐断智能体工作。所有退出/失败/超限统一为**柔性通知**:摘牌(改状态)+ 一条 user 消息,处置权交回模型与用户。 +3. **对话级持续状态**——工作流状态不随单次任务结束、模型停止输出、用户按停止按钮而结束。模型停下后用户可自由穿插讨论,说「继续」即接着推进。 + +## 2. 已拍板决策(定稿汇总) | 决策点 | 结论 | |---|---| -| 运行方式 | **不做独立模式/特殊 URL/新对话类型**,只能在正常对话中激活 | -| 激活入口 | **/ 菜单(slash 菜单)** 人工激活 + **AI 工具 `activate_workflow`** 自主激活 | -| 阶段边界 | **显式化**:AI 调**阶段汇报工具**声明阶段完成(期间汇报触发审核与推进;无后续阶段时该汇报即停止信号) | -| 阶段模型 | **非线性**:阶段间为候选路由集(结构约束候选、AI 自主选择、审核把关),非纯线性 | -| 可视化编辑 | 拖拽画布编辑器(类似 ComfyUI 流程图)纳入设计范围,编辑对象是 WORKFLOW.md 的阶段拓扑 | -| 与目标模式关系 | **不互斥**:工作流可以是目标模式中的一个小步骤;目标模式后续将重做,本期不考虑两者冲突 | -| 审核配置 | **独立**:`workflow_review.json`(走 resolve_deploy_config 回退链)+ frontmatter `review_mode` 自声明 | -| 前后端通信 | **轮询**(复用 task 轮询 session_data 快照链路,不新增 websocket 通道) | -| 状态隔离 | **对话级状态**,多对话可同时激活不同工作流,进度互不串扰 | -| 硬校验 | 不做步骤级硬校验;归档时只做结构校验(frontmatter 合法、阶段 id 唯一、路由目标存在) | -| 存储形态 | 工作流文件夹 = `WORKFLOW.md`(frontmatter 结构 + 自然语言正文)+ `scripts/`(已验证脚本)+ `references/`(可选),与 skill 对齐 | +| 运行方式 | 不做独立模式/特殊 URL/新对话类型;在普通对话中激活 | +| 激活入口 | slash 菜单人工激活(仅智能体空闲可用)+ AI 工具 `activate_workflow` 自主激活 | +| 同时激活数 | **一个对话同时只有一个工作流处于激活**;重复激活同工作流幂等返回进度,激活另一个被拒绝 | +| 审核建模 | **独立 review 节点**(菱形,一等公民),非阶段属性;要结束审核就在 end 前显式放 review 节点 | +| 结束节点语义 | end 即终点,汇报到 end 即 completed;**无隐藏的整体结束审核** | +| 分支节点语义 | 单出线 = 并线器(自动穿过,无需决策);多出线 = AI 决策点(等 `choose_workflow_branch`) | +| 审核异常 | 视为驳回(计入 reject_counts),工具返回中含「请告知用户」 | +| maxRejects 撞限 | 工作流 failed;经工具返回告知(模型已在场),不另发 user 消息 | +| max_stage_rounds 撞限 | **不停工作流、不停智能体**;注入 user 消息让模型立刻停下、告知用户、询问是否继续 | +| deactivate(用户/模型/撞限) | 一律柔性通知退出,见 §4.7 | +| 兜底提醒轮询 | **取消**。模型停止输出即停止,工作流挂起不断,期间可与用户讨论 | +| 停止任务联动 | **不做**(不仿 stop_goal_user_cancel) | +| 版本快照 | 激活时把 WORKFLOW.md 复制进状态目录;运行期一切读取只读快照,库文件被改不影响运行中实例 | +| 前后端通信 | 轮询(复用 task 轮询 session_data 快照链路)+ 统一通知池轮询器(user 消息) | +| 状态隔离 | 对话级状态目录,多对话互不惊扰 | +| 硬校验 | 不做步骤级硬校验;保存时做结构校验(对齐前后端 validate) | -## 3. 存储与落盘位置 +## 3. 数据模型与存储(已实现) -| 模式 | 用户工作流库 | 说明 | +### 3.1 节点模型(对齐 `static/src/components/workflow/workflowModel.ts`) + +五种节点,串行边界(无并行语义): + +| kind | 形态 | 语义 | |---|---|---| -| host | `~/.astrion/astrion/host/workflows/` | 统一,不按用户拆(对齐 `CUSTOM_SKILLS_DIR`) | -| docker/web | `users//personal/workflows/` | 每用户私有,多项目共享(对齐 `infer_private_skills_dir`) | +| `start` | 胶囊 | 入口,右 1 出,恰好 1 个 | +| `end` | 胶囊 | 终点,左 n 入,至少 1 个 | +| `stage` | 矩形 | AI 执行阶段:`goal` + `instructions` + `next`(单值,显式连接) | +| `review` | 菱形 | 审核把关:`prompt`(审核关注点)+ `next`(通过路由)+ `rejectTo`(驳回路由)+ `maxRejects`(连续驳回上限,超限 failed) | +| `branch` | 虚线矩形 | 分线/并线器:`next[]` 路由数组,每条带 `condition`(自然语言,AI 决策依据;多出线必填) | -- 源码树 `workflows/` 只放内置示例种子,双源合并。 -- 归档工具 `create_workflow`:校验 → `shutil.move` → 已存在拒绝覆盖(复用 `archive_skill_directory` 模式)。 -- 运行时状态:`{workspace.data_dir}/workflow_states/.json`(对话级,对齐已修复的 goal 对话级方案)。 +所有路径显式化(`next` 为 null 校验不通过);`position` 仅画布坐标,与执行解耦。 -## 4. WORKFLOW.md 格式(非线性候选路由) +### 3.2 工作流库(已实现) -```markdown ---- -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_prompt: 检查是否遗漏边界条件和安全问题 - next: [report, explore] # 审核通过后可进报告,也可回探索补充 - - id: report - name: 输出报告 - goal: 生成结构化评审报告 - review: true - next: [] # 空 = 终点,汇报即触发整体结束 -end_conditions: 报告落盘且审核通过 ---- +- WORKFLOW.md = YAML frontmatter(上述结构)+ markdown 正文(工作方式/验证方式/结束方式) +- 双源合并:源码树 `workflows/`(内置种子)+ 运行态用户库(host:`~/.astrion/astrion/host/workflows/`) +- 模块:`modules/workflow_manager.py`(CRUD + 校验 + camelCase↔snake_case) +- REST:`server/workflow_page.py`(`/workflows`、`/workflow/` 页面 + `/api/workflows` CRUD) -# 工作方式 / 验证方式 / 结束方式(自然语言正文,随阶段上下文注入) -``` - -- **路由语义**:`next` 是候选集(结构约束);AI 在阶段汇报工具中传 `next_stage_id` 自主选择;审核智能体可否决路由选择(打回时附带建议去向)。 -- **结构校验**(归档时):name/description/entry/stages 齐全、id 唯一、next 引用存在、entry 可达终点。 -- `position` 字段(可选)仅记录画布坐标,供可视化编辑器使用,不影响执行。 - -## 5. 运行时机制(复用现有引擎 + 显式阶段边界) - -### 主路径:阶段汇报工具驱动 +### 3.3 运行时状态目录(待实现) ``` -对话激活工作流 → 注入入口阶段上下文(goal + 工作方式 + 候选路由) - ↓ -主智能体正常跑(现有引擎不变,自由调工具、跑 scripts/) - ↓ -AI 调 report_workflow_stage(summary, next_stage_id?) ← 阶段边界显式声明 - ↓ -当前阶段 review=true?──否──→ 校验 next_stage_id 在候选集 → 推进,注入新阶段上下文 - │是 - ↓ -WorkflowReviewAgent 审核(active 模式可只读取证) - ├─ pass → 推进(next 为空 → 整体结束审核 → done,停止) - └─ retry → 工具结果返回整改反馈,本阶段继续 +{workspace.data_dir}/workflow_states// +├── state.json # 运行状态 +└── WORKFLOW.md # 激活时刻的原样快照 ``` -### 兜底:无 tool_calls 拦截(降级为提醒) +`state.json` schema: -主循环 `if not tool_calls:` 分支仍保留工作流检查,但语义降为**提醒**:工作流活跃且当前阶段有产出却未汇报时,注入「你还有进行中的工作流阶段 X,请用阶段汇报工具汇报或继续推进」提示并 continue 一轮;连续提醒无响应则按空转保护停止。阶段推进的主路径永远是显式汇报。 +```json +{ + "workflow_name": "code-review-pipeline", + "status": "active | completed | stopped | failed", + "exit_reason": "user | model | max_rejects | round_limit_ack …", + "current_node_id": "review-target-stage", + "stage_rounds": 3, + "round_limit_notified": false, + "stage_start_msg_index": 142, + "reject_counts": {"review-gate": 1}, + "history": [ + {"node_id": "explore", "kind": "stage", "summary": "…", "rounds": 5, "completed_at": 169…}, + {"node_id": "review-gate", "kind": "review", "decision": "reject", "message": "…", "at": 169…} + ], + "pending_notices": [ + {"type": "deactivated_by_user", "message": "完整文本", "created_at": 169…} + ], + "started_at": 169… +} +``` -### 其他 +要点: -- **结束**三种:汇报至终点点(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")`。 -- **与目标模式并存**:不互斥;目标模式后续重做时统一考虑两者关系。 +- **`current_node_id` 只停 stage / branch / end**——review 是**瞬态节点**:汇报时同步审完直接走到下一站,review 只作为 history 记录。状态机与前端进度展示因此大幅简化。 +- **`stage_start_msg_index`(消息游标)**:进入阶段时记录 conversation_history 长度,审核 payload 截取增量作为阶段工作痕迹(§4.5)。 +- **`pending_notices`**:柔性通知池(§4.7),落盘持久,取出即清除。 + +## 4. 运行时机制(待实现) + +### 4.1 上下文注入:目录常存,详情跟走 + +| 信息 | 载体 | 时机 | +|---|---|---| +| 全景目录(工作流名/描述/正文三段/全部节点一句话清单/当前位置) | system 段(**不冻结**,每次 build_messages 现生成) | 激活期间恒在,压缩免疫 | +| 当前节点详情(stage 的 goal + instructions 全文) | 工具返回(tool 消息进历史) | 激活时、每次推进时 | +| 迷失自查 | `get_workflow_status` 工具 | 模型主动调 | + +注意:主循环单次任务内 messages 只构建一次,阶段推进后同一任务后续迭代的 system 段会滞后。实施时二选一:推进时在工具 handler 内同步刷新 messages 中的工作流段;或 system 段注明「以最新工具返回/状态查询为准」。首选前者。 + +### 4.2 激活(两路入口,共享 `build_activation_text()`) + +**AI 自主激活**:`activate_workflow(name)` 工具 → 加载定义 → 复制快照 → 初始化 state → 工具返回全景 + 入口阶段详情。重复激活规则:同工作流幂等返回当前进度;不同工作流拒绝(提示先退出)。 + +**slash 菜单激活**(REST): + +``` +POST /api/workflow/activate {conversation_id, name} + → 检查该对话无运行中主任务(门闸/terminal 状态),忙则 409「智能体运行中,无法激活」 + → 复制 WORKFLOW.md 快照 + 初始化 state + → 构造完整 user 消息(含全景 + 入口详情 + 「请开始执行」),走正常消息链路: + 持久化 + 广播 + metadata 齐全(正常 user 消息参数一个不少, + 另打 auto_message_type: "workflow_activate" 标记供前端识别样式) + → 创建主任务过门闸 → 模型收到消息直接开干(无需再调 activate_workflow) +``` + +### 4.3 阶段推进矩阵(核心) + +`report_workflow_stage(summary)` 仅在当前节点为 stage 时可调。引擎读当前 stage 的 `next`,按目标类型分派: + +| 下一节点 | 引擎动作 | 工具返回 | +|---|---|---| +| `stage` | 直接推进 | 「已记录完成。下一阶段「X」:goal + instructions」 | +| `review` | **同步 await 审核智能体**(§4.5) | 通过 → 「审核通过。下一阶段「X」…」;驳回 → reject_counts+1,回到 rejectTo:「审核未通过,意见:…。你已回到阶段「Y」整改」 | +| `branch`(多出线) | 不推进,停在 branch 等选择 | 「前方分支点:→ A(条件:…)→ B(条件:…)。请调 choose_workflow_branch」 | +| `branch`(单出线) | 自动穿过(并线器无需决策) | 同 stage | +| `end` | status=completed | 「工作流已完成。请输出总结后结束」(模型随后自然停止) | + +`choose_workflow_branch(target_node_id)`:仅当前停在 branch 时可调;校验 target 在候选路由集;推进后返回新阶段详情。 + +技术可行性已验证:`handle_tool_call` 为 async(tools_execution.py L960),`submit_plan` 已证明工具可长阻塞等待外部事件——审核在工具内同步 await 成立。 + +### 4.4 审核智能体 + +`modules/workflow_review_agent.py`(fork `goal_review_agent.py`),内部工具 `report_workflow_review(decision: pass|reject, message)`(仿 `report_goal_status`:唯一结论出口 + 强制工具调用重试)。配置统一走个人空间「审核智能体」页(`modules/review_agent_config.py::resolve_review_agent_config`,模型复用子智能体模型库);`review_mode: active` 时注入只读 run_command 取证。 + +**payload 构建**(核心:让审核看证据而非听汇报): + +``` +【工作流】{name}:{description} +【本次审核把关】{review 节点名} +审核关注点:{review.prompt} + +【被审核阶段】{stage 名} +阶段目标:{stage.goal} +阶段要求:{stage.instructions} + +【阶段执行痕迹】(消息游标 stage_start_msg_index 截取的对话增量,截断控长) +1. run_command: git diff --stat → … +2. read_file: server/chat.py L120-180 → … + +【主智能体阶段汇报】{summary} + +【历史审核意见】(若是重审,带上前几次驳回意见) +``` + +### 4.5 审核结果与边界 + +- **pass** → 推进到 review.next(若为 branch 多出线 → 停在 branch 等选择,返回分支菜单)。 +- **reject** → reject_counts[node]+1 → 回到 review.rejectTo(stage 或 branch),工具返回带审核意见。 +- **审核异常/超时** → 视为驳回(计入计数),返回中含「审核服务可能异常,**请告知用户**」。 +- **撞 maxRejects** → status=failed(exit_reason=max_rejects),工具返回「连续驳回超限,工作流已终止,请告知用户」。模型在场同轮闭环,不另发 user 消息。 + +### 4.6 max_stage_rounds 撞限(柔性询问) + +- `stage_rounds` 跨任务累计:主循环**每轮迭代** +1(挂点在主循环层,不在工具循环内——遵守注入时序铁律),进入新阶段清零。 +- 撞限 → 注入 inline user 消息(`inject_runtime_user_message(inline=True)`,下轮模型即看到): + 「工作流阶段「X」已进行 N 轮,达到上限。请立刻停下当前工作,告知用户已超过 N 轮,并询问是否还要继续。」 +- **工作流不停**:status 保持 active,原地挂起等用户发话。 +- 防重复:撞限后 `round_limit_notified=true`。 +- 清零时机:下一条真实用户消息到达、新任务开始时清零(视作用户已知情交互);之后若再跑 X 轮会再次询问——每超 X 轮问一次。 + +### 4.7 柔性退出与通知池 + +**模型自主 deactivate / completed**:工具返回同轮闭环,不需要 user 消息;前端 UI 走进度事件。 + +**用户 slash deactivate**(REST,随时可用,不做空闲检查——摘牌不打断正在跑的任务): + +- 智能体**运行中** → 事件入 `pending_notices`;在 `execute_tool_calls` 末尾统一消费点注入 inline 通知(与 `process_sub_agent_updates` 同位置;遵守「循环内收集 → 循环后注入」铁律),模型下轮看到。 +- 智能体**空闲** → REST 直接派发:预占主任务门闸 → 完整 user 消息(持久化+广播+metadata 齐全)→ 创建主任务。与 slash activate 共用派发函数。 +- **兜底**:运行中模型在通知被消费前就停了 → 任务结尾轮询器 spawn 条件兜住(见下)。 + +**通知池 = 统一完成通知轮询器的第三路**(`poll_completion_notifications`,2026-06 实施、2026-08 门闸化),不新造轮子。改动点(均在 `server/chat_flow_task_main.py`): + +1. `_collect_pending_completion_notices`:增加 workflow 一路(与子智能体、后台命令按时间混排)。 +2. `needs_completion_poll`(L2478 附近):扩展条件 `or _has_pending_workflow_notices(...)`——否则无后台任务时工作流通知产生后轮询器不 spawn。 +3. 轮询器主体零改动(门闸预占、批量预写+末条触发、新任务续轮询全复用)。 + +通知消息 metadata 新增 `message_source: "workflow"`(未识别时回落 "user")。 + +**摘牌后的工具行为**:stopped/failed 后模型再调 `report_workflow_stage` / `choose_workflow_branch` → 返回 `success:false` + 「工作流已退出(原因),如需重新开始请重新激活」——返回错误但不炸任务。 + +### 4.8 与 goal 模式叠加 + +不互斥。工作流活跃时主循环尾部 no-tool-calls 分支不做任何工作流拦截(兜底提醒已取消);goal 审核照常。 + +## 5. 工具清单 + +### 主智能体(5 个) + +| 工具 | 参数 | 行为/返回 | +|---|---|---| +| `activate_workflow` | `name` | 加载+快照+初始化;返回全景目录+入口阶段详情。同工作流幂等;不同工作流拒绝 | +| `report_workflow_stage` | `summary` | 阶段汇报核心状态机,返回按 §4.3 矩阵分派 | +| `choose_workflow_branch` | `target_node_id` | 分支选择;校验候选集;返回新阶段详情 | +| `get_workflow_status` | — | 返回当前节点/已走路径/各阶段轮数/审核历史(迷失自查+用户问进度) | +| `deactivate_workflow` | `reason` | 柔性退出:摘牌+工具返回确认(模型自主退出无需 user 消息) | + +### 审核智能体内部(1 个) + +| 工具 | 参数 | 说明 | +|---|---|---| +| `report_workflow_review` | `decision: pass\|reject`, `message` | 唯一结论出口,仿 report_goal_status | + +### REST(3 个) + +- `POST /api/workflow/activate`:仅智能体空闲(409 检查);快照+初始化+派发完整 user 消息任务 +- `POST /api/workflow/deactivate`:随时可用;忙入通知池、闲直发任务 +- `GET /api/workflow/status?conversation_id=`:轮询/刷新恢复 ## 6. 通信与前端 -- **轮询链路**(现成):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=` 供轮询/刷新恢复。 +- **轮询事件**(sender → session_data 快照 → REST 轮询,对齐 goal 链路):`workflow_progress`(推进/驳回)/ `workflow_review_progress`(审核进行中)/ `workflow_completed` / `workflow_stopped` / `workflow_failed`。快照自带 conversation_id。 +- **slash 菜单**:新增 workflows 模式,数据用现成 `GET /api/workflows`。 +- **进度展示**:仿 goal 进度组件——当前阶段名 · 已走路径 · 审核状态 · 轮数。 +- **刷新恢复**:`GET /api/workflow/status`。 -## 7. 可视化拖拽编辑器(设计讨论中) +## 7. 已实现部分清单(编辑器期) -- **技术选型**:Vue Flow(项目为 Vue 3,React Flow 的 Vue 移植,API 同构)。 -- **编辑对象**:WORKFLOW.md frontmatter 的 stages 拓扑;节点 = 阶段,边 = next 候选路由;`position` 仅存坐标,与执行解耦。 -- **与 AI 生成协同**:AI `create_workflow` 生成归档 → 画布打开可视化/微调;画布编辑 → 序列化回 WORKFLOW.md。 -- **页面形态与一期范围**:待定(见 §9)。 +- `modules/workflow_manager.py`、`server/workflow_page.py` +- `static/src/components/workflow/`:`workflowModel.ts`(模型/校验/dagre 自动排版)、`WorkflowLibraryView.vue`、`WorkflowEditorView.vue`、四种节点组件 +- `workflows/` 四个内置示例(code-review-pipeline / bug-fix-triage / feature-development / research-report) -## 8. 新增工具与文件清单 +## 8. 实施清单(文件级,按依赖顺序) -| 项 | 类型 | 说明 | -|---|---|---| -| `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` | 提示词 | 工作流上下文模板 | +1. `modules/workflow_state_manager.py`(新增):状态目录读写、快照复制、计数、通知池、消息游标。 +2. `modules/workflow_review_agent.py`(新增,fork goal_review_agent)+ `prompts/workflow_review_agent.txt`(配置后改为统一审核智能体设置,见 `modules/review_agent_config.py`)。 +3. `server/workflow_flow.py`(新增):编排——`build_activation_text`、system 段构建、推进矩阵、审核调用、通知文本构造、状态查询。 +4. 工具注册:`core/main_terminal_parts/tools_definition/` 新增 5 个定义;`tools_execution.py` 注册 handler。 +5. system 段注入:`core/main_terminal_parts/context/messages.py` 追加工作流段(不冻结)+ 推进时刷新机制。 +6. 主循环挂点(`server/chat_flow_task_main.py`):stage_rounds 计数与撞限注入;`needs_completion_poll` 扩展;`_collect_pending_completion_notices` 加 workflow 路;工具循环末尾消费工作流通知。 +7. REST:`server/tasks/workflows.py`(新增):activate / deactivate / status。 +8. 前端:slash 菜单 workflows 模式、进度组件、轮询事件消费(`taskPolling/lifecycle.ts`)、刷新恢复。 +9. 验证:`python3 -m py_compile` 改动文件 + `python -m pytest test/test_server_refactor_smoke.py -q`。 -## 9. 待确认 +## 9. 硬约束(实施必须遵守) -- 可视化编辑器页面形态:独立路由页面(如 `/workflows` 库 + 编辑器)还是对话内弹层/抽屉? -- 编辑器一期范围:完整拖拽编辑(增删节点/连线/改参数/保存)还是先做只读可视化 + 文本编辑? -- 兜底提醒的容忍轮数(建议连续 2 轮无响应转空转停止)。 +- **单写者/门闸**:所有工作流逻辑发生在主任务内部(工具执行 + 主循环层),不新增主任务入口;REST 派发走门闸预占(AGENTS.md §12)。 +- **注入时序铁律**:工具循环内禁止直接 `inject_runtime_user_message`——一律「循环内收集 → 循环后注入」(记忆 `runtime_injected_message_convention`)。 +- **运行期注入标记**:注入的 user 消息必须走 `inject_runtime_user_message`(自动带 `runtime_injected` 等标记),否则刷新恢复重建会重复显示。 +- **不掐断智能体**:任何工作流路径不得 raise/return 导致主任务异常终止;柔性优先。 diff --git a/modules/approval_agent.py b/modules/approval_agent.py index fbeef81e..97820868 100644 --- a/modules/approval_agent.py +++ b/modules/approval_agent.py @@ -1,7 +1,6 @@ from __future__ import annotations import json -import os import time import uuid from pathlib import Path @@ -9,11 +8,10 @@ from typing import Any, Callable, Dict, List, Optional import httpx -from config import resolve_deploy_config, LOGS_DIR +from config import LOGS_DIR +from modules.review_agent_config import resolve_review_agent_config -# 部署级配置(审批智能体 url/key/model,含密钥引用)→ 优先 ~/.astrion//config -CONFIG_PATH = Path(resolve_deploy_config("auto_approval.json")) DEFAULT_MAX_ROUNDS = 3 DEFAULT_TIMEOUT_SECONDS = 60 DEFAULT_MAX_COMMAND_TIMEOUT = 20 @@ -21,41 +19,11 @@ DEBUG_SAVE_APPROVAL_AGENT_TRANSCRIPT = True DEBUG_TRANSCRIPT_DIR = Path(LOGS_DIR) / "approval_agent" -def _resolve_env_token(value: str) -> str: - text = str(value or "").strip() - if text.startswith("${") and text.endswith("}"): - key = text[2:-1].strip() - return os.environ.get(key, "") - return text - - def load_approval_agent_config() -> Dict[str, Any]: - base = { - "name": "auto-approval-agent", - "url": "", - "key": "", - "model": "", - "extra_params": {}, - "timeout_seconds": DEFAULT_TIMEOUT_SECONDS, - "max_rounds": DEFAULT_MAX_ROUNDS, - "max_command_timeout": DEFAULT_MAX_COMMAND_TIMEOUT, - } - try: - if not CONFIG_PATH.exists(): - return base - raw = json.loads(CONFIG_PATH.read_text(encoding="utf-8")) - if isinstance(raw, dict): - base.update(raw) - base["url"] = _resolve_env_token(base.get("url", "")) - base["key"] = _resolve_env_token(base.get("key", "")) - base["model"] = str(base.get("model") or "").strip() - base["extra_params"] = base.get("extra_params") if isinstance(base.get("extra_params"), dict) else {} - base["timeout_seconds"] = int(base.get("timeout_seconds") or DEFAULT_TIMEOUT_SECONDS) - base["max_rounds"] = max(1, int(base.get("max_rounds") or DEFAULT_MAX_ROUNDS)) - base["max_command_timeout"] = max(1, int(base.get("max_command_timeout") or DEFAULT_MAX_COMMAND_TIMEOUT)) - return base - except Exception: - return base + """加载审批智能体配置:统一走个人空间「审核智能体」设置 + 子智能体模型库。""" + cfg = resolve_review_agent_config("auto_approval") + cfg["name"] = "auto-approval-agent" + return cfg class ApprovalAgent: diff --git a/modules/goal_review_agent.py b/modules/goal_review_agent.py index 3c1fa584..2204f008 100644 --- a/modules/goal_review_agent.py +++ b/modules/goal_review_agent.py @@ -15,7 +15,6 @@ from __future__ import annotations import json -import os import time import uuid from pathlib import Path @@ -23,11 +22,10 @@ from typing import Any, Callable, Dict, List, Optional import httpx -from config import PROMPTS_DIR, resolve_deploy_config, LOGS_DIR +from config import PROMPTS_DIR, LOGS_DIR from modules.goal_state_manager import REVIEW_MODE_ACTIVE, REVIEW_MODE_READONLY +from modules.review_agent_config import resolve_review_agent_config -# 部署级配置(目标审核智能体 url/key/model,含密钥引用)→ 优先 ~/.astrion//config -CONFIG_PATH = Path(resolve_deploy_config("goal_review.json")) PROMPT_NAME = "goal_review_agent.txt" DEFAULT_MAX_ROUNDS = 3 DEFAULT_TIMEOUT_SECONDS = 60 @@ -39,39 +37,11 @@ DEBUG_TRANSCRIPT_DIR = Path(LOGS_DIR) / "goal_review_agent" FALLBACK_CONTINUE_MESSAGE = "审核未能给出明确结论。请重新对照目标核查当前进度,找出尚未完成的部分并继续推进。" -def _resolve_env_token(value: str) -> str: - text = str(value or "").strip() - if text.startswith("${") and text.endswith("}"): - key = text[2:-1].strip() - return os.environ.get(key, "") - return text - - def load_goal_review_agent_config() -> Dict[str, Any]: - base = { - "name": "goal-review-agent", - "url": "", - "key": "", - "model": "", - "extra_params": {}, - "timeout_seconds": DEFAULT_TIMEOUT_SECONDS, - "max_command_timeout": DEFAULT_MAX_COMMAND_TIMEOUT, - } - try: - if not CONFIG_PATH.exists(): - return base - raw = json.loads(CONFIG_PATH.read_text(encoding="utf-8")) - if isinstance(raw, dict): - base.update(raw) - base["url"] = _resolve_env_token(base.get("url", "")) - base["key"] = _resolve_env_token(base.get("key", "")) - base["model"] = str(base.get("model") or "").strip() - base["extra_params"] = base.get("extra_params") if isinstance(base.get("extra_params"), dict) else {} - base["timeout_seconds"] = int(base.get("timeout_seconds") or DEFAULT_TIMEOUT_SECONDS) - base["max_command_timeout"] = max(1, int(base.get("max_command_timeout") or DEFAULT_MAX_COMMAND_TIMEOUT)) - return base - except Exception: - return base + """加载目标审核智能体配置:统一走个人空间「审核智能体」设置 + 子智能体模型库。""" + cfg = resolve_review_agent_config("goal_review") + cfg["name"] = "goal-review-agent" + return cfg class GoalReviewAgent: @@ -222,6 +192,7 @@ class GoalReviewAgent: endpoint = f"{url.rstrip('/')}/chat/completions" headers = {"Authorization": f"Bearer {key}", "Content-Type": "application/json"} timeout_seconds = int(self.cfg.get("timeout_seconds") or DEFAULT_TIMEOUT_SECONDS) + max_rounds = max(1, int(self.cfg.get("max_rounds") or DEFAULT_MAX_ROUNDS)) extra_params = dict(self.cfg.get("extra_params") or {}) tools = self._build_tools(review_mode) @@ -235,6 +206,10 @@ class GoalReviewAgent: forced_tool_retry_used = False while True: rounds += 1 + if rounds > max_rounds: + out = _continue(f"目标审核超过 {max_rounds} 轮未产出结论,请继续推进目标。") + _flush_trace(out) + return out if cancel_check: external = cancel_check() if external: diff --git a/modules/personalization_manager.py b/modules/personalization_manager.py index 2ad0b001..8c77fe68 100644 --- a/modules/personalization_manager.py +++ b/modules/personalization_manager.py @@ -58,6 +58,9 @@ MIN_SHALLOW_KEEP_USER_TURN_TOOLS = 0 MAX_SHALLOW_KEEP_USER_TURN_TOOLS = 50 DEFAULT_SHALLOW_KEEP_USER_TURN_TOOLS = 3 +# 三个审核智能体的固定键:自动审批 / 目标审核 / 工作流审核 +REVIEW_AGENT_KEYS = ("auto_approval", "goal_review", "workflow_review") + DEFAULT_PERSONALIZATION_CONFIG: Dict[str, Any] = { "enabled": False, "communication_style": "default", # default / human_like / auto @@ -128,6 +131,12 @@ DEFAULT_PERSONALIZATION_CONFIG: Dict[str, Any] = { # 传统模式子智能体设置(个人空间-子智能体管理;与多智能体无关) "sub_agent_compress_threshold_tokens": 150000, # 子智能体上下文压缩阈值,最小 10000 "sub_agent_max_turns": None, # 子智能体最大执行轮次:None-默认 50 / 0-无上限 / 正整数-该值 + # 审核智能体统一配置(个人空间-审核智能体页):模型名留空则用子智能体模型库 default_model + "review_agents": { + "auto_approval": {"model": "", "thinking": False, "timeout_seconds": 60, "max_rounds": 3, "max_command_timeout": 20}, + "goal_review": {"model": "", "thinking": False, "timeout_seconds": 60, "max_rounds": 3, "max_command_timeout": 60}, + "workflow_review": {"model": "", "thinking": False, "timeout_seconds": 120, "max_rounds": 6, "max_command_timeout": 60}, + }, } __all__ = [ @@ -680,6 +689,12 @@ def sanitize_personalization_payload( else: base["project_memory_inject_limit"] = _sanitize_project_memory_inject_limit(base.get("project_memory_inject_limit")) + # 审核智能体统一配置(模型名 / 思考模式 / 超时与轮次参数) + if "review_agents" in data: + base["review_agents"] = _sanitize_review_agents(data.get("review_agents")) + else: + base["review_agents"] = _sanitize_review_agents(base.get("review_agents")) + return base @@ -696,6 +711,34 @@ def _sanitize_sub_agent_max_turns(value: Any) -> Optional[int]: return min(parsed, 100_000) # 防御性上限;需要更大时用 0 表示无上限 +def _sanitize_review_agents(value: Any) -> Dict[str, Dict[str, Any]]: + """清洗审核智能体统一配置:结构不对时回落默认值,数值参数钳到合理区间。""" + defaults = DEFAULT_PERSONALIZATION_CONFIG["review_agents"] + src = value if isinstance(value, dict) else {} + out: Dict[str, Dict[str, Any]] = {} + + def _clamp_int(raw: Any, fallback: int, lo: int, hi: int) -> int: + if isinstance(raw, bool): + return fallback + try: + parsed = int(raw) + except (TypeError, ValueError): + return fallback + return max(lo, min(parsed, hi)) + + for key in REVIEW_AGENT_KEYS: + d = defaults[key] + item = src.get(key) if isinstance(src.get(key), dict) else {} + out[key] = { + "model": str(item.get("model") or "").strip()[:100], + "thinking": bool(item.get("thinking", d["thinking"])), + "timeout_seconds": _clamp_int(item.get("timeout_seconds"), d["timeout_seconds"], 5, 3600), + "max_rounds": _clamp_int(item.get("max_rounds"), d["max_rounds"], 1, 50), + "max_command_timeout": _clamp_int(item.get("max_command_timeout"), d["max_command_timeout"], 1, 600), + } + return out + + def _sanitize_project_memory_inject_limit(value: Any) -> Optional[int]: """清洗项目记忆索引最大注入条数:None/''/0/负数 → None(无上限);非法值 → 默认 20;正整数钳到 [5, 100000]。""" if value is None or value == "": diff --git a/modules/review_agent_config.py b/modules/review_agent_config.py new file mode 100644 index 00000000..63161dec --- /dev/null +++ b/modules/review_agent_config.py @@ -0,0 +1,107 @@ +"""审核智能体统一配置解析。 + +三个审核智能体(自动审批 auto_approval / 目标审核 goal_review / 工作流审核 workflow_review) +的模型与运行参数统一来自个人空间设置(personalization.json 的 review_agents 键): +- model / thinking:模型名与思考模式;模型条目复用子智能体模型库 sub_agent_models.json, + 模型名留空时使用模型库的 default_model; +- timeout_seconds / max_rounds / max_command_timeout:审核请求超时、最大轮次、只读命令超时。 + +历史上三个智能体各自读取独立的部署级 json 配置(auto_approval.json / goal_review.json / +workflow_review.json),该方式已彻底废弃,不再做任何向后兼容。 +""" + +import json +from pathlib import Path +from typing import Any, Dict + +from config import DATA_DIR +from config.sub_agent import SUB_AGENT_MODELS_CONFIG_FILE +from modules.personalization_manager import REVIEW_AGENT_KEYS + +__all__ = ["resolve_review_agent_config", "REVIEW_AGENT_KEYS"] + + +def _load_model_entry(model_name: str) -> Dict[str, Any]: + """从子智能体模型库解析出指定模型的 profile;名称留空则用 default_model。 + + 返回 APIClient.apply_profile 格式的 profile(含 fast/thinking 两段),失败返回 None。 + """ + config_path = Path(SUB_AGENT_MODELS_CONFIG_FILE) + if not config_path.exists(): + return None + try: + raw = json.loads(config_path.read_text(encoding="utf-8")) + except Exception: + return None + models = raw.get("models", []) if isinstance(raw, dict) else (raw if isinstance(raw, list) else []) + default_key = str(raw.get("default_model", "")) if isinstance(raw, dict) else "" + + from modules.sub_agent.toolkit import _build_sub_agent_profile + + model_map: Dict[str, Dict[str, Any]] = {} + for item in models: + if not isinstance(item, dict): + continue + profile = _build_sub_agent_profile(item) + if profile: + model_map[profile["name"]] = profile + + chosen = model_name or default_key + if chosen not in model_map and model_map: + chosen = next(iter(model_map)) + return model_map.get(chosen) + + +def resolve_review_agent_config(agent_key: str) -> Dict[str, Any]: + """解析指定审核智能体的完整运行配置。 + + 返回字段:name / url / key / model / extra_params / timeout_seconds / max_rounds / + max_command_timeout。模型未配置或模型库不可用时 url/key/model 为空字符串, + 由各审核智能体走既有的「配置缺失」兜底行为。 + """ + base: Dict[str, Any] = { + "name": f"{agent_key}-agent", + "url": "", + "key": "", + "model": "", + "extra_params": {}, + "timeout_seconds": 60, + "max_rounds": 3, + "max_command_timeout": 60, + } + if agent_key not in REVIEW_AGENT_KEYS: + return base + + try: + from modules.personalization_manager import load_personalization_config + + personal = load_personalization_config(DATA_DIR) + except Exception: + personal = {} + settings = (personal.get("review_agents") or {}).get(agent_key) + if not isinstance(settings, dict): + return base + + base["timeout_seconds"] = int(settings.get("timeout_seconds") or base["timeout_seconds"]) + base["max_rounds"] = max(1, int(settings.get("max_rounds") or base["max_rounds"])) + base["max_command_timeout"] = max(1, int(settings.get("max_command_timeout") or base["max_command_timeout"])) + + model_name = str(settings.get("model") or "").strip() + profile = _load_model_entry(model_name) + if not profile: + return base + + # 按思考模式选段;模型不支持 thinking 时回落 fast 段 + thinking = bool(settings.get("thinking")) + segment = profile.get("thinking") if thinking else None + if not segment: + segment = profile.get("fast") or {} + base["url"] = str(segment.get("base_url") or "").strip() + base["key"] = str(segment.get("api_key") or "").strip() + base["model"] = str(segment.get("model_id") or "").strip() + extra = segment.get("extra_params") + base["extra_params"] = dict(extra) if isinstance(extra, dict) else {} + max_tokens = segment.get("max_tokens") + if isinstance(max_tokens, int) and max_tokens > 0 and "max_tokens" not in base["extra_params"]: + base["extra_params"]["max_tokens"] = max_tokens + return base diff --git a/modules/workflow_review_agent.py b/modules/workflow_review_agent.py new file mode 100644 index 00000000..441ad893 --- /dev/null +++ b/modules/workflow_review_agent.py @@ -0,0 +1,348 @@ +"""工作流阶段审核智能体(Workflow Review Agent)。 + +由 modules/goal_review_agent.py fork 而来。职责不同: +- goal_review_agent 判断"长期目标是否真正达成"(done/continue)。 +- workflow_review_agent 判断"工作流当前阶段的产出是否达到进入下一阶段的门槛"(pass/reject)。 + +两种审核模式(与 goal_review 同构): +- readonly:只给 report_workflow_review 一个工具,不注入 run_command。 +- active:额外注入只读 run_command 工具,允许其取证后再下结论。 + +返回结构:{"decision": "pass"|"reject", "message": str, "source": "workflow_review_agent"}。 +兜底(异常/超时/未产出结论/配置缺失)一律返回 reject,message 说明审核服务异常 +并提示主智能体「请告知用户」——定稿决策:审核异常视为驳回(计入 reject_counts), +用户看到消息后能发现是审核服务问题而非真实驳回。 +""" + +from __future__ import annotations + +import json +import time +import uuid +from pathlib import Path +from typing import Any, Callable, Dict, List, Optional + +import httpx + +from config import PROMPTS_DIR, LOGS_DIR +from modules.goal_state_manager import REVIEW_MODE_ACTIVE, REVIEW_MODE_READONLY +from modules.review_agent_config import resolve_review_agent_config + +PROMPT_NAME = "workflow_review_agent.txt" +DEFAULT_TIMEOUT_SECONDS = 120 +DEFAULT_MAX_ROUNDS = 6 +DEFAULT_MAX_COMMAND_TIMEOUT = 60 +DEBUG_SAVE_WORKFLOW_REVIEW_TRANSCRIPT = True +DEBUG_TRANSCRIPT_DIR = Path(LOGS_DIR) / "workflow_review_agent" + +# 兜底驳回消息(审核智能体未能产出明确结论时使用) +FALLBACK_REJECT_MESSAGE = ( + "本次审核未能正常完成(审核服务异常或未产出结论),按驳回处理。" + "请告知用户审核服务可能异常;若属偶发,可稍后重新汇报本阶段。" +) + + +def load_workflow_review_agent_config() -> Dict[str, Any]: + """加载工作流审核智能体配置:统一走个人空间「审核智能体」设置 + 子智能体模型库。""" + cfg = resolve_review_agent_config("workflow_review") + cfg["name"] = "workflow-review-agent" + return cfg + + +class WorkflowReviewAgent: + def __init__(self, *, web_terminal: Any): + self.web_terminal = web_terminal + self.cfg = load_workflow_review_agent_config() + + @staticmethod + def _system_prompt(review_mode: str) -> str: + try: + template = (Path(PROMPTS_DIR) / PROMPT_NAME).read_text(encoding="utf-8") + except Exception: + template = "你是工作流阶段审核智能体。你必须调用 report_workflow_review 返回 pass 或 reject。" + + active_start = "{{ACTIVE_REVIEW_ONLY}}" + active_end = "{{/ACTIVE_REVIEW_ONLY}}" + if review_mode == REVIEW_MODE_ACTIVE: + return template.replace(active_start, "").replace(active_end, "").strip() + + while active_start in template and active_end in template: + start = template.find(active_start) + end = template.find(active_end, start) + if start < 0 or end < 0: + break + template = template[:start] + template[end + len(active_end):] + return template.strip() + + @staticmethod + def _report_tool() -> Dict[str, Any]: + return { + "type": "function", + "function": { + "name": "report_workflow_review", + "description": ( + "提交工作流阶段审核结论并结束本次审核。pass=阶段产出合格,进入下一阶段;" + "reject=阶段产出不合格,打回整改。" + ), + "parameters": { + "type": "object", + "properties": { + "decision": { + "type": "string", + "enum": ["pass", "reject"], + "description": "pass=通过;reject=驳回。", + }, + "message": { + "type": "string", + "description": ( + "decision=pass 时简述通过理由(给主智能体与用户看);" + "decision=reject 时写给主执行模型的整改意见,需具体可执行," + "指出缺什么、怎么补、重新审核时要看到什么证据。" + ), + }, + }, + "required": ["decision", "message"], + }, + }, + } + + @staticmethod + def _run_command_tool() -> Dict[str, Any]: + return { + "type": "function", + "function": { + "name": "run_command", + "description": ( + "在终端中执行只读指令。可用于查看文件内容、搜索代码/文本、检查目录结构、" + "查看 git 状态与差异、运行测试以核实阶段产出是否合格。" + "禁止用于写入、删除、改权限或其他修改性操作。" + ), + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": ( + "要执行的终端命令字符串。应优先使用只读命令,例如 ls、cat、grep、find、" + "rg、git status、git diff、pwd、stat、以及运行测试的命令等。" + ), + } + }, + "required": ["command"], + }, + }, + } + + def _build_tools(self, review_mode: str) -> List[Dict[str, Any]]: + if review_mode == REVIEW_MODE_ACTIVE: + return [self._run_command_tool(), self._report_tool()] + return [self._report_tool()] + + async def _run_readonly_command(self, command: str) -> Dict[str, Any]: + work_path = Path(getattr(self.web_terminal.context_manager, "project_path", ".")) + timeout = int(self.cfg.get("max_command_timeout") or DEFAULT_MAX_COMMAND_TIMEOUT) + try: + result = await self.web_terminal.terminal_ops.run_command( + command=command, + working_dir=str(work_path), + timeout=timeout, + sandbox_write_access=False, + ) + return result if isinstance(result, dict) else {"success": False, "error": "run_command 返回格式异常"} + except Exception as exc: + return {"success": False, "error": str(exc)} + + async def review( + self, + *, + payload_text: str, + review_mode: str = REVIEW_MODE_READONLY, + progress_cb: Optional[Callable[[Dict[str, Any]], None]] = None, + cancel_check: Optional[Callable[[], Optional[Dict[str, Any]]]] = None, + ) -> Dict[str, Any]: + review_mode = review_mode if review_mode in (REVIEW_MODE_READONLY, REVIEW_MODE_ACTIVE) else REVIEW_MODE_READONLY + debug_transcript: List[Dict[str, Any]] = [] + + def _trace(event: str, payload: Dict[str, Any]) -> None: + if not DEBUG_SAVE_WORKFLOW_REVIEW_TRANSCRIPT: + return + debug_transcript.append({"ts": int(time.time() * 1000), "event": event, "payload": payload}) + + def _flush_trace(final_result: Dict[str, Any]) -> None: + if not DEBUG_SAVE_WORKFLOW_REVIEW_TRANSCRIPT: + return + try: + DEBUG_TRANSCRIPT_DIR.mkdir(parents=True, exist_ok=True) + row = { + "created_at": int(time.time() * 1000), + "review_mode": review_mode, + "messages": messages, + "trace": debug_transcript, + "final_result": final_result, + } + file = DEBUG_TRANSCRIPT_DIR / f"workflow_review_{int(time.time() * 1000)}.json" + file.write_text(json.dumps(row, ensure_ascii=False, indent=2), encoding="utf-8") + except Exception: + pass + + def _reject(message: str) -> Dict[str, Any]: + return {"decision": "reject", "message": message or FALLBACK_REJECT_MESSAGE, "source": "workflow_review_agent"} + + url = str(self.cfg.get("url") or "").strip() + key = str(self.cfg.get("key") or "").strip() + model = str(self.cfg.get("model") or "").strip() + if not url or not key or not model: + out = _reject(f"工作流审核智能体配置缺失,无法完成审核。{FALLBACK_REJECT_MESSAGE}") + _flush_trace(out) + return out + endpoint = f"{url.rstrip('/')}/chat/completions" + headers = {"Authorization": f"Bearer {key}", "Content-Type": "application/json"} + timeout_seconds = int(self.cfg.get("timeout_seconds") or DEFAULT_TIMEOUT_SECONDS) + max_rounds = max(1, int(self.cfg.get("max_rounds") or DEFAULT_MAX_ROUNDS)) + extra_params = dict(self.cfg.get("extra_params") or {}) + tools = self._build_tools(review_mode) + + messages: List[Dict[str, Any]] = [ + {"role": "system", "content": self._system_prompt(review_mode)}, + {"role": "user", "content": payload_text}, + ] + + async with httpx.AsyncClient(timeout=timeout_seconds) as client: + rounds = 0 + forced_tool_retry_used = False + while True: + rounds += 1 + if cancel_check: + external = cancel_check() + if external: + return external + if rounds > max_rounds: + out = _reject(f"审核超过最大轮次仍未产出结论。{FALLBACK_REJECT_MESSAGE}") + _flush_trace(out) + return out + if progress_cb: + progress_cb({"stage": "model_call", "round": rounds, "message": f"审核轮次 {rounds}"}) + req = { + "model": model, + "messages": messages, + "tools": tools, + "tool_choice": "auto", + "temperature": 0.0, + **extra_params, + } + _trace("request", {"round": rounds, "request": req}) + try: + resp = await client.post(endpoint, headers=headers, json=req) + resp.raise_for_status() + except httpx.HTTPStatusError as exc: + body = "" + try: + body = exc.response.text + except Exception: + body = str(exc) + _trace("http_error", {"round": rounds, "status_code": exc.response.status_code if exc.response else None, "body": body}) + # 兼容某些模型/网关不接受额外参数:自动降级重试一次(去掉 extra_params) + if extra_params: + retry_req = { + "model": model, + "messages": messages, + "tools": tools, + "tool_choice": "auto", + "temperature": 0.0, + } + _trace("retry_request_without_extra_params", {"round": rounds, "request": retry_req}) + retry_resp = await client.post(endpoint, headers=headers, json=retry_req) + try: + retry_resp.raise_for_status() + resp = retry_resp + except httpx.HTTPStatusError: + out = _reject(f"审核请求失败({retry_resp.status_code})。{FALLBACK_REJECT_MESSAGE}") + _flush_trace(out) + return out + else: + out = _reject( + f"审核请求失败({exc.response.status_code if exc.response else 'unknown'})。{FALLBACK_REJECT_MESSAGE}" + ) + _flush_trace(out) + return out + except Exception as exc: + _trace("request_exception", {"round": rounds, "error": str(exc)}) + out = _reject(f"审核请求异常({exc})。{FALLBACK_REJECT_MESSAGE}") + _flush_trace(out) + return out + + choice = ((resp.json().get("choices") or [{}])[0] or {}).get("message") or {} + reasoning_content = ( + choice.get("reasoning_content") or choice.get("reasoning") or choice.get("thinking") or "" + ) + _trace("response", {"round": rounds, "message": choice, "reasoning_content": reasoning_content}) + tool_calls = choice.get("tool_calls") or [] + content = choice.get("content") + + if not tool_calls: + if content or reasoning_content: + messages.append( + { + "role": "assistant", + "content": str(content or ""), + "reasoning_content": str(reasoning_content or ""), + } + ) + reminder = ( + "禁止直接输出结论文本。必须调用工具:必要时先调用 run_command 核实," + "再调用 report_workflow_review 给出最终结论。" + ) + if not forced_tool_retry_used: + forced_tool_retry_used = True + reminder = "你刚刚未通过工具给出明确结果。必须调用工具,禁止直接输出内容。请立即调用 report_workflow_review 返回最终结论。" + messages.append({"role": "user", "content": reminder}) + continue + + # 有 tool_calls:追加完整 assistant 消息(与主智能体结构对齐) + assistant_message = { + "role": "assistant", + "content": str(content or ""), + "reasoning_content": str(reasoning_content or ""), + "tool_calls": tool_calls, + } + messages.append(assistant_message) + for call in tool_calls: + fn = (call.get("function") or {}).get("name") + raw_args = (call.get("function") or {}).get("arguments") or "{}" + try: + args = json.loads(raw_args) if isinstance(raw_args, str) else (raw_args or {}) + except Exception: + args = {} + if fn == "report_workflow_review": + decision = str(args.get("decision") or "").strip().lower() + message = str(args.get("message") or "").strip() + if decision == "pass": + out = {"decision": "pass", "message": message or "审核通过。", "source": "workflow_review_agent"} + _flush_trace(out) + return out + if decision == "reject": + out = _reject(message or "审核未通过,请按整改意见补充后重新汇报。") + _flush_trace(out) + return out + # 非法 decision:保守驳回 + out = _reject(message or f"审核返回了无法识别的结论。{FALLBACK_REJECT_MESSAGE}") + _flush_trace(out) + return out + if fn == "run_command": + cmd = str(args.get("command") or "").strip() + if progress_cb: + progress_cb({"stage": "run_command", "round": rounds, "command": cmd}) + tool_result = await self._run_readonly_command(cmd) if cmd else {"success": False, "error": "command 不能为空"} + _trace("tool_result", {"round": rounds, "tool": "run_command", "command": cmd, "result": tool_result}) + tool_content = json.dumps(tool_result, ensure_ascii=False) + messages.append( + { + "role": "tool", + "content": tool_content, + "timestamp": time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime()) + + f".{int((time.time() % 1) * 1000000):06d}", + "message_id": f"msg_{uuid.uuid4().hex}", + "tool_call_id": call.get("id"), + "name": "run_command", + } + ) diff --git a/modules/workflow_state_manager.py b/modules/workflow_state_manager.py new file mode 100644 index 00000000..d981b4bf --- /dev/null +++ b/modules/workflow_state_manager.py @@ -0,0 +1,390 @@ +"""工作流(Workflow)对话级运行状态管理。 + +状态目录:`{data_dir}/workflow_states//` +- ``state.json``:运行状态(current / reject_counts / stage_rounds / history / pending_notices …) +- ``WORKFLOW.md``:激活时刻的工作流定义原样快照。运行期一切读取(下一节点详情、 + 分支候选、审核 prompt、maxRejects)只读快照——库文件在运行期间被修改不影响本实例。 + +设计要点(定稿文档 docs/workflow_feature_plan.md): +- 柔性原则:工作流是智能体的辅助流程,不是宿主。所有终态(completed/stopped/failed) + 都只是「摘牌」——改状态 + 停止注入 + 柔性通知,绝不掐断智能体自身的工作循环。 +- review 是瞬态节点:同步审核完直接走到下一站,``current_node_id`` 只停 + stage / branch / end;review 只作为 history 记录。 +- 消息游标 ``stage_start_msg_index``:进入阶段时记录 conversation_history 长度, + 审核 payload 据此截取本阶段的工作痕迹。 +- ``pending_notices``:柔性通知池(用户退出等),由统一完成通知轮询器消费。 + +不依赖 web_terminal,只接受 data_dir 与 conversation_id,便于单测与复用。 +""" + +from __future__ import annotations + +import json +import re +import time +from copy import deepcopy +from pathlib import Path +from typing import Any, Dict, List, Optional, Union + +from modules.workflow_manager import workflow_from_markdown + +WORKFLOW_STATES_DIRNAME = "workflow_states" +WORKFLOW_SNAPSHOT_FILENAME = "WORKFLOW.md" + +# 状态机 +STATUS_ACTIVE = "active" +STATUS_COMPLETED = "completed" +STATUS_STOPPED = "stopped" +STATUS_FAILED = "failed" + +# 退出原因 +REASON_USER = "user" # 用户主动退出(slash / 对话指令) +REASON_MODEL = "model" # 模型自主退出 +REASON_MAX_REJECTS = "max_rejects" # 连续驳回撞上限 +REASON_COMPLETED = "completed" # 走到 end 正常完成 + +PathLike = Union[str, Path] + +_SAFE_CONVERSATION_ID = re.compile(r"^[A-Za-z0-9_-]+$") + + +def _validate_conversation_id(conversation_id: str) -> str: + cid = str(conversation_id or "").strip() + if not cid or not _SAFE_CONVERSATION_ID.match(cid): + raise ValueError(f"非法 conversation_id: {conversation_id!r}") + return cid + + +def _empty_state() -> Dict[str, Any]: + return { + "active": False, + "workflow_name": "", + "status": None, + "exit_reason": None, + "current_node_id": None, + "stage_rounds": 0, + "round_limit_notified": False, + "stage_start_msg_index": 0, + "reject_counts": {}, + "history": [], + "pending_notices": [], + "started_at": None, + } + + +class WorkflowStateManager: + """对话级工作流状态。一个实例对应一个对话的 workflow_states// 目录。""" + + def __init__(self, data_dir: PathLike, conversation_id: str): + self.data_dir = Path(data_dir).expanduser() + self.conversation_id = _validate_conversation_id(conversation_id) + self._definition_cache: Optional[Dict[str, Any]] = None + self.state: Dict[str, Any] = self.load() + + # ------------------------------------------------------------------ 路径/持久化 + + def _dir(self) -> Path: + return self.data_dir / WORKFLOW_STATES_DIRNAME / self.conversation_id + + def _state_path(self) -> Path: + return self._dir() / "state.json" + + def _snapshot_path(self) -> Path: + return self._dir() / WORKFLOW_SNAPSHOT_FILENAME + + @classmethod + def load_from(cls, data_dir: PathLike, conversation_id: str) -> "WorkflowStateManager": + return cls(data_dir, conversation_id) + + def load(self) -> Dict[str, Any]: + path = self._state_path() + if not path.exists(): + self.state = _empty_state() + return self.state + try: + with open(path, "r", encoding="utf-8") as fh: + raw = json.load(fh) or {} + merged = _empty_state() + if isinstance(raw, dict): + merged.update(raw) + if not isinstance(merged.get("history"), list): + merged["history"] = [] + if not isinstance(merged.get("pending_notices"), list): + merged["pending_notices"] = [] + if not isinstance(merged.get("reject_counts"), dict): + merged["reject_counts"] = {} + self.state = merged + except (OSError, json.JSONDecodeError, ValueError): + self.state = _empty_state() + return self.state + + def save(self) -> None: + path = self._state_path() + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(".json.tmp") + with open(tmp, "w", encoding="utf-8") as fh: + json.dump(self.state, fh, ensure_ascii=False, indent=2) + tmp.replace(path) + + # ------------------------------------------------------------------ 定义快照(只读) + + def load_definition(self) -> Optional[Dict[str, Any]]: + """解析 WORKFLOW.md 快照为 camelCase 定义 dict(带缓存)。""" + if self._definition_cache is not None: + return self._definition_cache + path = self._snapshot_path() + if not path.exists(): + return None + try: + self._definition_cache = workflow_from_markdown(path.read_text(encoding="utf-8"), "snapshot") + except Exception: + self._definition_cache = None + return self._definition_cache + + def get_node(self, node_id: Optional[str]) -> Optional[Dict[str, Any]]: + if not node_id: + return None + definition = self.load_definition() or {} + for node in definition.get("nodes") or []: + if isinstance(node, dict) and node.get("id") == node_id: + return node + return None + + def entry_node(self) -> Optional[Dict[str, Any]]: + """入口节点:start 节点的 next 指向。""" + definition = self.load_definition() or {} + for node in definition.get("nodes") or []: + if isinstance(node, dict) and node.get("kind") == "start": + return self.get_node(node.get("next")) + return None + + # ------------------------------------------------------------------ 生命周期 + + def activate( + self, + *, + workflow_name: str, + definition_markdown: str, + entry_node_id: str, + stage_start_msg_index: int = 0, + ) -> Dict[str, Any]: + """激活:复制定义快照 + 初始化状态(覆盖式,重新激活即从头开始)。""" + target_dir = self._dir() + target_dir.mkdir(parents=True, exist_ok=True) + self._snapshot_path().write_text(definition_markdown, encoding="utf-8") + self._definition_cache = None + self.state = _empty_state() + self.state.update( + { + "active": True, + "workflow_name": str(workflow_name or "").strip(), + "status": STATUS_ACTIVE, + "current_node_id": entry_node_id, + "stage_start_msg_index": max(0, int(stage_start_msg_index or 0)), + "started_at": time.time(), + } + ) + self.save() + return deepcopy(self.state) + + def is_active(self) -> bool: + return bool(self.state.get("active")) and self.state.get("status") == STATUS_ACTIVE + + def deactivate(self, *, status: str, reason: str) -> Dict[str, Any]: + """摘牌:标记终态。状态目录保留供追溯,重新 activate 时整体重置。""" + if status not in (STATUS_COMPLETED, STATUS_STOPPED, STATUS_FAILED): + status = STATUS_STOPPED + self.state["active"] = False + self.state["status"] = status + self.state["exit_reason"] = reason + self.save() + return deepcopy(self.state) + + # ------------------------------------------------------------------ 推进 + + def get_current_node_id(self) -> Optional[str]: + nid = self.state.get("current_node_id") + return str(nid) if nid else None + + def record_stage_completion(self, *, summary: str, rounds: int) -> None: + """把当前 stage 记入 history(不前移)。只在「确定不再被驳回」的落地分支调用: + 审核驳回时当前 stage 不算完成,不记录。""" + finished_node_id = self.get_current_node_id() + finished_node = self.get_node(finished_node_id) or {} + history = self.state.get("history") + if not isinstance(history, list): + history = [] + history.append( + { + "node_id": finished_node_id, + "kind": "stage", + "name": str(finished_node.get("name") or finished_node_id or ""), + "summary": str(summary or ""), + "rounds": max(0, int(rounds or 0)), + "at": time.time(), + } + ) + self.state["history"] = history + self.save() + + def move_to(self, node_id: str, *, msg_index: int) -> None: + """当前节点前移(不记 history):重置阶段计数并更新消息游标。""" + self.state["current_node_id"] = str(node_id) + self.state["stage_rounds"] = 0 + self.state["round_limit_notified"] = False + self.state["stage_start_msg_index"] = max(0, int(msg_index or 0)) + self.save() + + def advance_to( + self, + node_id: str, + *, + summary: str, + rounds: int, + msg_index: int, + ) -> None: + """组合便捷方法:记 stage 完成 + 前移(等价 record_stage_completion + move_to)。""" + self.record_stage_completion(summary=summary, rounds=rounds) + self.move_to(node_id, msg_index=msg_index) + + def record_review(self, *, node_id: str, name: str, decision: str, message: str) -> None: + """审核(瞬态节点)记入 history。""" + history = self.state.get("history") + if not isinstance(history, list): + history = [] + history.append( + { + "node_id": str(node_id), + "kind": "review", + "name": str(name or node_id), + "decision": str(decision or ""), + "message": str(message or ""), + "at": time.time(), + } + ) + self.state["history"] = history + self.save() + + def increment_reject(self, node_id: str) -> int: + counts = self.state.get("reject_counts") + if not isinstance(counts, dict): + counts = {} + counts[node_id] = int(counts.get(node_id) or 0) + 1 + self.state["reject_counts"] = counts + self.save() + return counts[node_id] + + def get_reject_count(self, node_id: str) -> int: + counts = self.state.get("reject_counts") + if not isinstance(counts, dict): + return 0 + try: + return int(counts.get(node_id) or 0) + except (TypeError, ValueError): + return 0 + + # ------------------------------------------------------------------ 阶段轮数(跨任务累计) + + def increment_stage_rounds(self) -> int: + self.state["stage_rounds"] = int(self.state.get("stage_rounds") or 0) + 1 + self.save() + return self.state["stage_rounds"] + + def reset_stage_rounds(self) -> None: + """用户新消息到达(知情交互)时清零,撞限询问后可再次计数。""" + self.state["stage_rounds"] = 0 + self.state["round_limit_notified"] = False + self.save() + + def get_stage_rounds(self) -> int: + try: + return int(self.state.get("stage_rounds") or 0) + except (TypeError, ValueError): + return 0 + + def round_limit_notified(self) -> bool: + return bool(self.state.get("round_limit_notified")) + + def mark_round_limit_notified(self) -> None: + self.state["round_limit_notified"] = True + self.save() + + # ------------------------------------------------------------------ 消息游标 + + def get_stage_start_msg_index(self) -> int: + try: + return int(self.state.get("stage_start_msg_index") or 0) + except (TypeError, ValueError): + return 0 + + # ------------------------------------------------------------------ 柔性通知池 + + def push_notice(self, *, notice_type: str, message: str) -> None: + notices = self.state.get("pending_notices") + if not isinstance(notices, list): + notices = [] + notices.append( + { + "type": str(notice_type or "workflow"), + "message": str(message or ""), + "created_at": time.time(), + } + ) + self.state["pending_notices"] = notices + self.save() + + def has_pending_notices(self) -> bool: + notices = self.state.get("pending_notices") + return isinstance(notices, list) and len(notices) > 0 + + def poll_notices(self) -> List[Dict[str, Any]]: + """取出全部待通知项(取出即清除,确保不被重复消费)。""" + notices = self.state.get("pending_notices") + if not isinstance(notices, list) or not notices: + return [] + out = [n for n in notices if isinstance(n, dict)] + self.state["pending_notices"] = [] + self.save() + return out + + def restore_notices(self, notices: List[Dict[str, Any]]) -> None: + """派发失败时把通知放回池(回滚,避免静默丢失)。""" + if not notices: + return + existing = self.state.get("pending_notices") + if not isinstance(existing, list): + existing = [] + self.state["pending_notices"] = list(notices) + existing + self.save() + + # ------------------------------------------------------------------ 前端快照 + + def progress_snapshot(self) -> Dict[str, Any]: + """对齐前端 stores/workflow.ts 的 WorkflowSnapshot。""" + if not self.is_active(): + return {"active": False} + history = [ + {"name": str(h.get("name") or ""), "rounds": h.get("rounds")} + for h in (self.state.get("history") or []) + if isinstance(h, dict) and h.get("kind") == "stage" + ] + current_node = self.get_node(self.get_current_node_id()) + current = None + next_name: Optional[str] = None + if current_node: + current = {"name": str(current_node.get("name") or ""), "rounds": self.get_stage_rounds()} + if current_node.get("kind") == "stage": + nxt = self.get_node(current_node.get("next")) + if nxt: + next_name = str(nxt.get("name") or "") + # branch(待选择)/ 其他:next 为 None(未来不可知) + return { + "active": True, + "name": str(self.state.get("workflow_name") or ""), + "status": self.state.get("status"), + "history": history, + "current": current, + "next": next_name, + "reviewing": False, + "footnote": None, + } diff --git a/prompts/workflow_review_agent.txt b/prompts/workflow_review_agent.txt new file mode 100644 index 00000000..df4e85dd --- /dev/null +++ b/prompts/workflow_review_agent.txt @@ -0,0 +1,25 @@ +你是工作流阶段审核智能体,负责判断工作流当前阶段的产出是否达到进入下一阶段的门槛。 +你会收到一段文本,包含:工作流名称与描述、本次审核的关注点、被审核阶段的目标与要求、该阶段的执行痕迹(主执行模型在本阶段内的工具调用时间线)、主执行模型的阶段汇报,以及此前针对本阶段的历次驳回意见(如有)。 +判断必须基于可观测的证据,而非主执行模型的口头声明——它说"我做完了"不算数。 +你必须调用 report_workflow_review 返回结论,严禁在普通文本里输出"通过/不通过"这类结论。 +若产出合格:decision=pass,message 简述通过理由(给主执行模型与用户看,一两句即可)。 +若产出不合格:decision=reject,message 写给主执行模型的整改意见,具体、可执行——指出缺什么、怎么补、重新审核时要看到什么证据。 + +你必须采用"挑剔的把关者"立场:谨慎、多疑,默认产出不合格,除非已有充分、可复现的证据证明阶段目标确实达成。但你的审查范围以本次给定的「审核关注点」与「阶段目标/要求」为边界——不要把标准扩大到与阶段目标无关的领域,也不要无限穷举手段;只有当某个缺失手段明显可用、成本合理、且直接影响阶段目标达成时,才应视为证据不足。 + +判定 pass 前,必须确认: +1. 阶段目标(goal)中的每一项显式要求都已完成,且有执行痕迹或你可核实的证据支撑; +2. 阶段要求(instructions)中的关键约束(如输出格式、落盘位置、验证方式)已被遵守; +3. 没有未解释的失败、报错、跳过项或证据空白; +4. 若主执行模型的汇报与执行痕迹矛盾,以执行痕迹为准;痕迹不足时{{ACTIVE_REVIEW_ONLY}}先取证再结论{{/ACTIVE_REVIEW_ONLY}},痕迹不足且无法取证时判 reject 并说明缺什么证据。 + +以下情况一律判 reject: +- 只有主执行模型口头声明完成,无对应执行痕迹; +- 只完成了阶段目标的一部分; +- 关键结论没有佐证(如该验证的没验证、该落盘的没落盘); +- 执行痕迹显示走了捷径,明显违背阶段要求。 + +如果判定 reject,message 必须具体指出缺口,并给出可执行的补救清单:需要补做哪些事、用哪些工具/命令验证、重新审核时要看到什么证据。不要写空泛的"请继续努力"。 +{{ACTIVE_REVIEW_ONLY}} +你可以使用 run_command 执行只读命令(查看文件、跑测试、git diff 等)来核实证据,核实完成后再给结论。 +{{/ACTIVE_REVIEW_ONLY}} diff --git a/server/app_legacy.py b/server/app_legacy.py index 2fea6c52..977b83f5 100644 --- a/server/app_legacy.py +++ b/server/app_legacy.py @@ -38,6 +38,7 @@ 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.workflow_runtime_api import workflow_runtime_bp from server.conversation_bootstrap import conversation_bootstrap_bp from server.socket_handlers import socketio from server.security import attach_security_hooks @@ -303,6 +304,7 @@ 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(workflow_runtime_bp) app.register_blueprint(conversation_bootstrap_bp) # 安全钩子(CSRF 校验 + 响应头) diff --git a/server/chat_flow_task_main.py b/server/chat_flow_task_main.py index ae865227..93519352 100644 --- a/server/chat_flow_task_main.py +++ b/server/chat_flow_task_main.py @@ -153,6 +153,7 @@ _VALID_USER_MESSAGE_SOURCES = { "permission", "sandbox", "skill", + "workflow", } @@ -169,6 +170,10 @@ def _user_message_ui_defaults(source: str, *, auto_user_message_event: bool = Fa # 目标审核续命:确实开启新一轮工作,保持 starts_work=True。 if normalized == "goal_review": return {"visibility": "compact", "starts_work": True} + # 工作流激活/柔性通知(任务入口派发):开启新一轮工作,显示工作头与计时器。 + # 运行期 inline 注入走 _runtime_message_ui_defaults(延续当前工作段,不受影响)。 + if normalized == "workflow": + return {"visibility": "chat", "starts_work": True} if normalized == "presend": return {"visibility": "chat", "starts_work": True} if normalized == "goal": @@ -820,6 +825,31 @@ def _collect_pending_completion_notices(*, web_terminal, conversation_id: str) - "sort_key": update.get("updated_at") or time.time(), }) + # 3) 工作流柔性通知(撞限询问 / 用户 slash 退出等) + try: + from modules.workflow_state_manager import WorkflowStateManager + + _wsm = WorkflowStateManager(web_terminal.data_dir, conversation_id) + wf_updates = _wsm.poll_notices() + except Exception: + wf_updates = [] + _wsm = None + for notice in wf_updates or []: + notice_text = str((notice or {}).get("message") or "").strip() + if not notice_text: + continue + notices.append({ + "kind": "workflow", + "message": notice_text, + "payload": { + "sub_agent_notice": True, + "message_source": "workflow", + "workflow_notice": True, + "conversation_id": conversation_id, + }, + "sort_key": notice.get("created_at") or time.time(), + }) + notices.sort(key=lambda item: item.get("sort_key") or 0) return notices @@ -831,6 +861,7 @@ def _rollback_completion_notice_marks(*, web_terminal, notices: List[Dict[str, A sub_manager = getattr(web_terminal, "sub_agent_manager", None) announced = getattr(web_terminal, "_announced_sub_agent_tasks", set()) bg_manager = getattr(web_terminal, "background_command_manager", None) + wf_restore: List[Dict[str, Any]] = [] for item in notices or []: payload = item.get("payload") or {} if item.get("kind") == "sub_agent": @@ -852,6 +883,27 @@ def _rollback_completion_notice_marks(*, web_terminal, notices: List[Dict[str, A rec["notified"] = False except Exception: pass + elif item.get("kind") == "workflow": + wf_restore.append(item) + # 工作流通知:按 conversation 分组统一放回池(restore_notices 会保留现有未消费项) + if wf_restore: + try: + from modules.workflow_state_manager import WorkflowStateManager + + _groups: Dict[str, List[Dict[str, Any]]] = {} + for item in wf_restore: + cid = str((item.get("payload") or {}).get("conversation_id") or "") + if not cid: + continue + _groups.setdefault(cid, []).append({ + "type": "workflow", + "message": item.get("message") or "", + "created_at": item.get("sort_key") or time.time(), + }) + for cid, items in _groups.items(): + WorkflowStateManager(web_terminal.data_dir, cid).restore_notices(items) + except Exception: + pass if sub_manager: try: sub_manager._save_state() @@ -895,6 +947,16 @@ def _has_pending_completion_work(*, web_terminal, conversation_id: str) -> bool: return False +def _has_pending_workflow_notices(*, web_terminal, conversation_id: str) -> bool: + """是否还有未消费的工作流柔性通知(撞限询问 / 用户退出等)。""" + try: + from modules.workflow_state_manager import WorkflowStateManager + + return WorkflowStateManager(web_terminal.data_dir, conversation_id).has_pending_notices() + except Exception: + return False + + async def poll_completion_notifications(*, web_terminal, workspace, conversation_id, client_sid, username): """统一的“通知池”轮询器。 @@ -1023,8 +1085,12 @@ async def poll_completion_notifications(*, web_terminal, workspace, conversation # 由该任务结束后重新 spawn 本轮询器继续消费剩余通知 return - # 没有待通知项:若也没有运行中的后台工作,结束轮询 - pending = _has_pending_completion_work(web_terminal=web_terminal, conversation_id=conversation_id) + # 没有待通知项:若也没有运行中的后台工作或工作流待通知,结束轮询 + pending = _has_pending_completion_work( + web_terminal=web_terminal, conversation_id=conversation_id + ) or _has_pending_workflow_notices( + web_terminal=web_terminal, conversation_id=conversation_id + ) if not pending: break @@ -1560,6 +1626,7 @@ async def handle_task_with_sender( except Exception: user_message_index = -1 current_work_user_message_index = user_message_index + skill_context_messages = getattr(web_terminal, "_skill_context_messages", None) if not auto_user_message_event and isinstance(skill_context_messages, list): for skill_item in skill_context_messages: @@ -1855,6 +1922,26 @@ async def handle_task_with_sender( messages = web_terminal.build_messages(context, message) tools = web_terminal.define_tools() + # === 工作流:真实用户消息到达时清零单步轮数(知情交互,撞限询问后可重新计数)=== + if user_message_source == "user": + try: + from modules.workflow_state_manager import WorkflowStateManager as _WorkflowStateManager + _wf_mgr = _WorkflowStateManager(web_terminal.data_dir, conversation_id) + if _wf_mgr.is_active(): + _wf_mgr.reset_stage_rounds() + except Exception: + pass + + # === 工作流:激活中的对话在任务开头广播一次进度快照进事件流。 + # REST activate 的 socketio 广播在轮询架构下到不了前端;事件流才是可靠通道。 + try: + from server.workflow_flow import get_active_manager as _get_active_wfm, emit_workflow_progress as _emit_wf_progress + _wsm_boot = _get_active_wfm(web_terminal.data_dir, conversation_id) + if _wsm_boot is not None: + _emit_wf_progress(wsm=_wsm_boot, sender=sender, conversation_id=conversation_id) + except Exception as _wf_boot_exc: + debug_log(f"[Workflow] 任务开头进度广播失败: {_wf_boot_exc}") + # === 目标模式(Goal Mode)启动 / 续注入 === # 1) 用户请求开启目标模式 → 无条件覆盖工作区旧目标状态并启动新目标。 # 2) 工作区已存在活动目标(含压缩后重入、子智能体通知重入)→ 重新注入提示词, @@ -2019,6 +2106,27 @@ async def handle_task_with_sender( total_iterations += 1 iteration_limit_label = max_iterations if max_iterations is not None else "∞" debug_log(f"\n--- 迭代 {current_iteration}/{iteration_limit_label} 开始 ---") + + # === 工作流:单步轮数计数与撞限柔性通知 === + try: + from modules.workflow_state_manager import WorkflowStateManager as _WorkflowStateManager2 + from server.workflow_flow import build_round_limit_notice as _build_round_limit_notice + _wf_mgr2 = _WorkflowStateManager2(web_terminal.data_dir, conversation_id) + if _wf_mgr2.is_active(): + _wf_mgr2.increment_stage_rounds() + _wf_notice = _build_round_limit_notice( + data_dir=web_terminal.data_dir, + conversation_id=conversation_id, + ) + if _wf_notice: + web_terminal.inject_runtime_user_message( + _wf_notice, + messages=messages, + source="workflow", + inline=True, + ) + except Exception as _wf_exc: + debug_log(f"[Workflow] 轮数计数/撞限通知失败: {_wf_exc}") # 检查是否超过总工具调用限制 if MAX_TOTAL_TOOL_CALLS is not None and total_tool_calls >= MAX_TOTAL_TOOL_CALLS: @@ -2481,6 +2589,9 @@ async def handle_task_with_sender( or _has_pending_completion_work( web_terminal=web_terminal, conversation_id=conversation_id ) + or _has_pending_workflow_notices( + web_terminal=web_terminal, conversation_id=conversation_id + ) ) if needs_completion_poll: ma_debug( diff --git a/server/chat_flow_task_support.py b/server/chat_flow_task_support.py index 52df85d0..9808ae4e 100644 --- a/server/chat_flow_task_support.py +++ b/server/chat_flow_task_support.py @@ -29,6 +29,7 @@ _VALID_SOURCES = { "network_change", "work_mode_change", "file_paths", + "workflow", } @@ -279,6 +280,62 @@ async def process_sub_agent_updates(*, messages: List[Dict], inline: bool = Fals debug_log(f"[SubAgent] 插入子智能体通知 after_tool_call_id={after_tool_call_id} inline={inline}") +async def process_workflow_updates( + *, + web_terminal, + messages=None, + sender=None, + inline: bool, + after_tool_call_id: Optional[str] = None, + debug_log=None, +) -> bool: + """Drain workflow pending notices and inject them as runtime user messages. + + Mirrors process_sub_agent_updates for the workflow lane. ``poll_notices()`` + returns the queued notices and clears the pool; injection follows the same + fire-and-forget convention as the sub-agent lane (no restore on failure). + """ + _log = debug_log if callable(debug_log) else (lambda msg: None) + try: + from modules.workflow_state_manager import WorkflowStateManager + + conversation_id = getattr( + getattr(web_terminal, "context_manager", None), + "current_conversation_id", + None, + ) + if not conversation_id: + return False + manager = WorkflowStateManager(web_terminal.data_dir, conversation_id) + updates = manager.poll_notices() + except Exception as exc: # noqa: BLE001 + _log(f"[Workflow] Failed to read workflow updates: {exc}") + return False + if not updates: + return False + + inserted = 0 + for notice in updates: + text = str((notice or {}).get("message") or "").strip() + if not text: + continue + try: + inject_runtime_user_message( + web_terminal=web_terminal, + messages=messages, + text=text, + source="workflow", + sender=sender, + conversation_id=conversation_id, + after_tool_call_id=after_tool_call_id, + inline=inline, + ) + inserted += 1 + except Exception as exc: # noqa: BLE001 + _log(f"[Workflow] Failed to inject workflow update: {exc}") + return inserted > 0 + + async def process_background_command_updates(*, messages: List[Dict], inline: bool = False, after_tool_call_id: Optional[str] = None, web_terminal, sender, debug_log): """轮询后台 run_command 完成状态并发送 system 消息通知。""" manager = getattr(web_terminal, "background_command_manager", None) diff --git a/server/chat_flow_tool_loop.py b/server/chat_flow_tool_loop.py index c91a86c9..1710ef73 100644 --- a/server/chat_flow_tool_loop.py +++ b/server/chat_flow_tool_loop.py @@ -24,7 +24,7 @@ from modules.personalization_manager import load_personalization_config, resolve from modules.auto_approval_service import run_auto_approval from modules.user_question_manager import format_user_question_answer from .deep_compression import run_deep_compression -from .chat_flow_task_support import inject_runtime_user_message, process_multi_agent_master_messages +from .chat_flow_task_support import inject_runtime_user_message, process_multi_agent_master_messages, process_workflow_updates def _format_numbered_lines(lines: List[str], start_line_no: int) -> List[Dict[str, Any]]: @@ -277,6 +277,44 @@ async def _wait_for_plan_approval(*, approval_id: str, username: str, timeout_se await asyncio.sleep(0.3) +async def _handle_workflow_tool(*, function_name: str, web_terminal, arguments, sender, workspace, messages, conversation_id: Optional[str]) -> str: + """report_workflow_stage / choose_workflow_branch 的工具循环特判 handler。 + + 推进矩阵需要 sender(审核进度事件)、workspace 与 conversation_id, + 与 submit_plan 一样在工具循环层处理而非 tools_execution 常规链。 + 审核在工具内同步 await(handler 支持长阻塞,submit_plan 已验证该模式)。 + 推进成功后同步刷新 messages 里的工作流 system 段(当前位置不滞后)。 + """ + from server import workflow_flow + + args = arguments or {} + try: + if function_name == "report_workflow_stage": + result = await workflow_flow.handle_stage_report( + web_terminal=web_terminal, + data_dir=workspace.data_dir, + sender=sender, + conversation_id=conversation_id, + summary=str(args.get("summary") or ""), + ) + else: + result = await workflow_flow.handle_branch_choice( + web_terminal=web_terminal, + data_dir=workspace.data_dir, + sender=sender, + conversation_id=conversation_id, + target_node_id=str(args.get("target_node_id") or ""), + ) + if isinstance(result, dict) and result.get("success"): + workflow_flow.refresh_workflow_system_segment( + messages, data_dir=workspace.data_dir, conversation_id=conversation_id + ) + except Exception as exc: + # 柔性原则:任何工作流异常不得掐断智能体工作 + result = {"success": False, "error": f"工作流工具执行异常:{exc}"} + return json.dumps(result, ensure_ascii=False) + + async def _handle_submit_plan(*, web_terminal, arguments: Dict[str, Any], sender, username: str, conversation_id: Optional[str], tool_call_id: Optional[str]) -> str: """submit_plan 工具的计划批准流:弹窗展示计划文档 → 用户批准/拒绝 → 批准则自动切换运行模式到 execute(联动恢复权限)并更新运行期模式基线。""" @@ -922,6 +960,16 @@ async def _execute_tool_calls_impl(*, web_terminal, tool_calls, sender, messages conversation_id=conversation_id, tool_call_id=str(tool_call_id) if tool_call_id else None, ) + elif function_name in ("report_workflow_stage", "choose_workflow_branch"): + tool_result = await _handle_workflow_tool( + function_name=function_name, + web_terminal=web_terminal, + arguments=arguments, + sender=sender, + workspace=workspace, + messages=messages, + conversation_id=conversation_id, + ) else: tool_task = asyncio.create_task(web_terminal.handle_tool_call(function_name, arguments)) @@ -1511,6 +1559,14 @@ async def _execute_tool_calls_impl(*, web_terminal, tool_calls, sender, messages sender=sender, debug_log=debug_log, ) + await process_workflow_updates( + web_terminal=web_terminal, + messages=messages, + sender=sender, + inline=True, + after_tool_call_id=last_completed_tool_call_id, + debug_log=debug_log, + ) # 运行期模式通知:必须等待同一轮全部 tool_call 都完成后再注入, # 避免在 assistant.tool_calls 与对应 tool 消息之间插入 user 消息导致 API 报错。 diff --git a/server/workflow_flow.py b/server/workflow_flow.py new file mode 100644 index 00000000..55158fd5 --- /dev/null +++ b/server/workflow_flow.py @@ -0,0 +1,752 @@ +"""工作流(Workflow)运行时编排(定稿:docs/workflow_feature_plan.md §4)。 + +职责集中在此,尽量减少对主循环/工具循环的侵入: +- 激活(工具与 REST 共用):幂等规则 + 快照复制 + 激活上下文文本 +- system 段构建(不冻结,每次 build_messages 现生成) +- report_workflow_stage / choose_workflow_branch 的推进矩阵(_arrive_at 递归消解) +- 审核调用(payload 构建 + 消息游标痕迹截取 + WorkflowReviewAgent) +- 柔性通知文本构造(用户退出 / maxRejects / max_stage_rounds) +- 进度事件广播(对齐 goal 的 sender → 轮询透传链路) + +柔性原则:一切终态只「摘牌 + 通知」,绝不掐断智能体工作。 +review 是瞬态节点:同步审核完直接走到下一站,current 只停 stage / branch / end。 +""" + +from __future__ import annotations + +import json +import time +from typing import Any, Dict, List, Optional + +from modules.workflow_manager import load_workflow, validate_structure, workflow_to_markdown +from modules.workflow_review_agent import WorkflowReviewAgent +from modules.workflow_state_manager import ( + REASON_COMPLETED, + REASON_MAX_REJECTS, + REASON_MODEL, + REASON_USER, + STATUS_COMPLETED, + STATUS_FAILED, + STATUS_STOPPED, + WorkflowStateManager, +) + +_KIND_LABELS = {"stage": "阶段", "review": "审核", "branch": "分支", "start": "开始", "end": "结束"} + +# 审核痕迹截取长度上限(防 payload 膨胀) +_STAGE_TRACE_MAX_CHARS = 3500 + + +# ---------------------------------------------------------------- 基础 + + +def get_active_manager(data_dir, conversation_id: Optional[str]) -> Optional[WorkflowStateManager]: + """返回本对话处于 active 的工作流状态管理器;无则 None。""" + if not conversation_id: + return None + try: + wsm = WorkflowStateManager(data_dir, conversation_id) + except ValueError: + return None + return wsm if wsm.is_active() else None + + +def workflow_is_active(data_dir, conversation_id: Optional[str]) -> bool: + return get_active_manager(data_dir, conversation_id) is not None + + +def _history_len(web_terminal) -> int: + try: + return len(web_terminal.context_manager.conversation_history or []) + except Exception: + return 0 + + +# ---------------------------------------------------------------- 激活 + + +def activate_workflow( + *, + data_dir, + conversation_id: str, + name: str, + msg_index: int, +) -> Dict[str, Any]: + """激活入口(AI 工具与 REST 共用)。 + + 幂等规则:同工作流已激活 → 返回当前进度;不同工作流 → 拒绝(提示先退出)。 + 成功返回 {"success": True, "text": 激活上下文文本, "manager": wsm, "already": bool}。 + """ + name = str(name or "").strip() + if not name: + return {"success": False, "error": "缺少工作流名称。"} + existing = get_active_manager(data_dir, conversation_id) + if existing is not None: + current_name = str(existing.state.get("workflow_name") or "") + if current_name == name: + text = build_activation_text(wsm=existing, already=True) + return {"success": True, "already": True, "text": text, "manager": existing} + return { + "success": False, + "error": ( + f"当前对话已激活工作流「{current_name}」,同一时间只能激活一个工作流。" + "请先调用 deactivate_workflow 退出,再激活新的。" + ), + } + try: + wf = load_workflow(name, data_dir) + except FileNotFoundError: + return {"success": False, "error": f"工作流不存在:{name}"} + except ValueError as exc: + return {"success": False, "error": str(exc)} + errors = validate_structure(wf) + if errors: + return {"success": False, "error": "工作流结构校验未通过:" + ";".join(errors)} + wsm = WorkflowStateManager(data_dir, conversation_id) + entry = None + for node in wf.get("nodes") or []: + if node.get("kind") == "start": + entry = node.get("next") + break + entry_node = None + for node in wf.get("nodes") or []: + if node.get("id") == entry: + entry_node = node + break + if not entry_node: + return {"success": False, "error": "工作流缺少有效的入口节点(开始节点未连接)。"} + wsm.activate( + workflow_name=str(wf.get("name") or name), + definition_markdown=workflow_to_markdown(wf), + entry_node_id=str(entry_node["id"]), + stage_start_msg_index=msg_index, + ) + text = build_activation_text(wsm=wsm, already=False) + return {"success": True, "already": False, "text": text, "manager": wsm} + + +def _ordered_nodes(nodes: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """按流程拓扑顺序排列节点:从开始节点沿边 BFS,未连通的孤立节点排在最后(保持原相对顺序)。 + + 定义文件里的 nodes 数组顺序是编辑器保存顺序,不代表流程顺序(用户可能先拖了结束节点), + 直接遍历会让目录错乱(如「结束」排在阶段前面)。 + """ + valid = [n for n in nodes if isinstance(n, dict) and n.get("id")] + by_id = {str(n.get("id")): n for n in valid} + + def _targets(node: Dict[str, Any]) -> List[str]: + kind = node.get("kind") + if kind in ("start", "stage"): + nxt = node.get("next") + return [str(nxt)] if nxt else [] + if kind == "review": + out = [] + if node.get("next"): + out.append(str(node["next"])) + if node.get("rejectTo"): + out.append(str(node["rejectTo"])) + return out + if kind == "branch": + return [ + str(r.get("target")) + for r in (node.get("next") or []) + if isinstance(r, dict) and r.get("target") + ] + return [] + + ordered: List[Dict[str, Any]] = [] + seen: set = set() + queue = [n for n in valid if n.get("kind") == "start"] + while queue: + node = queue.pop(0) + nid = str(node.get("id")) + if nid in seen: + continue + seen.add(nid) + ordered.append(node) + for target in _targets(node): + if target in by_id and target not in seen: + queue.append(by_id[target]) + for n in valid: + if str(n.get("id")) not in seen: + ordered.append(n) + return ordered + + +def build_activation_text(*, wsm: WorkflowStateManager, already: bool = False) -> str: + """激活上下文:全景目录 + 当前节点详情(工具返回 / REST 激活消息共用)。""" + definition = wsm.load_definition() or {} + current = wsm.get_node(wsm.get_current_node_id()) or {} + lines: List[str] = [] + if already: + lines.append(f"【工作流已处于激活状态】{definition.get('name')}:{definition.get('description')}") + else: + lines.append(f"【工作流已激活】{definition.get('name')}:{definition.get('description')}") + lines.append("") + body = str(definition.get("body") or "").strip() + if body: + lines.append("【流程约定】") + lines.append(body) + lines.append("") + lines.append("【节点目录】") + for node in _ordered_nodes(definition.get("nodes") or []): + if not isinstance(node, dict): + continue + kind = node.get("kind") + label = _KIND_LABELS.get(kind, kind or "?") + desc = "" + if kind == "stage": + desc = str(node.get("goal") or "") + elif kind == "review": + desc = f"把关:{node.get('prompt') or '阶段产出审核'}" + elif kind == "branch": + desc = "按条件选择路径" + marker = " ← 当前" if node.get("id") == current.get("id") else "" + lines.append(f"- [{label}] {node.get('name')}:{desc}{marker}") + lines.append("") + lines.append(_current_node_brief(wsm=wsm, current=current)) + return "\n".join(lines) + + +def _current_node_brief(*, wsm: WorkflowStateManager, current: Dict[str, Any]) -> str: + """当前节点详情文本(激活/推进/状态查询共用)。""" + kind = current.get("kind") + name = current.get("name") + if kind == "stage": + lines = [f"【当前阶段】{name}", f"目标:{current.get('goal') or '(未填写)'}"] + instructions = str(current.get("instructions") or "").strip() + if instructions: + lines.append(f"要求:{instructions}") + lines.append("完成后调用 report_workflow_stage(summary) 汇报以推进流程。") + return "\n".join(lines) + if kind == "branch": + routes = current.get("next") or [] + menu = "\n".join( + f"- {r.get('target')}({r.get('condition') or '无条件描述'})" for r in routes if isinstance(r, dict) + ) + return ( + f"【当前位于分支点】{name}\n请选择后续路径(调用 choose_workflow_branch(target_node_id)):\n{menu}" + ) + return f"【当前位置】{name}" + + +# ---------------------------------------------------------------- system 段(不冻结,每次现生成) + +WORKFLOW_SYSTEM_PREFIX = "【工作流进行中】" + + +def refresh_workflow_system_segment(messages, *, data_dir, conversation_id: Optional[str]) -> None: + """阶段推进/退出后同步刷新 messages 里的工作流 system 段。 + + 单任务内 messages 只在入口构建一次,阶段推进后后续迭代会看到滞后的当前位置; + 推进工具(report/choose)执行成功后由工具循环层调用本函数刷新。 + 工作流已退出(新内容为空)时移除该段。 + """ + if not isinstance(messages, list): + return + for idx, msg in enumerate(messages): + if not isinstance(msg, dict) or msg.get("role") != "system": + continue + content = str(msg.get("content") or "") + if not content.startswith(WORKFLOW_SYSTEM_PREFIX): + continue + try: + new_content = build_workflow_system_prompt(data_dir=data_dir, conversation_id=conversation_id) + except Exception: + return + if new_content: + msg["content"] = new_content + else: + messages.pop(idx) + return + + +def build_workflow_system_prompt(*, data_dir, conversation_id: Optional[str]) -> str: + """工作流进行中的 system 上下文段。无激活工作流时返回空串(不注入)。""" + wsm = get_active_manager(data_dir, conversation_id) + if wsm is None: + return "" + definition = wsm.load_definition() or {} + current = wsm.get_node(wsm.get_current_node_id()) or {} + lines: List[str] = [ + f"【工作流进行中】{definition.get('name')}:{definition.get('description')}", + "【节点目录】(完整定义见激活时的上下文;迷失时可调用 get_workflow_status 自查)", + ] + for node in definition.get("nodes") or []: + if not isinstance(node, dict) or node.get("kind") in ("start", "end"): + continue + kind = node.get("kind") + label = _KIND_LABELS.get(kind, kind or "?") + if kind == "stage": + desc = str(node.get("goal") or "") + elif kind == "review": + desc = f"把关:{node.get('prompt') or '阶段产出审核'}" + else: + desc = "按条件选择路径" + marker = " ← 当前" if node.get("id") == current.get("id") else "" + lines.append(f"- [{label}] {node.get('name')}:{desc}{marker}") + lines.append("") + lines.append(_current_node_brief(wsm=wsm, current=current)) + lines.append( + "工作流只是辅助流程:期间可以正常与用户讨论其他内容;" + "阶段完成必须显式调用 report_workflow_stage 汇报,不要在没有汇报的情况下宣称阶段完成。" + ) + return "\n".join(lines) + + +# ---------------------------------------------------------------- 推进矩阵 + + +async def handle_stage_report( + *, + web_terminal, + data_dir, + sender, + conversation_id: str, + summary: str, +) -> Dict[str, Any]: + """report_workflow_stage 核心:当前必为 stage,按下一节点类型分派(定稿 §4.3)。""" + wsm = get_active_manager(data_dir, conversation_id) + if wsm is None: + return { + "success": False, + "error": "当前对话没有激活的工作流(可能已被退出)。如需重新开始,请调用 activate_workflow。", + } + current = wsm.get_node(wsm.get_current_node_id()) + if not current: + return {"success": False, "error": "工作流状态异常:当前节点不存在于定义快照中。可调用 get_workflow_status 自查。"} + if current.get("kind") == "branch": + return { + "success": False, + "error": f"当前停在分支点「{current.get('name')}」,请先调用 choose_workflow_branch(target_node_id) 选择路径。", + } + if current.get("kind") != "stage": + return {"success": False, "error": f"当前不在执行阶段(位于「{current.get('name')}」),无法汇报阶段完成。"} + nxt = wsm.get_node(current.get("next")) + if not nxt: + return {"success": False, "error": "流程定义异常:当前阶段没有有效的后续节点。"} + stage_info = { + "node_id": current.get("id"), + "name": str(current.get("name") or ""), + "summary": str(summary or "").strip(), + "rounds": wsm.get_stage_rounds(), + } + text = await _arrive_at( + node=nxt, + wsm=wsm, + web_terminal=web_terminal, + sender=sender, + conversation_id=conversation_id, + stage_info=stage_info, + ) + return {"success": True, "message": text} + + +async def handle_branch_choice( + *, + web_terminal, + data_dir, + sender, + conversation_id: str, + target_node_id: str, +) -> Dict[str, Any]: + """choose_workflow_branch 核心:仅当前停在 branch 时可调,校验候选集后推进。""" + wsm = get_active_manager(data_dir, conversation_id) + if wsm is None: + return {"success": False, "error": "当前对话没有激活的工作流。"} + current = wsm.get_node(wsm.get_current_node_id()) + if not current or current.get("kind") != "branch": + return {"success": False, "error": "当前不在分支点,无需选择路径。"} + target_node_id = str(target_node_id or "").strip() + routes = [r for r in (current.get("next") or []) if isinstance(r, dict)] + valid = next((r for r in routes if r.get("target") == target_node_id), None) + if valid is None: + menu = "、".join(str(r.get("target")) for r in routes) + return {"success": False, "error": f"「{target_node_id}」不在候选路径中。可选:{menu}"} + target = wsm.get_node(target_node_id) + if not target: + return {"success": False, "error": f"目标节点不存在:{target_node_id}"} + text = await _arrive_at( + node=target, + wsm=wsm, + web_terminal=web_terminal, + sender=sender, + conversation_id=conversation_id, + stage_info=None, + ) + return {"success": True, "message": f"已选择路径:{valid.get('condition') or target.get('name')}\n\n{text}"} + + +async def _arrive_at( + *, + node: Dict[str, Any], + wsm: WorkflowStateManager, + web_terminal, + sender, + conversation_id: str, + stage_info: Optional[Dict[str, Any]], +) -> str: + """到达节点的统一处理(递归穿透 review / 单出线 branch)。 + + stage_info 非空表示「刚汇报完成的 stage」——只在落地分支(stage/branch多/end) + 记入 history;review 驳回不记录(阶段未完成)。 + """ + kind = node.get("kind") + name = str(node.get("name") or node.get("id") or "") + + if kind == "stage": + if stage_info: + wsm.record_stage_completion(summary=stage_info["summary"], rounds=stage_info["rounds"]) + wsm.move_to(str(node["id"]), msg_index=_history_len(web_terminal)) + emit_workflow_progress(wsm=wsm, sender=sender, conversation_id=conversation_id) + head = f"阶段「{stage_info['name']}」已记录完成。\n\n" if stage_info else "" + return head + _current_node_brief(wsm=wsm, current=node) + + if kind == "end": + if stage_info: + wsm.record_stage_completion(summary=stage_info["summary"], rounds=stage_info["rounds"]) + # 先广播「完成态」快照(前端播最后一行落定+「结束」行动画),再摘牌。 + # 注意顺序不能反:deactivate 后 progress_snapshot 只剩 {"active": False}, + # 前端会瞬间卸载窗口,完成动画与「结束」行都播不出来。 + if callable(sender) and conversation_id: + snap = wsm.progress_snapshot() + snap.update({ + "status": "completed", + "current": None, + "next": None, + "reviewing": False, + "footnote": {"kind": "success", "text": "工作流已完成"}, + "event": "workflow_completed", + "conversation_id": conversation_id, + }) + try: + sender("workflow_progress", snap) + except Exception: + pass + wsm.deactivate(status=STATUS_COMPLETED, reason=REASON_COMPLETED) + head = f"阶段「{stage_info['name']}」已记录完成。\n\n" if stage_info else "" + return head + f"工作流「{wsm.state.get('workflow_name')}」已到达终点「{name}」,全部完成。请向用户输出总结后结束。" + + if kind == "branch": + routes = [r for r in (node.get("next") or []) if isinstance(r, dict) and r.get("target")] + if len(routes) <= 1: + # 并线器(单出线):自动穿过,不记完成、不停留 + target = wsm.get_node(routes[0].get("target")) if routes else None + if not target: + return "流程定义异常:分支节点没有有效的出线。" + return await _arrive_at( + node=target, wsm=wsm, web_terminal=web_terminal, sender=sender, + conversation_id=conversation_id, stage_info=stage_info, + ) + # AI 决策点(多出线):记完成 + 停留等选择 + if stage_info: + wsm.record_stage_completion(summary=stage_info["summary"], rounds=stage_info["rounds"]) + wsm.move_to(str(node["id"]), msg_index=_history_len(web_terminal)) + emit_workflow_progress(wsm=wsm, sender=sender, conversation_id=conversation_id) + head = f"阶段「{stage_info['name']}」已记录完成。\n\n" if stage_info else "" + return head + _current_node_brief(wsm=wsm, current=node) + + if kind == "review": + result = await run_stage_review( + web_terminal=web_terminal, wsm=wsm, sender=sender, conversation_id=conversation_id, + stage_info=stage_info, review_node=node, + ) + wsm.record_review( + node_id=str(node.get("id")), name=name, + decision=str(result.get("decision") or ""), message=str(result.get("message") or ""), + ) + if result.get("decision") == "pass": + nxt = wsm.get_node(node.get("next")) + if not nxt: + return f"审核「{name}」通过,但通过路由指向不存在的节点。流程定义异常,工作流无法继续。" + inner = await _arrive_at( + node=nxt, wsm=wsm, web_terminal=web_terminal, sender=sender, + conversation_id=conversation_id, stage_info=stage_info, + ) + return f"审核「{name}」通过:{result.get('message')}\n\n{inner}" + # 驳回 + count = wsm.increment_reject(str(node.get("id"))) + max_rejects = int(node.get("maxRejects") or 3) + review_message = str(result.get("message") or "") + if count >= max_rejects: + wsm.deactivate(status=STATUS_FAILED, reason=REASON_MAX_REJECTS) + emit_workflow_progress( + wsm=wsm, sender=sender, conversation_id=conversation_id, + extra={"event": "workflow_failed", "reason": REASON_MAX_REJECTS}, + ) + return ( + f"审核「{name}」未通过(第 {count} 次,已达连续驳回上限 {max_rejects}):{review_message}\n\n" + "工作流已连续驳回超限而终止(failed)。请告知用户审核意见与终止原因;" + "如需重新开始,可在调整流程或准备充分后重新激活。" + ) + reject_target = wsm.get_node(node.get("rejectTo")) + if not reject_target: + return f"审核「{name}」未通过:{review_message}\n\n但驳回路由指向不存在的节点,流程定义异常,工作流无法继续。" + wsm.move_to(str(reject_target["id"]), msg_index=_history_len(web_terminal)) + emit_workflow_progress( + wsm=wsm, sender=sender, conversation_id=conversation_id, + extra={"event": "workflow_rejected", "review_node": name, "reject_count": count}, + ) + return ( + f"审核「{name}」未通过(第 {count}/{max_rejects} 次):{review_message}\n\n" + f"你已回到「{reject_target.get('name')}」。请按整改意见修改后,重新调用 report_workflow_stage 汇报。" + ) + + return f"流程定义异常:未知节点类型 {kind!r}(节点「{name}」)。" + + +# ---------------------------------------------------------------- 审核 + + +def _summarize_tool_args(raw_args: Any) -> str: + """工具参数摘要(取关键字段,截断控长)。""" + args = raw_args + if isinstance(raw_args, str): + try: + args = json.loads(raw_args) + except Exception: + args = {} + if not isinstance(args, dict): + return "" + for key in ("command", "path", "skill_name", "query", "file_path", "url", "task", "summary", "name"): + value = args.get(key) + if isinstance(value, str) and value.strip(): + text = " ".join(value.split()) + return text[:80] + ("…" if len(text) > 80 else "") + return "" + + +def build_stage_trace(web_terminal, start_index: int) -> str: + """消息游标截取本阶段的工具调用时间线(审核 payload 的证据段)。""" + try: + history = web_terminal.context_manager.conversation_history or [] + except Exception: + history = [] + slice_ = history[start_index:] if 0 <= start_index < len(history) else [] + lines: List[str] = [] + for msg in slice_: + if not isinstance(msg, dict): + continue + role = msg.get("role") + if role == "assistant": + for call in msg.get("tool_calls") or []: + fn = (call or {}).get("function") or {} + fname = fn.get("name") + if not fname: + continue + summary = _summarize_tool_args(fn.get("arguments")) + lines.append(f"→ {fname}:{summary}" if summary else f"→ {fname}") + elif role == "tool": + content = " ".join(str(msg.get("content") or "").split()) + if content: + lines.append(f" ↳ {content[:160]}{'…' if len(content) > 160 else ''}") + text = "\n".join(lines) + if len(text) > _STAGE_TRACE_MAX_CHARS: + text = "…(前段略)\n" + text[-_STAGE_TRACE_MAX_CHARS:] + return text or "(本阶段暂无工具调用记录)" + + +def build_review_payload( + *, + web_terminal, + wsm: WorkflowStateManager, + stage_info: Dict[str, Any], + review_node: Dict[str, Any], +) -> str: + """审核 payload:工作流信息 + 审核关注点 + 阶段目标要求 + 执行痕迹 + 汇报 + 历史驳回。""" + definition = wsm.load_definition() or {} + stage_node = wsm.get_node(stage_info.get("node_id")) or {} + lines: List[str] = [ + f"【工作流】{definition.get('name')}:{definition.get('description')}", + "", + f"【本次审核把关】{review_node.get('name')}", + f"审核关注点:{review_node.get('prompt') or '阶段产出是否达到进入下一阶段的门槛'}", + "", + f"【被审核阶段】{stage_info.get('name')}", + f"阶段目标:{stage_node.get('goal') or '(未填写)'}", + ] + instructions = str(stage_node.get("instructions") or "").strip() + if instructions: + lines.append(f"阶段要求:{instructions}") + lines += [ + "", + "【阶段执行痕迹】(本阶段内的工具调用时间线)", + build_stage_trace(web_terminal, wsm.get_stage_start_msg_index()), + "", + "【主执行模型阶段汇报】", + stage_info.get("summary") or "(无)", + ] + rejects = [ + h for h in (wsm.state.get("history") or []) + if isinstance(h, dict) + and h.get("kind") == "review" + and h.get("node_id") == review_node.get("id") + and h.get("decision") == "reject" + ] + if rejects: + lines.append("") + lines.append("【历史审核意见】") + for idx, item in enumerate(rejects, start=1): + lines.append(f"第 {idx} 次驳回:{item.get('message') or ''}") + return "\n".join(lines) + + +async def run_stage_review( + *, + web_terminal, + wsm: WorkflowStateManager, + sender, + conversation_id: str, + stage_info: Dict[str, Any], + review_node: Dict[str, Any], +) -> Dict[str, Any]: + """调审核智能体。异常兜底 = reject(定稿:视为驳回 + 请告知用户)。""" + review_name = str(review_node.get("name") or "") + if callable(sender): + try: + sender( + "workflow_review_progress", + {"conversation_id": conversation_id, "progress": {"stage": "start", "message": f"审核「{review_name}」开始"}}, + ) + except Exception: + pass + payload = build_review_payload( + web_terminal=web_terminal, wsm=wsm, stage_info=stage_info, review_node=review_node, + ) + definition = wsm.load_definition() or {} + review_mode = definition.get("reviewMode") or "active" + + def _progress(progress: Dict[str, Any]) -> None: + if callable(sender): + try: + sender("workflow_review_progress", {"conversation_id": conversation_id, "progress": progress}) + except Exception: + pass + + try: + agent = WorkflowReviewAgent(web_terminal=web_terminal) + result = await agent.review(payload_text=payload, review_mode=review_mode, progress_cb=_progress) + except Exception as exc: + result = { + "decision": "reject", + "message": ( + f"审核智能体执行异常({exc})。本次按驳回处理:请告知用户审核服务可能异常;" + "若属偶发,可稍后重新汇报本阶段。" + ), + "source": "workflow_review_agent", + } + if not isinstance(result, dict) or result.get("decision") not in ("pass", "reject"): + result = { + "decision": "reject", + "message": "审核未产出有效结论。本次按驳回处理:请告知用户审核服务可能异常;若属偶发,可稍后重新汇报本阶段。", + "source": "workflow_review_agent", + } + return result + + +# ---------------------------------------------------------------- 退出 / 通知文本 + + +def deactivate_workflow(*, data_dir, conversation_id: str, reason: str) -> Dict[str, Any]: + """模型自主退出(deactivate_workflow 工具):摘牌,工具返回闭环,不发 user 通知。""" + wsm = get_active_manager(data_dir, conversation_id) + if wsm is None: + return {"success": False, "error": "当前对话没有激活的工作流。"} + name = str(wsm.state.get("workflow_name") or "") + wsm.deactivate(status=STATUS_STOPPED, reason=REASON_MODEL) + note = str(reason or "").strip() + return { + "success": True, + "message": f"工作流「{name}」已退出({note or '模型自主退出'})。工作流状态已摘牌,你可以继续自由工作。", + } + + +def deactivate_workflow_by_user(*, data_dir, conversation_id: str) -> Dict[str, Any]: + """用户 slash 退出(REST):摘牌 + 柔性通知入池(忙时工具循环消费 / 闲时 REST 直发)。""" + wsm = get_active_manager(data_dir, conversation_id) + if wsm is None: + return {"success": False, "error": "当前对话没有激活的工作流。"} + name = str(wsm.state.get("workflow_name") or "") + wsm.deactivate(status=STATUS_STOPPED, reason=REASON_USER) + wsm.push_notice( + notice_type="deactivated_by_user", + message=( + f"用户已通过快捷操作退出工作流「{name}」。工作流已摘牌,无需继续按流程推进;" + "你可以继续自由工作。若用户之后要求恢复,可重新激活。" + ), + ) + return {"success": True, "workflow_name": name} + + +def build_round_limit_notice(*, data_dir, conversation_id: str) -> Optional[str]: + """max_stage_rounds 撞限通知文本(主循环层注入)。未撞限返回 None。""" + wsm = get_active_manager(data_dir, conversation_id) + if wsm is None or wsm.round_limit_notified(): + return None + definition = wsm.load_definition() or {} + max_rounds = int(definition.get("maxStageRounds") or 20) + rounds = wsm.get_stage_rounds() + if rounds < max_rounds: + return None + wsm.mark_round_limit_notified() + current = wsm.get_node(wsm.get_current_node_id()) or {} + return ( + f"工作流「{definition.get('name')}」的当前步骤「{current.get('name')}」已进行 {rounds} 轮," + f"达到单步轮数上限({max_rounds})。请立刻停下当前工作,告知用户已超过 {max_rounds} 轮," + "并询问是否还要继续。(工作流仍在进行中,等待用户决定;用户回复后可继续推进或退出。)" + ) + + +# ---------------------------------------------------------------- 状态查询 / 进度事件 + + +def build_status_text(*, data_dir, conversation_id: str) -> str: + """get_workflow_status 工具返回文本。""" + wsm = get_active_manager(data_dir, conversation_id) + if wsm is None: + return "当前对话没有激活的工作流。" + definition = wsm.load_definition() or {} + current = wsm.get_node(wsm.get_current_node_id()) or {} + lines: List[str] = [ + f"【工作流状态】{definition.get('name')}:{definition.get('description')}", + f"已进行时长:{int(time.time() - float(wsm.state.get('started_at') or time.time()))} 秒", + "", + "【已完成的步骤】", + ] + stage_records = [h for h in (wsm.state.get("history") or []) if isinstance(h, dict) and h.get("kind") == "stage"] + if stage_records: + for item in stage_records: + lines.append(f"- {item.get('name')}({item.get('rounds') or 0} 轮):{(item.get('summary') or '')[:80]}") + else: + lines.append("(暂无)") + review_records = [h for h in (wsm.state.get("history") or []) if isinstance(h, dict) and h.get("kind") == "review"] + if review_records: + lines.append("") + lines.append("【审核记录】") + for item in review_records: + label = "通过" if item.get("decision") == "pass" else "驳回" + lines.append(f"- {item.get('name')}:{label} — {(item.get('message') or '')[:80]}") + lines.append("") + lines.append(_current_node_brief(wsm=wsm, current=current)) + lines.append(f"当前步骤已进行轮数:{wsm.get_stage_rounds()}") + return "\n".join(lines) + + +def emit_workflow_progress( + *, + wsm: WorkflowStateManager, + sender, + conversation_id: Optional[str], + extra: Optional[Dict[str, Any]] = None, +) -> None: + """广播工作流进度快照(sender → session_data → REST 轮询透传,对齐 goal 链路)。""" + if not callable(sender) or not conversation_id: + return + snap = wsm.progress_snapshot() + snap["conversation_id"] = conversation_id + if extra: + snap.update(extra) + try: + sender("workflow_progress", snap) + except Exception: + pass diff --git a/server/workflow_runtime_api.py b/server/workflow_runtime_api.py new file mode 100644 index 00000000..1aa660ba --- /dev/null +++ b/server/workflow_runtime_api.py @@ -0,0 +1,303 @@ +"""工作流运行时 REST API(区别于 workflow_page.py 的编辑器 CRUD)。 + +- `POST /api/workflow/activate` :slash 菜单激活。仅智能体空闲(主任务门闸未被持有) + 可用;激活即快照定义,随后以一条 user 提示消息派发一轮工作(门闸 token 随任务移交)。 +- `POST /api/workflow/deactivate` :用户主动退出。柔性原则——只摘牌 + 通知,绝不掐断 + 正在运行的智能体:忙时通知入池(由工具循环末尾 inline 消费),闲时直接派发一轮任务。 +- `GET /api/workflow/status` :当前对话工作流进度快照(前端刷新/恢复用)。 +""" +from __future__ import annotations + +from datetime import datetime + +from flask import Blueprint, jsonify, request + +from server.auth_helpers import api_login_required +from server.context import make_terminal_callback, with_terminal + +workflow_runtime_bp = Blueprint("workflow_runtime", __name__) + + +def _broadcast_progress(data_dir, conversation_id: str, username: str) -> None: + """向该用户房间广播一次工作流进度快照(前端按 conversation_id 过滤)。""" + try: + from modules.workflow_state_manager import WorkflowStateManager + from server.workflow_flow import emit_workflow_progress + + wsm = WorkflowStateManager(data_dir, conversation_id) + emit_workflow_progress( + wsm=wsm, + sender=make_terminal_callback(username), + conversation_id=conversation_id, + ) + except Exception: + pass + + +@workflow_runtime_bp.route("/api/workflow/activate", methods=["POST"]) +@api_login_required +@with_terminal +def api_activate_workflow(terminal, workspace, username): + data = request.get_json(silent=True) or {} + name = str(data.get("name") or "").strip() + conversation_id = str(data.get("conversation_id") or "").strip() or None + if not name: + return jsonify({"error": "缺少工作流名称"}), 400 + + from server.main_task_gate import release_main_task_gate, try_acquire_main_task_gate + from server.workflow_flow import activate_workflow + + created_new = False + if not conversation_id: + # 空对话激活(定稿语义:激活条件是「智能体空闲」而非「对话非空」): + # 自动创建对话,对齐 tasks/api.py 未带 conversation_id 时的补建先例。 + cm = getattr(getattr(terminal, "context_manager", None), "conversation_manager", None) + if cm is None: + return jsonify({"error": "对话管理器不可用"}), 500 + _run_mode = str(getattr(terminal, "run_mode", "") or "fast") + if _run_mode not in {"fast", "thinking", "deep"}: + _run_mode = "fast" + _thinking = getattr(terminal, "thinking_mode", None) + _thinking = bool(_thinking) if _thinking is not None else (_run_mode != "fast") + _svc_cid_before = getattr(cm, "current_conversation_id", None) + # 完整对齐 /api/conversations 正常创建路径的模式继承(否则任务 terminal 恢复时 + # work_mode 回退个性化默认 plan、reasoning_effort 丢失,导致 plan 抑制执行 + 思考退化): + # work_mode/权限/执行环境沿用 terminal 当前值;effort 优先级 terminal 当前档 > 个性化默认。 + try: + from modules.personalization_manager import load_personalization_config as _load_prefs + _prefs = _load_prefs(workspace.data_dir) or {} + except Exception: + _prefs = {} + _work_mode = "plan" + try: + _work_mode = str(terminal.get_work_mode() or "plan") if hasattr(terminal, "get_work_mode") else "plan" + except Exception: + pass + if _work_mode not in ("plan", "ask", "execute"): + _work_mode = "plan" + _permission_mode = getattr(terminal, "get_permission_mode", lambda: "unrestricted")() + if _permission_mode not in ("readonly", "approval", "auto_approval", "unrestricted"): + _permission_mode = "unrestricted" + # plan 档不变量:权限必须只读,同时记录进入前权限供离开 plan 恢复 + _pre_plan_permission = None + if _work_mode == "plan": + if _permission_mode != "readonly": + _pre_plan_permission = _permission_mode + _permission_mode = "readonly" + _reasoning_effort = getattr(terminal, "reasoning_effort", None) + if not (isinstance(_reasoning_effort, str) and _reasoning_effort.strip()): + _reasoning_effort = (_prefs.get("default_reasoning_effort") or None) + _meta_overrides = { + "work_mode": _work_mode, + "permission_mode": _permission_mode, + "execution_mode": getattr(terminal, "get_execution_mode", lambda: "sandbox")(), + "pre_plan_permission_mode": _pre_plan_permission, + "reasoning_effort": _reasoning_effort, + } + try: + conversation_id = cm.create_conversation( + project_path=str(getattr(workspace, "project_path", "") or "."), + run_mode=_run_mode, + thinking_mode=_thinking, + model_key=getattr(terminal, "model_key", None), + metadata_overrides=_meta_overrides, + ) + except Exception as exc: # noqa: BLE001 + return jsonify({"error": f"创建对话失败:{exc}"}), 500 + created_new = True + # 恢复服务 terminal 的对话指针,避免污染共享服务 terminal 的上下文状态 + try: + cm.current_conversation_id = _svc_cid_before + except Exception: + pass + + # 仅智能体空闲(主任务门闸未被持有)可激活;新建对话不可能有并发任务,跳过预占。 + gate_token = None + if not created_new: + gate_token = try_acquire_main_task_gate(terminal) + if gate_token is None: + return jsonify({"error": "智能体正在工作中,工作流仅可在空闲时激活。"}), 409 + + try: + # 激活提示消息将追加到历史末尾,阶段消息游标取当前长度 + 1。 + # 新建对话:激活消息是第 1 条,游标固定为 1(服务 terminal 的 history 长度不可信)。 + if created_new: + msg_index = 1 + else: + try: + msg_index = len(getattr(terminal.context_manager, "conversation_history", []) or []) + 1 + except Exception: + msg_index = 0 + result = activate_workflow( + data_dir=workspace.data_dir, + conversation_id=conversation_id, + name=name, + msg_index=msg_index, + ) + if not result.get("success"): + release_main_task_gate(terminal, gate_token) + return jsonify({"error": result.get("error")}), 400 + + # 幂等:同工作流已激活 → 只广播进度,不重复派发 + if result.get("already"): + if gate_token: + release_main_task_gate(terminal, gate_token) + _broadcast_progress(workspace.data_dir, conversation_id, username) + return jsonify({ + "success": True, + "already": True, + "conversation_id": conversation_id, + "snapshot": result["manager"].progress_snapshot(), + }) + + activation_text = str(result.get("text") or "") + prompt = ( + "用户已通过快捷菜单激活工作流,请立即开始按流程执行。" + "完成当前步骤后调用 report_workflow_stage(summary) 汇报。\n\n" + f"{activation_text}" + ) + + from .tasks import task_manager + + workspace_id = getattr(workspace, "workspace_id", None) or "default" + session_data = { + "username": username, + "message_source": "workflow", + # 门闸 token 随任务移交,由任务线程认领(见 process_message_task) + "main_task_gate_token": gate_token, + # 让任务事件流携带该 user 消息,保证轮询客户端/刷新后可见 + "auto_user_message_event": True, + "auto_user_message_payload": { + "message_source": "workflow", + "workflow_activate": True, + "visibility": "chat", + "starts_work": True, + "timestamp": datetime.now().isoformat(), + }, + } + try: + rec = task_manager.create_chat_task( + username, + workspace_id, + prompt, + [], + conversation_id, + message_source="workflow", + session_data=session_data, + ) + except RuntimeError as exc: + release_main_task_gate(terminal, gate_token) + return jsonify({"error": str(exc)}), 409 + + _broadcast_progress(workspace.data_dir, conversation_id, username) + return jsonify({ + "success": True, + "task_id": rec.task_id, + "conversation_id": conversation_id, + # 快照随响应返回:前端立即写入 store,不等任务事件流的首个进度事件 + "snapshot": result["manager"].progress_snapshot(), + }) + except Exception as exc: # noqa: BLE001 + release_main_task_gate(terminal, gate_token) + return jsonify({"error": f"激活工作流失败:{exc}"}), 500 + + +@workflow_runtime_bp.route("/api/workflow/deactivate", methods=["POST"]) +@api_login_required +@with_terminal +def api_deactivate_workflow(terminal, workspace, username): + data = request.get_json(silent=True) or {} + conversation_id = str(data.get("conversation_id") or "").strip() + if not conversation_id: + return jsonify({"error": "缺少 conversation_id"}), 400 + + from server.main_task_gate import release_main_task_gate, try_acquire_main_task_gate + from server.workflow_flow import deactivate_workflow_by_user + + result = deactivate_workflow_by_user( + data_dir=workspace.data_dir, + conversation_id=conversation_id, + ) + if not result.get("success"): + return jsonify({"error": result.get("error")}), 400 + + _broadcast_progress(workspace.data_dir, conversation_id, username) + + # 柔性通知已入池:闲时直接取出并派发一轮任务;忙时留池, + # 由运行中的工具循环末尾 process_workflow_updates inline 消费。 + dispatched = False + gate_token = try_acquire_main_task_gate(terminal) + if gate_token is not None: + wsm = None + notices = None + restored = False + try: + from modules.workflow_state_manager import WorkflowStateManager + + wsm = WorkflowStateManager(workspace.data_dir, conversation_id) + notices = wsm.poll_notices() + notice_text = "\n\n".join( + str(n.get("message") or "").strip() for n in notices if str(n.get("message") or "").strip() + ) + if notice_text: + from .tasks import task_manager + + workspace_id = getattr(workspace, "workspace_id", None) or "default" + session_data = { + "username": username, + "message_source": "workflow", + "main_task_gate_token": gate_token, + "auto_user_message_event": True, + "auto_user_message_payload": { + "message_source": "workflow", + "workflow_notice": True, + "visibility": "chat", + "starts_work": True, + "timestamp": datetime.now().isoformat(), + }, + } + task_manager.create_chat_task( + username, + workspace_id, + notice_text, + [], + conversation_id, + message_source="workflow", + session_data=session_data, + ) + dispatched = True + except Exception: # noqa: BLE001 + # 任何失败:通知放回池(等轮询器/工具循环消费),不静默丢失 + if wsm is not None and notices and not restored: + try: + wsm.restore_notices(notices) + restored = True + except Exception: # noqa: BLE001 + pass + finally: + if not dispatched: + release_main_task_gate(terminal, gate_token) + + return jsonify({ + "success": True, + "workflow_name": result.get("workflow_name"), + "dispatched": dispatched, + # 摘牌后快照(active=False),前端据此关闭窗口 + "snapshot": {"active": False}, + }) + + +@workflow_runtime_bp.route("/api/workflow/status", methods=["GET"]) +@api_login_required +@with_terminal +def api_workflow_status(terminal, workspace, username): + conversation_id = str(request.args.get("conversation_id") or "").strip() + if not conversation_id: + return jsonify({"error": "缺少 conversation_id"}), 400 + try: + from modules.workflow_state_manager import WorkflowStateManager + + wsm = WorkflowStateManager(workspace.data_dir, conversation_id) + return jsonify({"success": True, "snapshot": wsm.progress_snapshot()}) + except Exception as exc: # noqa: BLE001 + return jsonify({"error": f"读取工作流状态失败:{exc}"}), 500 diff --git a/static/src/App.vue b/static/src/App.vue index 21524b13..c8d1c35b 100644 --- a/static/src/App.vue +++ b/static/src/App.vue @@ -358,6 +358,7 @@ @delete-runtime-message="handleDeleteRuntimeMessage" @composer-height-change="handleComposerHeightChange" @toggle-goal-mode="handleToggleGoalMode" + @workflow-activated="handleWorkflowActivated" @toggle-agent-type-menu="handleToggleAgentTypeMenu" @select-new-conversation-type="handleSelectNewConversationType" @toggle-work-mode-menu="toggleWorkModeMenu" diff --git a/static/src/app/methods/conversation/bootstrap.ts b/static/src/app/methods/conversation/bootstrap.ts index d99b071a..a0ed8a11 100644 --- a/static/src/app/methods/conversation/bootstrap.ts +++ b/static/src/app/methods/conversation/bootstrap.ts @@ -2,6 +2,7 @@ import { debugLog, traceLog } from '../common'; import { useQuickDockStore } from '../../../stores/quickDock'; import { useConversationStore } from '../../../stores/conversation'; +import { useWorkflowStore } from '../../../stores/workflow'; /** * 统一加载协议(方案 B):进入对话的单一入口。 @@ -120,6 +121,18 @@ export const bootstrapMethods = { Array.isArray(data.edited_files) ? data.edited_files : [] ); + // 4.6 快捷窗口:回填工作流运行状态(静态呈现,不播动画)。 + // 失败不影响对话进入。 + try { + const wfResp = await fetch(`/api/workflow/status?conversation_id=${encodeURIComponent(normalizedId)}`); + if (wfResp.ok) { + const wfData = await wfResp.json(); + useWorkflowStore().setWorkflow(wfData?.snapshot, false); + } + } catch (wfErr) { + debugLog('enterConversation:workflow-status-failed', { conversationId: normalizedId, error: String(wfErr || '') }); + } + // 5. 运行中任务快速恢复(任务/事件/判据已由 bootstrap 聚合, // 免去 GET /api/tasks + 历史死等 + GET /api/tasks/{id} 三次请求) const running = data.running || {}; diff --git a/static/src/app/methods/taskPolling/index.ts b/static/src/app/methods/taskPolling/index.ts index 934d5110..d4d13f32 100644 --- a/static/src/app/methods/taskPolling/index.ts +++ b/static/src/app/methods/taskPolling/index.ts @@ -3,6 +3,7 @@ import { probeMethods } from './probe'; import { aiStreamMethods } from './aiStream'; import { toolMethods } from './tool'; import { goalMethods } from './goal'; +import { workflowMethods } from './workflow'; import { titleMethods } from './title'; import { lifecycleMethods } from './lifecycle'; import { messagingMethods } from './messaging'; @@ -15,6 +16,7 @@ export const taskPollingMethods = { ...aiStreamMethods, ...toolMethods, ...goalMethods, + ...workflowMethods, ...titleMethods, ...lifecycleMethods, ...messagingMethods, diff --git a/static/src/app/methods/taskPolling/lifecycle.ts b/static/src/app/methods/taskPolling/lifecycle.ts index e032032d..d4696828 100644 --- a/static/src/app/methods/taskPolling/lifecycle.ts +++ b/static/src/app/methods/taskPolling/lifecycle.ts @@ -269,6 +269,14 @@ export const lifecycleMethods = { this.handleGoalStopped?.(eventData, eventIdx); break; + case 'workflow_progress': + this.handleWorkflowProgress?.(eventData, eventIdx); + break; + + case 'workflow_review_progress': + this.handleWorkflowReviewProgress?.(eventData, eventIdx); + break; + case 'append_payload': this.handleAppendPayload(eventData, eventIdx); break; diff --git a/static/src/app/methods/taskPolling/workflow.ts b/static/src/app/methods/taskPolling/workflow.ts new file mode 100644 index 00000000..4fa5b96d --- /dev/null +++ b/static/src/app/methods/taskPolling/workflow.ts @@ -0,0 +1,44 @@ +// @ts-nocheck +import { useWorkflowStore } from '../../../stores/workflow'; + +/** + * 工作流运行时事件处理(任务轮询链路)。 + * + * - workflow_progress:进度快照(推进/审核结果/激活/退出都会广播),live=true 播窗口动画 + * - workflow_review_progress:审核智能体实时进度,复用右侧自动审批面板(对齐 goal_review_progress) + */ +export const workflowMethods = { + handleWorkflowProgress(data: any) { + // 对话隔离:忽略不属于当前对话的工作流事件 + if (data?.conversation_id && this.currentConversationId && data.conversation_id !== this.currentConversationId) { + return; + } + useWorkflowStore().setWorkflow(data, true); + }, + + handleWorkflowReviewProgress(data: any) { + if (data?.conversation_id && this.currentConversationId && data.conversation_id !== this.currentConversationId) { + return; + } + const progress = data?.progress || data || {}; + if (!progress || typeof progress !== 'object') { + return; + } + if (!Array.isArray(this.autoApprovalFeedLines)) { + this.autoApprovalFeedLines = []; + } + this.autoApprovalTitle = '工作流审核'; + if (progress.stage === 'start') { + this.autoApprovalFeedLines = ['开始审核']; + this.autoApprovalFinalMessage = ''; + } else if (progress.stage === 'model_call') { + this.autoApprovalFeedLines.push(String(progress.message || `审核轮次 ${progress.round || ''}`).trim()); + } else if (progress.stage === 'run_command' && progress.command) { + this.autoApprovalFeedLines.push(String(progress.command)); + } else if (progress.message) { + this.autoApprovalFeedLines.push(String(progress.message)); + } + this.autoApprovalFeedLines = this.autoApprovalFeedLines.slice(-20); + this.$forceUpdate(); + } +}; diff --git a/static/src/app/methods/ui/mode.ts b/static/src/app/methods/ui/mode.ts index 36ab198f..ded54f73 100644 --- a/static/src/app/methods/ui/mode.ts +++ b/static/src/app/methods/ui/mode.ts @@ -78,6 +78,41 @@ export const modeMethods = { this.modeMenuOpen = false; this.modelMenuOpen = false; }, + /** + * 工作流激活成功(slash 菜单):空对话激活时后端已自动创建对话并派发任务, + * 前端进入该对话(bootstrap 会回放运行中的任务)。已有对话激活时 id 相同,无需跳转。 + */ + async handleWorkflowActivated(conversationId) { + const normalized = String(conversationId || '').trim(); + if (!normalized || normalized === this.currentConversationId) { + return; + } + // 侧边栏列表先插入占位(对齐 send.ts 首条消息创建对话的行为),否则列表要待下次刷新才出现 + try { + const newPlaceholder = { + id: normalized, + title: '新对话', + updated_at: new Date().toISOString(), + total_messages: 0, + total_tools: 0 + }; + if (Array.isArray(this.conversations)) { + this.conversations.splice( + 0, + this.conversations.length, + newPlaceholder, + ...this.conversations.filter((conv) => conv && conv.id !== normalized) + ); + } + } catch (_e) { + // 占位失败不阻断进入对话 + } + try { + await this.enterConversation(normalized, { source: 'sidebar', urlMode: 'push' }); + } catch (error) { + console.warn('[Workflow] 激活后进入新对话失败:', error); + } + }, // 输入栏「智能体/多智能体」类型选择器:空对话态可选(写 newConversationType), // 已有对话为禁用展示态(类型创建时确定、不可变)。 handleToggleAgentTypeMenu() { diff --git a/static/src/components/chat/quickdock/QuickDock.vue b/static/src/components/chat/quickdock/QuickDock.vue index 5296e171..818a6d44 100644 --- a/static/src/components/chat/quickdock/QuickDock.vue +++ b/static/src/components/chat/quickdock/QuickDock.vue @@ -7,6 +7,7 @@ }" >
+ @@ -52,14 +53,16 @@ import { useConversationStore } from '@/stores/conversation'; import { useUiStore } from '@/stores/ui'; import { usePersonalizationStore, hasCachedQuickDockAutoExpand } from '@/stores/personalization'; import { useFileStore } from '@/stores/file'; +import { useWorkflowStore } from '@/stores/workflow'; import TodoWindow from './TodoWindow.vue'; +import WorkflowWindow from './WorkflowWindow.vue'; import RunnerWindow from './RunnerWindow.vue'; import RunnerDetailPanel from './RunnerDetailPanel.vue'; import FileWindow from './FileWindow.vue'; /** * 快捷窗口(Quick Dock)容器 - * 对话区右侧占位列:待办 / 子智能体 / 后台指令 / 文件 四个窗口上下排布。 + * 对话区右侧占位列:工作流 / 待办 / 子智能体 / 后台指令 / 文件 五个窗口上下排布。 * 同时负责:全局 ⋯ 菜单、Esc 分层关闭、列表轮询、对话切换重置。 */ @@ -71,6 +74,7 @@ const bgStore = useBackgroundCommandStore(); const conversationStore = useConversationStore(); const uiStore = useUiStore(); const fileStore = useFileStore(); +const workflowStore = useWorkflowStore(); const personalizationStore = usePersonalizationStore(); // hasContent / userCollapsed 提升到 store:App.vue 的展开收起按钮共用同一判定 @@ -207,7 +211,7 @@ function releaseNoAnim(force = false) { // 用户手动展开后只要内容不完全清空(hasContent 不再变化),dock 保持展开 watch( hasContent, - () => { + (content) => { quickDock.userCollapsed = !autoExpand.value; }, // immediate:挂载时即校正(手动模式下刷新恢复对话,已有内容不应自动展开) @@ -450,6 +454,15 @@ watch( // 只关瞬态面板;列表数据保留,由 bootstrap(edited_files)与 fetchTodoList 回填覆盖, // 避免切换对话时列「先收起再展开」闪烁(/new 场景由 app watcher 负责清空)。 quickDock.resetTransient(); + // 工作流状态按对话隔离,但不清空——与待办同款「保留至回填」防闪烁: + // 切到有工作流的对话(含 /new 激活后自动进入新对话)数据一致、无感切换; + // 切到无工作流的对话由回填结果清空(live=false,不播退出动画)。 + const switchConvId = conversationStore.currentConversationId; + if (switchConvId) { + void workflowStore.fetchStatus(switchConvId); + } else { + workflowStore.reset(); + } refreshAll(); // 切换期间重开无过渡窗口:新对话回填后的收起/展开状态瞬间切换,不播容器动画。 // flush:'sync' 保证在 app watcher 清空回填(/new 场景)之前捕获同步序号, diff --git a/static/src/components/chat/quickdock/WorkflowWindow.vue b/static/src/components/chat/quickdock/WorkflowWindow.vue new file mode 100644 index 00000000..068bb225 --- /dev/null +++ b/static/src/components/chat/quickdock/WorkflowWindow.vue @@ -0,0 +1,416 @@ + + + diff --git a/static/src/components/chat/quickdock/quickdock.css b/static/src/components/chat/quickdock/quickdock.css index 06f3fc5f..ad605737 100644 --- a/static/src/components/chat/quickdock/quickdock.css +++ b/static/src/components/chat/quickdock/quickdock.css @@ -98,6 +98,22 @@ } } +/* 整窗退出动画(与进入对称):工作流完成停留后 / slash 停用时播放 */ +.qd-window.qd-window-exit { + animation: qd-window-out 0.24s ease-in forwards; +} + +@keyframes qd-window-out { + from { + opacity: 1; + transform: translateY(0); + } + to { + opacity: 0; + transform: translateY(-6px); + } +} + .qd-window__header { height: 38px; display: flex; @@ -1086,3 +1102,323 @@ stroke-width: 1.8; stroke-linecap: round; } + +/* ============================================================ + 工作流窗口(WorkflowWindow.vue) + 迁移自 demo/workflow_dock_window.html 定稿: + - 视口固定 3 行,刚好显示 刚完成/当前/下一步;历史可向上滚动, + 渲染到「下一步」为止(不可向后滚动) + - 行结构对齐待办:6px 圆点(完成=accent 填充 / 当前=info 呼吸 / 下一步=空心) + ============================================================ */ + +.wf-viewport { + height: calc(var(--qd-row-h) * 3); + overflow-y: auto; + scrollbar-width: none; + -ms-overflow-style: none; +} + +.wf-viewport::-webkit-scrollbar { + display: none; + width: 0; + height: 0; +} + +.wf-list { + list-style: none; + margin: 0; + padding: 0; + min-height: 100%; + display: flex; + flex-direction: column; +} + +.wf-row { + flex: none; + height: var(--qd-row-h); + display: flex; + align-items: center; + gap: 9px; + padding: 0 12px; + overflow: hidden; + user-select: none; +} + +.wf-row__dot { + flex: none; + width: 6px; + height: 6px; + border-radius: 50%; + border: 1.5px solid var(--text-tertiary); + background: transparent; + transition: + background 0.2s ease, + border-color 0.2s ease; +} + +.wf-row__name { + position: relative; + flex: 1; + min-width: 0; + font-size: 13px; + line-height: 1; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + transition: color 0.22s ease 0.12s; /* 变灰略滞后于划线(对齐待办) */ +} + +.wf-row__meta { + flex: none; + display: flex; + align-items: center; + gap: 5px; + font-size: 11px; + color: var(--text-tertiary); + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +/* --- 完成行:点填充 accent + 文字灰 + 划线 --- */ +.wf-row.is-done .wf-row__dot { + background: var(--accent); + border-color: var(--accent); +} + +.wf-row.is-done .wf-row__name { + color: var(--text-tertiary); +} + +.wf-row__name::after { + content: ''; + position: absolute; + left: -1px; + right: -1px; + top: 50%; + height: 1.5px; + margin-top: -0.75px; + background: var(--text-tertiary); + border-radius: 1px; + transform: scaleX(0); + transform-origin: left center; + pointer-events: none; +} + +.wf-row.is-done .wf-row__name::after { + transform: scaleX(1); +} + +/* 完成瞬间:划线从左往右划过 + 点弹跳(参数对齐待办 qd-strike-in / qd-dot-pop) */ +.wf-row.is-just-done .wf-row__name::after { + animation: wf-strike-in 0.28s var(--qd-ease-strike) forwards; +} + +@keyframes wf-strike-in { + from { + transform: scaleX(0); + } + to { + transform: scaleX(1); + } +} + +.wf-row.is-just-done .wf-row__dot { + animation: wf-dot-pop 0.32s var(--qd-ease-out); +} + +@keyframes wf-dot-pop { + 0% { + transform: scale(1); + } + 45% { + transform: scale(1.6); + } + 100% { + transform: scale(1); + } +} + +/* --- 当前行:info 呼吸点 + 正文色加粗 --- */ +.wf-row.is-current .wf-row__dot { + border: none; + width: 7px; + height: 7px; + background: var(--state-info); + animation: wf-pulse 1.6s ease-in-out infinite; +} + +@keyframes wf-pulse { + 0%, + 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.35; + transform: scale(0.85); + } +} + +.wf-row.is-current .wf-row__name { + font-weight: 500; +} + +/* 审核中:点变 accent 色 + spinner 文案 */ +.wf-row.is-reviewing .wf-row__dot { + background: var(--accent); +} + +.wf-spinner { + width: 11px; + height: 11px; + border-radius: 50%; + border: 1.5px solid var(--border-strong); + border-top-color: var(--accent); + animation: wf-spin 0.7s linear infinite; +} + +@keyframes wf-spin { + to { + transform: rotate(360deg); + } +} + +/* --- 下一行(未来仅一步可见):淡色 --- */ +.wf-row.is-next .wf-row__name { + color: var(--text-tertiary); +} + +/* 驳回「复活」:划线从右往左收回 + 文字恢复正文色 */ +.wf-row.is-reviving .wf-row__name { + color: var(--text-primary); + transition: color 0.22s ease; +} + +.wf-row.is-reviving .wf-row__name::after { + transform: scaleX(1); + transform-origin: right center; + animation: wf-strike-out 0.28s var(--qd-ease-strike) forwards; +} + +@keyframes wf-strike-out { + from { + transform: scaleX(1); + } + to { + transform: scaleX(0); + } +} + +/* 驳回红闪(当前行,两下) */ +.wf-row.is-reject-flash { + animation: wf-reject-flash 0.62s ease-in-out; +} + +@keyframes wf-reject-flash { + 0%, + 100% { + background: transparent; + } + 25%, + 70% { + background: color-mix(in srgb, var(--state-danger) 12%, transparent); + } +} + +.wf-row.is-reject-flash .wf-row__dot { + animation: none; + background: var(--state-danger); +} + +/* 行进入(新「下一步」追加到底部时淡入上移) */ +.wf-row.is-entering { + animation: wf-row-in 0.3s var(--qd-ease-out) backwards; +} + +@keyframes wf-row-in { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* 行移出(驳回时被吃掉的未来行) */ +.wf-row.is-leaving { + animation: wf-row-out-down 0.24s ease-in forwards; +} + +@keyframes wf-row-out-down { + from { + opacity: 1; + } + to { + opacity: 0; + transform: translateY(8px); + height: 0; + } +} + +/* 工作流状态脚注条(审核中/驳回/完成等瞬时事件) */ +.wf-footnote { + border-top: 1px solid var(--border-default); + height: 30px; + display: flex; + align-items: center; + gap: 6px; + padding: 0 12px; + font-size: 11.5px; + color: var(--text-tertiary); + user-select: none; + overflow: hidden; + white-space: nowrap; +} + +.wf-footnote__dot { + flex: none; + width: 6px; + height: 6px; + border-radius: 50%; +} + +.wf-footnote--info .wf-footnote__dot { + background: var(--state-info); + animation: wf-pulse 1.6s ease-in-out infinite; +} + +.wf-footnote--danger { + color: var(--state-danger); +} + +.wf-footnote--danger .wf-footnote__dot { + background: var(--state-danger); +} + +.wf-footnote--success .wf-footnote__dot { + background: var(--state-success); +} + +.wf-footnote.is-entering { + animation: wf-footnote-in 0.24s ease-out; +} + +@keyframes wf-footnote-in { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.wf-footnote__text { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/static/src/components/input/InputComposer.vue b/static/src/components/input/InputComposer.vue index 3e7fb9fe..9dca0947 100644 --- a/static/src/components/input/InputComposer.vue +++ b/static/src/components/input/InputComposer.vue @@ -652,6 +652,7 @@ import StatusAvatar from '@/components/avatar/StatusAvatar.vue'; import { useInputStore } from '@/stores/input'; import { usePersonalizationStore } from '@/stores/personalization'; import { useUiStore } from '@/stores/ui'; +import { useWorkflowStore } from '@/stores/workflow'; defineOptions({ name: 'InputComposer' }); @@ -696,6 +697,7 @@ const emit = defineEmits([ 'restore-user-question', 'toggle-goal-mode', 'open-goal-dialog', + 'workflow-activated', 'open-git-changes-panel', 'toggle-agent-type-menu', 'select-new-conversation-type', @@ -831,6 +833,7 @@ type SkillItem = { name: string; description: string; path: string }; type SlashMenuMode = | 'root' | 'skills' + | 'workflows' | 'theme' | 'permission' | 'execution' @@ -849,6 +852,7 @@ type SlashMenuItem = { mode?: SlashMenuMode; skill?: SkillItem; }; +type WorkflowItem = { name: string; description: string; source: string; updatedAt: string; nodeCount: number }; const availableSkills = ref([]); const selectedSkillRefs = ref([]); const skillsLoaded = ref(false); @@ -867,6 +871,11 @@ const slashManualScrolled = ref(false); const slashSkillsEntrySource = ref<'typed' | 'menu' | null>(null); /** Escape 从 // 直达的 skills 退回 root 后,抑制本次 token 存续期间的自动重进 */ const slashDoubleSlashSuppressed = ref(false); +const workflowStore = useWorkflowStore(); +const availableWorkflows = ref([]); +const workflowsLoaded = ref(false); +const workflowsLoading = ref(false); +const workflowActionPending = ref(false); let slashAnimId: number | null = null; /** 动画真正进行中(比 animId 更可靠,可覆盖 rAF 结束后的延迟 scroll 事件) */ let slashAnimating = false; @@ -1509,6 +1518,15 @@ const rootSlashMenuItems = computed(() => { description: '插入一个 AgentSkill 引用(// 快捷直达)', mode: 'skills' }, + { + id: 'workflows', + label: '工作流', + description: workflowStore.isActive + ? `进行中:${workflowStore.snapshot.name} · 查看或退出` + : '激活一个工作流,让智能体按流程推进', + disabled: !props.isConnected, + mode: 'workflows' + }, { id: 'conversation-type', label: '对话类型', @@ -1819,6 +1837,9 @@ const activeSlashMenuItems = computed(() => { if (slashMenuMode.value === 'skills') { return skillSlashMenuItems.value; } + if (slashMenuMode.value === 'workflows') { + return workflowSlashMenuItems.value; + } if (slashMenuMode.value === 'theme') { return themeSlashMenuItems.value; } @@ -1851,6 +1872,7 @@ const activeSlashMenuItems = computed(() => { const slashMenuAriaLabel = computed(() => { if (slashMenuMode.value === 'skills') return '可用 AgentSkills'; + if (slashMenuMode.value === 'workflows') return '工作流选项'; if (slashMenuMode.value === 'theme') return '主题选项'; if (slashMenuMode.value === 'permission') return '权限模式选项'; if (slashMenuMode.value === 'execution') return '执行环境选项'; @@ -1867,6 +1889,8 @@ const slashMenuAriaLabel = computed(() => { const slashMenuEmptyText = computed(() => { if (slashMenuMode.value === 'skills') return skillsLoading.value ? '正在加载 skills...' : '没有匹配的 skill'; + if (slashMenuMode.value === 'workflows') + return workflowsLoading.value ? '正在加载工作流...' : '暂无可用工作流(可去 /workflows 创建)'; if (slashMenuMode.value === 'permission') return '无可用权限模式'; if (slashMenuMode.value === 'execution') return '无可用执行环境'; if (slashMenuMode.value === 'model') return '无可用模型'; @@ -1912,6 +1936,141 @@ const loadSkills = async () => { } }; +const loadWorkflows = async (force = false) => { + if ((workflowsLoaded.value && !force) || workflowsLoading.value) { + return; + } + workflowsLoading.value = true; + try { + const response = await fetch('/api/workflows'); + const data = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(data?.error || '加载工作流列表失败'); + } + availableWorkflows.value = Array.isArray(data.workflows) + ? data.workflows + .filter((item: any) => item && item.name) + .map((item: any) => ({ + name: String(item.name || ''), + description: String(item.description || ''), + source: String(item.source || ''), + updatedAt: String(item.updatedAt || ''), + nodeCount: Number(item.nodeCount ?? 0) + })) + : []; + workflowsLoaded.value = true; + } catch (error) { + availableWorkflows.value = []; + workflowsLoaded.value = false; + console.warn('[WorkflowSlash] 加载工作流列表失败:', error); + } finally { + workflowsLoading.value = false; + } +}; + +const activateWorkflow = async (name: string) => { + if (workflowActionPending.value) return; + // 空对话也允许激活(定稿:条件是「智能体空闲」而非「对话非空」)—— + // 后端会自动创建对话并返回新 conversation_id,前端随后进入该对话。 + const conversationId = String(props.currentConversationId || '').trim(); + workflowActionPending.value = true; + try { + const body: Record = { name }; + if (conversationId) { + body.conversation_id = conversationId; + } + const response = await fetch('/api/workflow/activate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + const data = await response.json().catch(() => ({})); + if (!response.ok || !data?.success) { + throw new Error(data?.error || '激活工作流失败'); + } + // 快照随响应返回:立即写入 store(socketio 广播在轮询架构下不可靠, + // 任务事件流的首个进度事件要等模型首次汇报才发出) + if (data?.snapshot) { + workflowStore.setWorkflow(data.snapshot, true); + } + useUiStore().pushToast({ title: `工作流「${name}」已激活`, type: 'success' }); + const newCid = String(data?.conversation_id || '').trim(); + if (newCid && newCid !== conversationId) { + emit('workflow-activated', newCid); + } + } catch (error: any) { + useUiStore().pushToast({ title: '激活工作流失败', message: String(error?.message || error || ''), type: 'error' }); + } finally { + workflowActionPending.value = false; + } +}; + +const deactivateWorkflow = async () => { + if (workflowActionPending.value) return; + const conversationId = String(props.currentConversationId || '').trim(); + if (!conversationId) { + return; + } + workflowActionPending.value = true; + try { + const response = await fetch('/api/workflow/deactivate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ conversation_id: conversationId }) + }); + const data = await response.json().catch(() => ({})); + if (!response.ok || !data?.success) { + throw new Error(data?.error || '退出工作流失败'); + } + // 摘牌成功:写 live=true 的空快照,触发窗口退出动画(与出现对称),而非瞬间卸载 + workflowStore.setWorkflow(null, true); + useUiStore().pushToast({ title: `已退出工作流「${data?.workflow_name || ''}」`, type: 'success' }); + } catch (error: any) { + useUiStore().pushToast({ title: '退出工作流失败', message: String(error?.message || error || ''), type: 'error' }); + } finally { + workflowActionPending.value = false; + } +}; + +/** 工作流子菜单项:激活中首项为「退出当前工作流」,其后为可激活列表。 */ +const workflowSlashMenuItems = computed(() => { + const items: SlashMenuItem[] = []; + const activeName = workflowStore.isActive ? String(workflowStore.snapshot.name || '') : ''; + if (activeName) { + items.push({ + id: 'workflow-deactivate', + label: `退出当前工作流`, + description: `进行中:${activeName} · 摘牌退出(不打断当前工作)`, + disabled: workflowActionPending.value, + action: () => { + void deactivateWorkflow(); + } + }); + } + // 激活仅智能体空闲可用(streaming / 审批等待 / 已有激活工作流时禁用); + // 空对话允许激活(后端自动创建对话)。 + const activateDisabled = + !props.isConnected || + props.streamingMessage || + workflowActionPending.value || + !!activeName; + for (const wf of availableWorkflows.value) { + const isCurrent = activeName && wf.name === activeName; + items.push({ + id: `workflow-activate-${wf.name}`, + label: wf.name, + description: isCurrent + ? '当前激活中' + : `${wf.description || '(无描述)'}${wf.source === 'builtin' ? ' · 内置' : ''}`, + disabled: activateDisabled || !!isCurrent, + action: () => { + void activateWorkflow(wf.name); + } + }); + } + return items; +}); + const refreshSkillSlashState = () => { const token = findSlashToken(); if (!token || !props.isConnected || props.inputLocked) { @@ -2001,6 +2160,9 @@ const selectSlashMenuItem = (index = skillSlashActiveIndex.value) => { if (item.mode === 'skills') { void loadSkills(); } + if (item.mode === 'workflows') { + void loadWorkflows(); + } if (item.mode === 'workspace') { emit('fetch-host-workspaces'); } diff --git a/static/src/components/personalization/PersonalizationDrawer.vue b/static/src/components/personalization/PersonalizationDrawer.vue index d48f67b1..c0769ad8 100644 --- a/static/src/components/personalization/PersonalizationDrawer.vue +++ b/static/src/components/personalization/PersonalizationDrawer.vue @@ -1882,6 +1882,156 @@ +
+
+ 统一配置三个审核智能体的模型与运行参数。模型与子智能体共用模型库,留空则使用模型库默认模型。 +
+ + +
+
; const sessionRole = ref(''); @@ -2637,6 +2790,9 @@ watch( loadSubAgentSettings(); loadSubAgentModels(); } + if (isVisible && tab === 'review-agents') { + loadSubAgentModels(); + } if ( isVisible && tab === 'general' && @@ -2696,6 +2852,30 @@ const loadSubAgentModels = async () => { } }; +// ----- 审核智能体(模型与运行参数统一配置,模型库复用子智能体模型列表) ----- +const reviewAgentDefs: Array<{ key: ReviewAgentKey; name: string; desc: string }> = [ + { key: 'auto_approval', name: '自动审批智能体', desc: '自动审批模式下判断工具调用是否越权/危险' }, + { key: 'goal_review', name: '目标审核智能体', desc: '目标模式下判断长期目标是否真正达成' }, + { key: 'workflow_review', name: '工作流审核智能体', desc: '工作流审核节点判断阶段产出是否达标' } +]; + +const reviewAgentOf = (key: ReviewAgentKey): ReviewAgentSetting => { + const agents = (form.value as any).review_agents; + return agents?.[key] || { model: '', thinking: false, timeout_seconds: 60, max_rounds: 3, max_command_timeout: 60 }; +}; + +const updateReviewAgent = (key: ReviewAgentKey, patch: Partial) => { + const agents = { ...((form.value as any).review_agents || {}) }; + agents[key] = { ...reviewAgentOf(key), ...patch }; + personalization.updateField({ key: 'review_agents', value: agents }); +}; + +const updateReviewAgentInt = (key: ReviewAgentKey, field: 'timeout_seconds' | 'max_rounds' | 'max_command_timeout', raw: string, lo: number, hi: number) => { + const parsed = parseInt(raw, 10); + if (!Number.isFinite(parsed)) return; + updateReviewAgent(key, { [field]: Math.max(lo, Math.min(parsed, hi)) }); +}; + const loadSubAgentSettings = async () => { try { const resp = await fetch('/api/multiagent/settings', { credentials: 'same-origin' }); diff --git a/static/src/stores/personalization.ts b/static/src/stores/personalization.ts index b36ef4f5..0e4cfbcc 100644 --- a/static/src/stores/personalization.ts +++ b/static/src/stores/personalization.ts @@ -11,6 +11,71 @@ type CommunicationStyle = 'default' | 'human_like' | 'auto'; type ConversationContinuity = 'low' | 'medium' | 'high'; type VersioningBackupMode = 'shallow' | 'full'; +/** 与后端 DEFAULT_PERSONALIZATION_CONFIG['review_agents'] 保持一致 */ +const defaultReviewAgents = (): Record => ({ + auto_approval: { + model: '', + thinking: false, + timeout_seconds: 60, + max_rounds: 3, + max_command_timeout: 20 + }, + goal_review: { + model: '', + thinking: false, + timeout_seconds: 60, + max_rounds: 3, + max_command_timeout: 60 + }, + workflow_review: { + model: '', + thinking: false, + timeout_seconds: 120, + max_rounds: 6, + max_command_timeout: 60 + } +}); + +/** 清洗后端返回的 review_agents:缺键/类型错时回落默认值,数值钳到合理区间 */ +const sanitizeReviewAgents = (raw: any): Record => { + const defaults = defaultReviewAgents(); + const src = raw && typeof raw === 'object' ? raw : {}; + const clamp = (v: any, fallback: number, lo: number, hi: number): number => { + const n = typeof v === 'number' ? v : parseInt(v, 10); + if (!Number.isFinite(n)) return fallback; + return Math.max(lo, Math.min(Math.trunc(n), hi)); + }; + const keys: ReviewAgentKey[] = ['auto_approval', 'goal_review', 'workflow_review']; + const out = {} as Record; + for (const key of keys) { + const d = defaults[key]; + const item = src[key] && typeof src[key] === 'object' ? src[key] : {}; + out[key] = { + model: typeof item.model === 'string' ? item.model : '', + thinking: typeof item.thinking === 'boolean' ? item.thinking : d.thinking, + timeout_seconds: clamp(item.timeout_seconds, d.timeout_seconds, 5, 3600), + max_rounds: clamp(item.max_rounds, d.max_rounds, 1, 50), + max_command_timeout: clamp(item.max_command_timeout, d.max_command_timeout, 1, 600) + }; + } + return out; +}; + +/** 审核智能体键:自动审批 / 目标审核 / 工作流审核 */ +export type ReviewAgentKey = 'auto_approval' | 'goal_review' | 'workflow_review'; +export interface ReviewAgentSetting { + /** 模型名(子智能体模型库条目);留空 = 模型库 default_model */ + model: string; + /** 思考模式开关(模型不支持思考时后端自动回落 fast 段) */ + thinking: boolean; + /** 审核请求超时(秒) */ + timeout_seconds: number; + /** 审核最大轮次 */ + max_rounds: number; + /** 只读取证命令超时(秒) */ + max_command_timeout: number; +} + interface PersonalForm { enabled: boolean; communication_style: CommunicationStyle; @@ -76,6 +141,7 @@ interface PersonalForm { goal_end_conditions: string[]; goal_max_turns: number; goal_max_tokens: number | null; + review_agents: Record; } interface ExperimentState { @@ -278,7 +344,8 @@ const defaultForm = (): PersonalForm => ({ goal_review_mode: 'readonly', goal_end_conditions: ['max_turns'], goal_max_turns: 5, - goal_max_tokens: null + goal_max_tokens: null, + review_agents: defaultReviewAgents() }); const defaultExperimentState = (): ExperimentState => ({ @@ -553,7 +620,8 @@ export const usePersonalizationStore = defineStore('personalization', { goal_max_tokens: typeof data.goal_max_tokens === 'number' && data.goal_max_tokens > 0 ? data.goal_max_tokens - : null + : null, + review_agents: sanitizeReviewAgents(data.review_agents) }; // 如果theme发生变化,应用到界面 const currentTheme = diff --git a/static/src/stores/quickDock.ts b/static/src/stores/quickDock.ts index 0a86eb8c..fc6728c0 100644 --- a/static/src/stores/quickDock.ts +++ b/static/src/stores/quickDock.ts @@ -2,10 +2,11 @@ import { defineStore } from 'pinia'; import { useFileStore } from './file'; import { useSubAgentStore } from './subAgent'; import { useBackgroundCommandStore } from './backgroundCommand'; +import { useWorkflowStore } from './workflow'; /** * 快捷窗口(Quick Dock)状态 - * 对话区右侧占位列:待办 / 子智能体 / 后台指令 / 文件记录 四个窗口。 + * 对话区右侧占位列:工作流 / 待办 / 子智能体 / 后台指令 / 文件记录 五个窗口。 * 本 store 管理:文件记录列表、详情面板目标、文件预览目标、全局 ⋯ 菜单。 */ @@ -133,6 +134,21 @@ interface QuickDockState { filesSyncSeq: number; } +/** + * 初始乐观掩码的内容假定值。落在空对话路由(/ 或 /new)时全局缓存不适用—— + * 空对话真实内容必为空,按缓存乐观展开只会呈现「展开空白几秒后收回」; + * 落在具体对话路径时保持「首帧按上次离开状态展开」的防跳变优化。 + */ +const loadInitialAssumedContent = (): boolean => { + if (typeof window !== 'undefined') { + const path = window.location.pathname.replace(/\/+$/, '') || '/'; + if (path === '/' || path === '/new') { + return false; + } + } + return loadCachedHadContent(); +}; + export const useQuickDockStore = defineStore('quickDock', { state: (): QuickDockState => ({ editedFiles: [], @@ -141,25 +157,29 @@ export const useQuickDockStore = defineStore('quickDock', { previewPath: null, menu: null, userCollapsed: false, - assumedContent: loadCachedHadContent(), - assumedActive: loadCachedHadContent(), + assumedContent: loadInitialAssumedContent(), + assumedActive: loadInitialAssumedContent(), filesSyncSeq: 0 }), getters: { - /** 四个窗口(待办/子智能体/后台指令/文件记录)任一有内容 */ + /** 五个窗口(工作流/待办/子智能体/后台指令/文件记录)任一有内容 */ hasContent(state): boolean { const fileStore = useFileStore(); const subAgentStore = useSubAgentStore(); const bgStore = useBackgroundCommandStore(); + const workflowStore = useWorkflowStore(); const todoCount = fileStore.todoList?.tasks?.length || 0; - const real = + // 工作流是「实时状态」(激活/完成/停用即刻变化),不是回填内容,不参与乐观掩码—— + // 否则切换对话时 assumedContent=false 会把刚激活的工作流掩盖掉(/new 激活闪烁根因)。 + // 切对话时旧工作流残留走「保留至回填覆盖」策略,与其余窗口一致。 + const restReal = todoCount > 0 || subAgentStore.subAgents.length > 0 || bgStore.commands.length > 0 || state.editedFiles.length > 0; // 乐观掩码生效期间(初始加载/切换对话的内容未到齐窗口)以假定状态为准; // 掩码关闭后纯真实状态 - return state.assumedActive ? state.assumedContent : real; + return workflowStore.isActive || (state.assumedActive ? state.assumedContent : restReal); }, /** 实际处于展开态:有内容且未被用户手动收起 */ expanded(state): boolean { diff --git a/static/src/stores/workflow.ts b/static/src/stores/workflow.ts new file mode 100644 index 00000000..bc88503d --- /dev/null +++ b/static/src/stores/workflow.ts @@ -0,0 +1,106 @@ +import { defineStore } from 'pinia'; + +/** + * 工作流(Workflow)运行状态 store(对话级)。 + * + * 承载当前对话激活工作流的进度快照,供快捷窗口 WorkflowWindow 渲染。 + * 数据源(后续运行时实施接入): + * - 轮询事件 workflow_progress / workflow_review_progress / workflow_completed / ...(live=true,播动画) + * - GET /api/workflow/status 刷新恢复 / bootstrap 回填(live=false,静态呈现) + * + * 列表语义(与 demo/workflow_dock_window.html 定稿一致): + * history(已完成,可向上滚动查看)+ current(进行中)+ next(未来仅一步可见)。 + */ + +export interface WorkflowStepRecord { + name: string; + rounds: number | null; +} + +export interface WorkflowFootnote { + kind: 'info' | 'success' | 'danger'; + text: string; +} + +export interface WorkflowSnapshot { + /** 当前对话是否有活跃工作流(控制窗口出现/消失) */ + active: boolean; + /** 工作流显示名 */ + name: string; + status: 'active' | 'completed' | 'stopped' | 'failed' | null; + /** 已完成步骤(含「刚完成」,按完成顺序) */ + history: WorkflowStepRecord[]; + /** 当前进行中的阶段;完成/未激活时为 null */ + current: WorkflowStepRecord | null; + /** 下一阶段名(分支未选择时为 null);当前已是最后阶段时为「结束」;完成态为 null */ + next: string | null; + /** 当前阶段是否处于审核中(review 瞬态节点的副状态) */ + reviewing: boolean; + /** 瞬时脚注提示(审核中/驳回/完成等) */ + footnote: WorkflowFootnote | null; +} + +interface WorkflowState { + snapshot: WorkflowSnapshot; + /** true = 来自任务期实时事件(播动画);false = 来自加载/恢复(静态呈现) */ + live: boolean; +} + +const emptySnapshot = (): WorkflowSnapshot => ({ + active: false, + name: '', + status: null, + history: [], + current: null, + next: null, + reviewing: false, + footnote: null +}); + +export const useWorkflowStore = defineStore('workflow', { + state: (): WorkflowState => ({ + snapshot: emptySnapshot(), + live: false + }), + getters: { + isActive(state): boolean { + return state.snapshot.active; + } + }, + actions: { + /** 写入新快照。live=true 触发窗口动画;false 静态呈现(加载/刷新恢复)。 */ + setWorkflow(snapshot: Partial | null | undefined, live = false) { + this.live = live; + if (!snapshot || snapshot.active !== true) { + this.snapshot = emptySnapshot(); + return; + } + this.snapshot = { ...emptySnapshot(), ...snapshot, active: true }; + }, + /** 对话切换 / 离开对话时清空 */ + reset() { + this.snapshot = emptySnapshot(); + this.live = false; + }, + /** + * 对话切换时的状态回填:拉取目标对话的工作流状态覆盖本地(live=false 静态校正)。 + * 目标对话无激活工作流或请求失败时清空。与 QuickDock「保留旧内容直到回填」的 + * 防闪烁机制对齐:切换期间不清空,等本请求回来后一次性校正。 + */ + async fetchStatus(conversationId: string) { + try { + const resp = await fetch(`/api/workflow/status?conversation_id=${encodeURIComponent(conversationId)}`, { + credentials: 'same-origin' + }); + const data = await resp.json().catch(() => ({})); + if (resp.ok && data?.success && data?.snapshot?.active === true) { + this.setWorkflow(data.snapshot, false); + } else { + this.reset(); + } + } catch (err) { + this.reset(); + } + } + } +});