feat(workflow): 实现工作流运行时并统一审核智能体配置

工作流运行时:
- 状态机与编排:modules/workflow_state_manager.py + server/workflow_flow.py
  (激活快照/阶段推进/审核节点/分支决策/柔性通知/max_stage_rounds 撞限询问)
- 五个工具(activate/report_stage/choose_branch/get_status/deactivate)
  与 REST API(server/workflow_runtime_api.py)
- 前端:QuickDock 工作流窗口(三段式进度,推进/驳回/完成/退出动画)、
  slash 菜单激活与退出、轮询事件消费、进入对话状态回填
- 审核:modules/workflow_review_agent.py(pass/reject 把关节点)

审核智能体统一配置:
- 个人空间新增「审核智能体」标签页:自动审批/目标/工作流三个审核智能体
  统一选择模型+思考模式+超时/轮次参数
- modules/review_agent_config.py 统一解析(复用子智能体模型库),
  废除独立 json 配置(auto_approval/goal_review/workflow_review)
- goal 审核接入 max_rounds 上限(原常量未接线);workflow 审核硬编码 6 轮改为可配

联调修复:
- /new 空对话激活:后端自动创建对话并完整继承模式参数
  (work_mode/permission/execution/reasoning_effort,修复思考模式丢失)
- 激活/通知消息 starts_work=True,恢复智能体回复头部与工作计时
- 节点目录改为从开始节点拓扑遍历(修复按保存顺序显示错乱)
- QuickDock 乐观掩码不再掩盖工作流实时状态(修复 /new 激活窗口瞬关+延迟瞬开);
  /new 路由不套用全局内容缓存(修复空对话展开空白数秒后收回)
- 工作流完成先广播完成态快照再摘牌,窗口播完落定+退出动画再收起
- 激活提示中的工具名修正为 report_workflow_stage
This commit is contained in:
JOJO 2026-08-21 16:50:35 +08:00
parent d071292c81
commit 4d9b709a9e
37 changed files with 4052 additions and 232 deletions

View File

@ -356,11 +356,13 @@ AI 执行以下流程时,每一步都要向用户说明在做什么:
- 不要在提示词里暴露内部实现细节(例如“命令文本猜测”等) - 不要在提示词里暴露内部实现细节(例如“命令文本猜测”等)
- 文案应面向用户能力边界与操作建议,不描述内部判定算法 - 文案应面向用户能力边界与操作建议,不描述内部判定算法
### 10.5 自动审批智能体配置与调试 ### 10.5 审核智能体配置与调试
- 自动审批配置文件:`config/auto_approval.json` - 审核智能体配置统一在个人空间「审核智能体」页设置2026-08 起),存于 personalization.json 的 `review_agents`
- 建议字段:`name` / `url` / `key` / `model` / `extra_params` - 三个审核智能体:`auto_approval`(自动审批)/ `goal_review`(目标审核)/ `workflow_review`(工作流审核)
- 额外控制:`timeout_seconds` / `max_rounds` / `max_command_timeout` - 字段:`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` - 调试开关(代码变量):`modules/approval_agent.py` 中 `DEBUG_SAVE_APPROVAL_AGENT_TRANSCRIPT`
- 开启后写入:`logs/approval_agent/` - 开启后写入:`logs/approval_agent/`
- 记录以累积 `messages` 为主,便于对齐主/子智能体会话格式 - 记录以累积 `messages` 为主,便于对齐主/子智能体会话格式

View File

@ -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
}

View File

@ -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
}

View File

@ -319,6 +319,21 @@ class MessagesMixin:
if skills_prompt: if skills_prompt:
messages.append({"role": "system", "content": 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: def _build_memory_system_prompt() -> str:
return self._build_memory_system_content() return self._build_memory_system_content()

View File

@ -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.agent_tools import ToolsDefinitionAgentToolsMixin
from core.main_terminal_parts.tools_definition.context_tools import ToolsDefinitionContextToolsMixin 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.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 from core.main_terminal_parts.tools_definition.main import ToolsDefinitionMainMixin
class MainTerminalToolsDefinitionMixin( class MainTerminalToolsDefinitionMixin(
@ -19,6 +20,7 @@ class MainTerminalToolsDefinitionMixin(
ToolsDefinitionAgentToolsMixin, ToolsDefinitionAgentToolsMixin,
ToolsDefinitionContextToolsMixin, ToolsDefinitionContextToolsMixin,
ToolsDefinitionMiscToolsMixin, ToolsDefinitionMiscToolsMixin,
ToolsDefinitionWorkflowToolsMixin,
ToolsDefinitionMainMixin, ToolsDefinitionMainMixin,
): ):
pass pass

View File

@ -96,6 +96,7 @@ class ToolsDefinitionMainMixin:
tools.extend(self._build_agent_tools()) tools.extend(self._build_agent_tools())
tools.extend(self._build_context_tools()) tools.extend(self._build_context_tools())
tools.extend(self._build_misc_tools()) tools.extend(self._build_misc_tools())
tools.extend(self._build_workflow_tools())
# 多模态模型自带能力,不再暴露 vlm_analyze改为 view_image / view_video # 多模态模型自带能力,不再暴露 vlm_analyze改为 view_image / view_video
model_key = getattr(self, "model_key", None) model_key = getattr(self, "model_key", None)
if model_key and (model_supports_image(model_key) or model_supports_video(model_key)): if model_key and (model_supports_image(model_key) or model_supports_video(model_key)):

View File

@ -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"],
},
},
},
]

View File

@ -578,6 +578,51 @@ class MainTerminalToolsExecutionMixin:
result.pop("target_dir", None) result.pop("target_dir", None)
return result 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]: def _handle_update_project_memory(self, name: str, description: str, content: str) -> Dict[str, Any]:
"""处理 update_project_memory写入 .astrion/memory/{name}.md""" """处理 update_project_memory写入 .astrion/memory/{name}.md"""
safe_name = str(name).strip() safe_name = str(name).strip()
@ -1129,6 +1174,12 @@ class MainTerminalToolsExecutionMixin:
self._mark_file_read_from_result(tool_name, arguments, result) self._mark_file_read_from_result(tool_name, arguments, result)
elif tool_name == "create_skill": elif tool_name == "create_skill":
result = self._handle_create_skill_tool(arguments) 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"}: elif tool_name in {"vlm_analyze", "ocr_image"}:
path = arguments.get("path") path = arguments.get("path")
prompt = arguments.get("prompt") prompt = arguments.get("prompt")

View File

@ -152,9 +152,7 @@
### 7.4 自动审批approval agent ### 7.4 自动审批approval agent
- 自动审批由独立审批智能体执行(与主智能体分离) - 自动审批由独立审批智能体执行(与主智能体分离)
- 配置文件:`config/auto_approval.json` - 配置个人空间「审核智能体」页统一设置personalization.json 的 `review_agents.auto_approval`),模型复用子智能体模型库;旧的 `config/auto_approval.json` 已彻底废除
- `name` / `url` / `key` / `model` / `extra_params`
- `timeout_seconds` / `max_rounds` / `max_command_timeout`
- 审批智能体可调用能力: - 审批智能体可调用能力:
- `run_command`(用于只读核查) - `run_command`(用于只读核查)
- 审批决策工具approved / rejected + reason - 审批决策工具approved / rejected + reason

View File

@ -1,141 +1,260 @@
# 工作流Workflow功能设计方案 # 工作流Workflow功能设计与实施方案
> 状态设计讨论中2026-08-18 更新,纳入阶段汇报工具 / 非线性路由 / 可视化编辑决策)。 > 状态:**设计定稿2026-08-21**。工作流库页与可视化编辑器已实现;本文档是运行时实施的最终依据。
> 调研依据:`workflow_research/external_products/agentic_workflow_research.md`(外部产品)、`workflow_research/code_mount_points/code_mount_points.md`(代码挂载点)。 > 调研依据:`workflow_research/` 三份报告(外部产品 / 范式要素 / 代码挂载点)。
> 历史说明:本文档前身是 2026-08-18 的讨论稿;当时「审核作为阶段属性」「兜底提醒轮询」「整体结束审核」等设想已在定稿中推翻,以本文档为准。
## 1. 功能定义 ## 1. 功能定义与核心理念
用户把「一套既定流程:工作方式 → 验证方式 → 结束方式」存为一个**工作流**workflow之后在任何普通对话中激活它让主智能体复用现有循环与执行引擎按流程推进阶段结束/整体结束时可由审核智能体软审核。 用户把「一套既定流程:工作方式 → 验证方式 → 结束方式」存为一个**工作流**workflow之后在任何普通对话中激活它让主智能体复用现有循环与执行引擎按流程推进阶段可由审核智能体软审核。
核心原则(外部调研共识):**结构在边上,自主在节点内**——工作流只约束阶段拓扑与进出契约,阶段内主智能体完全自主(自由调工具、跑脚本)。 三条核心理念(定稿):
## 2. 已拍板的设计决策 1. **结构在边上,自主在节点内**——画布拓扑(节点 + 路由是结构约束阶段内主智能体完全自主自由调工具、跑脚本。外部调研LangGraph / Dify / CrewAI / n8n一致验证的范式。
2. **工作流是智能体的辅助流程,不是宿主**——任何工作流异常/终态都不得掐断智能体工作。所有退出/失败/超限统一为**柔性通知**:摘牌(改状态)+ 一条 user 消息,处置权交回模型与用户。
3. **对话级持续状态**——工作流状态不随单次任务结束、模型停止输出、用户按停止按钮而结束。模型停下后用户可自由穿插讨论,说「继续」即接着推进。
## 2. 已拍板决策(定稿汇总)
| 决策点 | 结论 | | 决策点 | 结论 |
|---|---| |---|---|
| 运行方式 | **不做独立模式/特殊 URL/新对话类型**,只能在正常对话中激活 | | 运行方式 | 不做独立模式/特殊 URL/新对话类型;在普通对话中激活 |
| 激活入口 | **/ 菜单slash 菜单)** 人工激活 + **AI 工具 `activate_workflow`** 自主激活 | | 激活入口 | slash 菜单人工激活(仅智能体空闲可用)+ AI 工具 `activate_workflow` 自主激活 |
| 阶段边界 | **显式化**AI 调**阶段汇报工具**声明阶段完成(期间汇报触发审核与推进;无后续阶段时该汇报即停止信号) | | 同时激活数 | **一个对话同时只有一个工作流处于激活**;重复激活同工作流幂等返回进度,激活另一个被拒绝 |
| 阶段模型 | **非线性**阶段间为候选路由集结构约束候选、AI 自主选择、审核把关),非纯线性 | | 审核建模 | **独立 review 节点**(菱形,一等公民),非阶段属性;要结束审核就在 end 前显式放 review 节点 |
| 可视化编辑 | 拖拽画布编辑器(类似 ComfyUI 流程图)纳入设计范围,编辑对象是 WORKFLOW.md 的阶段拓扑 | | 结束节点语义 | end 即终点,汇报到 end 即 completed**无隐藏的整体结束审核** |
| 与目标模式关系 | **不互斥**:工作流可以是目标模式中的一个小步骤;目标模式后续将重做,本期不考虑两者冲突 | | 分支节点语义 | 单出线 = 并线器(自动穿过,无需决策);多出线 = AI 决策点(等 `choose_workflow_branch` |
| 审核配置 | **独立**`workflow_review.json`(走 resolve_deploy_config 回退链)+ frontmatter `review_mode` 自声明 | | 审核异常 | 视为驳回(计入 reject_counts工具返回中含「请告知用户」 |
| 前后端通信 | **轮询**(复用 task 轮询 session_data 快照链路,不新增 websocket 通道) | | maxRejects 撞限 | 工作流 failed经工具返回告知模型已在场不另发 user 消息 |
| 状态隔离 | **对话级状态**,多对话可同时激活不同工作流,进度互不串扰 | | max_stage_rounds 撞限 | **不停工作流、不停智能体**;注入 user 消息让模型立刻停下、告知用户、询问是否继续 |
| 硬校验 | 不做步骤级硬校验归档时只做结构校验frontmatter 合法、阶段 id 唯一、路由目标存在) | | deactivate用户/模型/撞限) | 一律柔性通知退出,见 §4.7 |
| 存储形态 | 工作流文件夹 = `WORKFLOW.md`frontmatter 结构 + 自然语言正文)+ `scripts/`(已验证脚本)+ `references/`(可选),与 skill 对齐 | | 兜底提醒轮询 | **取消**。模型停止输出即停止,工作流挂起不断,期间可与用户讨论 |
| 停止任务联动 | **不做**(不仿 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` | | `start` | 胶囊 | 入口,右 1 出,恰好 1 个 |
| docker/web | `users/<user>/personal/workflows/` | 每用户私有,多项目共享(对齐 `infer_private_skills_dir` | | `end` | 胶囊 | 终点,左 n 入,至少 1 个 |
| `stage` | 矩形 | AI 执行阶段:`goal` + `instructions` + `next`(单值,显式连接) |
| `review` | 菱形 | 审核把关:`prompt`(审核关注点)+ `next`(通过路由)+ `rejectTo`(驳回路由)+ `maxRejects`(连续驳回上限,超限 failed |
| `branch` | 虚线矩形 | 分线/并线器:`next[]` 路由数组,每条带 `condition`自然语言AI 决策依据;多出线必填) |
- 源码树 `workflows/` 只放内置示例种子,双源合并。 所有路径显式化(`next` 为 null 校验不通过);`position` 仅画布坐标,与执行解耦。
- 归档工具 `create_workflow`:校验 → `shutil.move` → 已存在拒绝覆盖(复用 `archive_skill_directory` 模式)。
- 运行时状态:`{workspace.data_dir}/workflow_states/<conversation_id>.json`(对话级,对齐已修复的 goal 对话级方案)。
## 4. WORKFLOW.md 格式(非线性候选路由 ### 3.2 工作流库(已实现)
```markdown - WORKFLOW.md = YAML frontmatter上述结构+ markdown 正文(工作方式/验证方式/结束方式)
--- - 双源合并:源码树 `workflows/`(内置种子)+ 运行态用户库host`~/.astrion/astrion/host/workflows/`
name: code-review-pipeline - 模块:`modules/workflow_manager.py`CRUD + 校验 + camelCase↔snake_case
description: 代码评审标准流程 - REST`server/workflow_page.py``/workflows`、`/workflow/<name>` 页面 + `/api/workflows` CRUD
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: 报告落盘且审核通过
---
# 工作方式 / 验证方式 / 结束方式(自然语言正文,随阶段上下文注入) ### 3.3 运行时状态目录(待实现)
```
- **路由语义**`next` 是候选集结构约束AI 在阶段汇报工具中传 `next_stage_id` 自主选择;审核智能体可否决路由选择(打回时附带建议去向)。
- **结构校验**归档时name/description/entry/stages 齐全、id 唯一、next 引用存在、entry 可达终点。
- `position` 字段(可选)仅记录画布坐标,供可视化编辑器使用,不影响执行。
## 5. 运行时机制(复用现有引擎 + 显式阶段边界)
### 主路径:阶段汇报工具驱动
``` ```
对话激活工作流 → 注入入口阶段上下文goal + 工作方式 + 候选路由) {workspace.data_dir}/workflow_states/<conversation_id>/
├── state.json # 运行状态
主智能体正常跑(现有引擎不变,自由调工具、跑 scripts/ └── WORKFLOW.md # 激活时刻的原样快照
AI 调 report_workflow_stage(summary, next_stage_id?) ← 阶段边界显式声明
当前阶段 review=true──否──→ 校验 next_stage_id 在候选集 → 推进,注入新阶段上下文
│是
WorkflowReviewAgent 审核active 模式可只读取证)
├─ pass → 推进next 为空 → 整体结束审核 → done停止
└─ retry → 工具结果返回整改反馈,本阶段继续
``` ```
### 兜底:无 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 - **`current_node_id` 只停 stage / branch / end**——review 是**瞬态节点**汇报时同步审完直接走到下一站review 只作为 history 记录。状态机与前端进度展示因此大幅简化。
- **压缩维持**:状态落盘 + `handle_task_with_sender` 入口重注入当前阶段上下文(对齐 goal 重注入模式)。 - **`stage_start_msg_index`(消息游标)**:进入阶段时记录 conversation_history 长度,审核 payload 截取增量作为阶段工作痕迹§4.5)。
- **编排模块**:新增 `server/workflow_flow.py`(仿 `server/goal_flow.py`)。 - **`pending_notices`**柔性通知池§4.7),落盘持久,取出即清除。
- **审核智能体**`modules/workflow_review_agent.py`fork `goal_review_agent.py`),内部工具 `report_workflow_stage_status`pass/retry/complete配置 `resolve_deploy_config("workflow_review.json")`
- **与目标模式并存**:不互斥;目标模式后续重做时统一考虑两者关系。 ## 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` 为 asynctools_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.rejectTostage 或 branch工具返回带审核意见。
- **审核异常/超时** → 视为驳回(计入计数),返回中含「审核服务可能异常,**请告知用户**」。
- **撞 maxRejects** → status=failedexit_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 |
### REST3 个)
- `POST /api/workflow/activate`仅智能体空闲409 检查);快照+初始化+派发完整 user 消息任务
- `POST /api/workflow/deactivate`:随时可用;忙入通知池、闲直发任务
- `GET /api/workflow/status?conversation_id=`:轮询/刷新恢复
## 6. 通信与前端 ## 6. 通信与前端
- **轮询链路**现成sender 事件 → `session_data` 快照 → REST 轮询透传 → 前端 `taskPolling/lifecycle.ts` case 消费。 - **轮询事件**sender → session_data 快照 → REST 轮询,对齐 goal 链路):`workflow_progress`(推进/驳回)/ `workflow_review_progress`(审核进行中)/ `workflow_completed` / `workflow_stopped` / `workflow_failed`。快照自带 conversation_id。
- 新增事件:`workflow_progress` / `workflow_review_progress` / `workflow_completed` / `workflow_stopped`,快照自带 `conversation_id`(对齐 goal 修复后的过滤机制)。 - **slash 菜单**:新增 workflows 模式,数据用现成 `GET /api/workflows`
- **激活**slash 菜单新增 `workflows` 模式(数据 `GET /api/workflows``POST /api/workflow/activate|deactivate`。 - **进度展示**:仿 goal 进度组件——当前阶段名 · 已走路径 · 审核状态 · 轮数。
- **进度展示**:仿 goal 进度组件——当前阶段名 · 已完成阶段列表 · 审核状态 · 轮数。 - **刷新恢复**`GET /api/workflow/status`。
- **状态 API**`GET /api/workflow/status?conversation_id=` 供轮询/刷新恢复。
## 7. 可视化拖拽编辑器(设计讨论中) ## 7. 已实现部分清单(编辑器期
- **技术选型**Vue Flow项目为 Vue 3React Flow 的 Vue 移植API 同构)。 - `modules/workflow_manager.py`、`server/workflow_page.py`
- **编辑对象**WORKFLOW.md frontmatter 的 stages 拓扑;节点 = 阶段,边 = next 候选路由;`position` 仅存坐标,与执行解耦。 - `static/src/components/workflow/``workflowModel.ts`(模型/校验/dagre 自动排版)、`WorkflowLibraryView.vue`、`WorkflowEditorView.vue`、四种节点组件
- **与 AI 生成协同**AI `create_workflow` 生成归档 → 画布打开可视化/微调;画布编辑 → 序列化回 WORKFLOW.md。 - `workflows/` 四个内置示例code-review-pipeline / bug-fix-triage / feature-development / research-report
- **页面形态与一期范围**:待定(见 §9
## 8. 新增工具与文件清单 ## 8. 实施清单(文件级,按依赖顺序)
| 项 | 类型 | 说明 | 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`)。
| `create_workflow` | AI 工具 | 生成并校验归档(仿 create_skill | 3. `server/workflow_flow.py`(新增):编排——`build_activation_text`、system 段构建、推进矩阵、审核调用、通知文本构造、状态查询。
| `read_workflow` | AI 工具 | 读工作流定义(仿 read_skill | 4. 工具注册:`core/main_terminal_parts/tools_definition/` 新增 5 个定义;`tools_execution.py` 注册 handler。
| `activate_workflow` / `deactivate_workflow` | AI 工具 | 开启/退出当前对话的工作流 | 5. system 段注入:`core/main_terminal_parts/context/messages.py` 追加工作流段(不冻结)+ 推进时刷新机制。
| `report_workflow_stage` | AI 工具 | **阶段汇报**:期间汇报触发审核推进;无后续阶段时即停止信号 | 6. 主循环挂点(`server/chat_flow_task_main.py`stage_rounds 计数与撞限注入;`needs_completion_poll` 扩展;`_collect_pending_completion_notices` 加 workflow 路;工具循环末尾消费工作流通知。
| `report_workflow_stage_status` | 审核智能体内部工具 | pass/retry/complete仿 report_goal_status | 7. REST`server/tasks/workflows.py`新增activate / deactivate / status。
| `modules/workflows_manager.py` | 模块 | 校验/归档/目录合并(仿 skills_manager | 8. 前端slash 菜单 workflows 模式、进度组件、轮询事件消费(`taskPolling/lifecycle.ts`)、刷新恢复。
| `modules/workflow_state_manager.py` | 模块 | 对话级状态落盘(对齐 goal_state_manager 对话级方案) | 9. 验证:`python3 -m py_compile` 改动文件 + `python -m pytest test/test_server_refactor_smoke.py -q`
| `modules/workflow_review_agent.py` | 模块 | 阶段/整体审核 |
| `server/workflow_flow.py` | 模块 | 编排:激活/注入/汇报处理/推进/兜底提醒 |
| `server/tasks/workflows.py` | REST API | 列表/激活/状态/画布读写 |
| `prompts/workflow.txt` | 提示词 | 工作流上下文模板 |
## 9. 待确认 ## 9. 硬约束(实施必须遵守)
- 可视化编辑器页面形态:独立路由页面(如 `/workflows` 库 + 编辑器)还是对话内弹层/抽屉? - **单写者/门闸**:所有工作流逻辑发生在主任务内部(工具执行 + 主循环层不新增主任务入口REST 派发走门闸预占AGENTS.md §12
- 编辑器一期范围:完整拖拽编辑(增删节点/连线/改参数/保存)还是先做只读可视化 + 文本编辑? - **注入时序铁律**:工具循环内禁止直接 `inject_runtime_user_message`——一律「循环内收集 → 循环后注入」(记忆 `runtime_injected_message_convention`)。
- 兜底提醒的容忍轮数(建议连续 2 轮无响应转空转停止)。 - **运行期注入标记**:注入的 user 消息必须走 `inject_runtime_user_message`(自动带 `runtime_injected` 等标记),否则刷新恢复重建会重复显示。
- **不掐断智能体**:任何工作流路径不得 raise/return 导致主任务异常终止;柔性优先。

View File

@ -1,7 +1,6 @@
from __future__ import annotations from __future__ import annotations
import json import json
import os
import time import time
import uuid import uuid
from pathlib import Path from pathlib import Path
@ -9,11 +8,10 @@ from typing import Any, Callable, Dict, List, Optional
import httpx 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/<mode>/config
CONFIG_PATH = Path(resolve_deploy_config("auto_approval.json"))
DEFAULT_MAX_ROUNDS = 3 DEFAULT_MAX_ROUNDS = 3
DEFAULT_TIMEOUT_SECONDS = 60 DEFAULT_TIMEOUT_SECONDS = 60
DEFAULT_MAX_COMMAND_TIMEOUT = 20 DEFAULT_MAX_COMMAND_TIMEOUT = 20
@ -21,41 +19,11 @@ DEBUG_SAVE_APPROVAL_AGENT_TRANSCRIPT = True
DEBUG_TRANSCRIPT_DIR = Path(LOGS_DIR) / "approval_agent" 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]: def load_approval_agent_config() -> Dict[str, Any]:
base = { """加载审批智能体配置:统一走个人空间「审核智能体」设置 + 子智能体模型库。"""
"name": "auto-approval-agent", cfg = resolve_review_agent_config("auto_approval")
"url": "", cfg["name"] = "auto-approval-agent"
"key": "", return cfg
"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
class ApprovalAgent: class ApprovalAgent:

View File

@ -15,7 +15,6 @@
from __future__ import annotations from __future__ import annotations
import json import json
import os
import time import time
import uuid import uuid
from pathlib import Path from pathlib import Path
@ -23,11 +22,10 @@ from typing import Any, Callable, Dict, List, Optional
import httpx 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.goal_state_manager import REVIEW_MODE_ACTIVE, REVIEW_MODE_READONLY
from modules.review_agent_config import resolve_review_agent_config
# 部署级配置(目标审核智能体 url/key/model含密钥引用→ 优先 ~/.astrion/<mode>/config
CONFIG_PATH = Path(resolve_deploy_config("goal_review.json"))
PROMPT_NAME = "goal_review_agent.txt" PROMPT_NAME = "goal_review_agent.txt"
DEFAULT_MAX_ROUNDS = 3 DEFAULT_MAX_ROUNDS = 3
DEFAULT_TIMEOUT_SECONDS = 60 DEFAULT_TIMEOUT_SECONDS = 60
@ -39,39 +37,11 @@ DEBUG_TRANSCRIPT_DIR = Path(LOGS_DIR) / "goal_review_agent"
FALLBACK_CONTINUE_MESSAGE = "审核未能给出明确结论。请重新对照目标核查当前进度,找出尚未完成的部分并继续推进。" 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]: def load_goal_review_agent_config() -> Dict[str, Any]:
base = { """加载目标审核智能体配置:统一走个人空间「审核智能体」设置 + 子智能体模型库。"""
"name": "goal-review-agent", cfg = resolve_review_agent_config("goal_review")
"url": "", cfg["name"] = "goal-review-agent"
"key": "", return cfg
"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
class GoalReviewAgent: class GoalReviewAgent:
@ -222,6 +192,7 @@ class GoalReviewAgent:
endpoint = f"{url.rstrip('/')}/chat/completions" endpoint = f"{url.rstrip('/')}/chat/completions"
headers = {"Authorization": f"Bearer {key}", "Content-Type": "application/json"} headers = {"Authorization": f"Bearer {key}", "Content-Type": "application/json"}
timeout_seconds = int(self.cfg.get("timeout_seconds") or DEFAULT_TIMEOUT_SECONDS) 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 {}) extra_params = dict(self.cfg.get("extra_params") or {})
tools = self._build_tools(review_mode) tools = self._build_tools(review_mode)
@ -235,6 +206,10 @@ class GoalReviewAgent:
forced_tool_retry_used = False forced_tool_retry_used = False
while True: while True:
rounds += 1 rounds += 1
if rounds > max_rounds:
out = _continue(f"目标审核超过 {max_rounds} 轮未产出结论,请继续推进目标。")
_flush_trace(out)
return out
if cancel_check: if cancel_check:
external = cancel_check() external = cancel_check()
if external: if external:

View File

@ -58,6 +58,9 @@ MIN_SHALLOW_KEEP_USER_TURN_TOOLS = 0
MAX_SHALLOW_KEEP_USER_TURN_TOOLS = 50 MAX_SHALLOW_KEEP_USER_TURN_TOOLS = 50
DEFAULT_SHALLOW_KEEP_USER_TURN_TOOLS = 3 DEFAULT_SHALLOW_KEEP_USER_TURN_TOOLS = 3
# 三个审核智能体的固定键:自动审批 / 目标审核 / 工作流审核
REVIEW_AGENT_KEYS = ("auto_approval", "goal_review", "workflow_review")
DEFAULT_PERSONALIZATION_CONFIG: Dict[str, Any] = { DEFAULT_PERSONALIZATION_CONFIG: Dict[str, Any] = {
"enabled": False, "enabled": False,
"communication_style": "default", # default / human_like / auto "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_compress_threshold_tokens": 150000, # 子智能体上下文压缩阈值,最小 10000
"sub_agent_max_turns": None, # 子智能体最大执行轮次None-默认 50 / 0-无上限 / 正整数-该值 "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__ = [ __all__ = [
@ -680,6 +689,12 @@ def sanitize_personalization_payload(
else: else:
base["project_memory_inject_limit"] = _sanitize_project_memory_inject_limit(base.get("project_memory_inject_limit")) 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 return base
@ -696,6 +711,34 @@ def _sanitize_sub_agent_max_turns(value: Any) -> Optional[int]:
return min(parsed, 100_000) # 防御性上限;需要更大时用 0 表示无上限 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]: def _sanitize_project_memory_inject_limit(value: Any) -> Optional[int]:
"""清洗项目记忆索引最大注入条数None/''/0/负数 → None无上限非法值 → 默认 20正整数钳到 [5, 100000]。""" """清洗项目记忆索引最大注入条数None/''/0/负数 → None无上限非法值 → 默认 20正整数钳到 [5, 100000]。"""
if value is None or value == "": if value is None or value == "":

View File

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

View File

@ -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"}
兜底异常/超时/未产出结论/配置缺失一律返回 rejectmessage 说明审核服务异常
并提示主智能体请告知用户定稿决策审核异常视为驳回计入 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",
}
)

View File

@ -0,0 +1,390 @@
"""工作流Workflow对话级运行状态管理。
状态目录`{data_dir}/workflow_states/<conversation_id>/`
- ``state.json``运行状态current / reject_counts / stage_rounds / history / pending_notices
- ``WORKFLOW.md``激活时刻的工作流定义原样快照运行期一切读取下一节点详情
分支候选审核 promptmaxRejects只读快照库文件在运行期间被修改不影响本实例
设计要点定稿文档 docs/workflow_feature_plan.md
- 柔性原则工作流是智能体的辅助流程不是宿主所有终态completed/stopped/failed
都只是摘牌改状态 + 停止注入 + 柔性通知绝不掐断智能体自身的工作循环
- review 是瞬态节点同步审核完直接走到下一站``current_node_id`` 只停
stage / branch / endreview 只作为 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/<conversation_id>/ 目录。"""
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,
}

View File

@ -0,0 +1,25 @@
你是工作流阶段审核智能体,负责判断工作流当前阶段的产出是否达到进入下一阶段的门槛。
你会收到一段文本,包含:工作流名称与描述、本次审核的关注点、被审核阶段的目标与要求、该阶段的执行痕迹(主执行模型在本阶段内的工具调用时间线)、主执行模型的阶段汇报,以及此前针对本阶段的历次驳回意见(如有)。
判断必须基于可观测的证据,而非主执行模型的口头声明——它说"我做完了"不算数。
你必须调用 report_workflow_review 返回结论,严禁在普通文本里输出"通过/不通过"这类结论。
若产出合格decision=passmessage 简述通过理由(给主执行模型与用户看,一两句即可)。
若产出不合格decision=rejectmessage 写给主执行模型的整改意见,具体、可执行——指出缺什么、怎么补、重新审核时要看到什么证据。
你必须采用"挑剔的把关者"立场:谨慎、多疑,默认产出不合格,除非已有充分、可复现的证据证明阶段目标确实达成。但你的审查范围以本次给定的「审核关注点」与「阶段目标/要求」为边界——不要把标准扩大到与阶段目标无关的领域,也不要无限穷举手段;只有当某个缺失手段明显可用、成本合理、且直接影响阶段目标达成时,才应视为证据不足。
判定 pass 前,必须确认:
1. 阶段目标goal中的每一项显式要求都已完成且有执行痕迹或你可核实的证据支撑
2. 阶段要求instructions中的关键约束如输出格式、落盘位置、验证方式已被遵守
3. 没有未解释的失败、报错、跳过项或证据空白;
4. 若主执行模型的汇报与执行痕迹矛盾,以执行痕迹为准;痕迹不足时{{ACTIVE_REVIEW_ONLY}}先取证再结论{{/ACTIVE_REVIEW_ONLY}},痕迹不足且无法取证时判 reject 并说明缺什么证据。
以下情况一律判 reject
- 只有主执行模型口头声明完成,无对应执行痕迹;
- 只完成了阶段目标的一部分;
- 关键结论没有佐证(如该验证的没验证、该落盘的没落盘);
- 执行痕迹显示走了捷径,明显违背阶段要求。
如果判定 rejectmessage 必须具体指出缺口,并给出可执行的补救清单:需要补做哪些事、用哪些工具/命令验证、重新审核时要看到什么证据。不要写空泛的"请继续努力"。
{{ACTIVE_REVIEW_ONLY}}
你可以使用 run_command 执行只读命令查看文件、跑测试、git diff 等)来核实证据,核实完成后再给结论。
{{/ACTIVE_REVIEW_ONLY}}

View File

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

View File

@ -153,6 +153,7 @@ _VALID_USER_MESSAGE_SOURCES = {
"permission", "permission",
"sandbox", "sandbox",
"skill", "skill",
"workflow",
} }
@ -169,6 +170,10 @@ def _user_message_ui_defaults(source: str, *, auto_user_message_event: bool = Fa
# 目标审核续命:确实开启新一轮工作,保持 starts_work=True。 # 目标审核续命:确实开启新一轮工作,保持 starts_work=True。
if normalized == "goal_review": if normalized == "goal_review":
return {"visibility": "compact", "starts_work": True} return {"visibility": "compact", "starts_work": True}
# 工作流激活/柔性通知(任务入口派发):开启新一轮工作,显示工作头与计时器。
# 运行期 inline 注入走 _runtime_message_ui_defaults延续当前工作段不受影响
if normalized == "workflow":
return {"visibility": "chat", "starts_work": True}
if normalized == "presend": if normalized == "presend":
return {"visibility": "chat", "starts_work": True} return {"visibility": "chat", "starts_work": True}
if normalized == "goal": 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(), "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) notices.sort(key=lambda item: item.get("sort_key") or 0)
return notices 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) sub_manager = getattr(web_terminal, "sub_agent_manager", None)
announced = getattr(web_terminal, "_announced_sub_agent_tasks", set()) announced = getattr(web_terminal, "_announced_sub_agent_tasks", set())
bg_manager = getattr(web_terminal, "background_command_manager", None) bg_manager = getattr(web_terminal, "background_command_manager", None)
wf_restore: List[Dict[str, Any]] = []
for item in notices or []: for item in notices or []:
payload = item.get("payload") or {} payload = item.get("payload") or {}
if item.get("kind") == "sub_agent": 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 rec["notified"] = False
except Exception: except Exception:
pass 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: if sub_manager:
try: try:
sub_manager._save_state() sub_manager._save_state()
@ -895,6 +947,16 @@ def _has_pending_completion_work(*, web_terminal, conversation_id: str) -> bool:
return False 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): 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 本轮询器继续消费剩余通知 # 由该任务结束后重新 spawn 本轮询器继续消费剩余通知
return 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: if not pending:
break break
@ -1560,6 +1626,7 @@ async def handle_task_with_sender(
except Exception: except Exception:
user_message_index = -1 user_message_index = -1
current_work_user_message_index = user_message_index current_work_user_message_index = user_message_index
skill_context_messages = getattr(web_terminal, "_skill_context_messages", None) skill_context_messages = getattr(web_terminal, "_skill_context_messages", None)
if not auto_user_message_event and isinstance(skill_context_messages, list): if not auto_user_message_event and isinstance(skill_context_messages, list):
for skill_item in skill_context_messages: for skill_item in skill_context_messages:
@ -1855,6 +1922,26 @@ async def handle_task_with_sender(
messages = web_terminal.build_messages(context, message) messages = web_terminal.build_messages(context, message)
tools = web_terminal.define_tools() 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启动 / 续注入 === # === 目标模式Goal Mode启动 / 续注入 ===
# 1) 用户请求开启目标模式 → 无条件覆盖工作区旧目标状态并启动新目标。 # 1) 用户请求开启目标模式 → 无条件覆盖工作区旧目标状态并启动新目标。
# 2) 工作区已存在活动目标(含压缩后重入、子智能体通知重入)→ 重新注入提示词, # 2) 工作区已存在活动目标(含压缩后重入、子智能体通知重入)→ 重新注入提示词,
@ -2019,6 +2106,27 @@ async def handle_task_with_sender(
total_iterations += 1 total_iterations += 1
iteration_limit_label = max_iterations if max_iterations is not None else "" iteration_limit_label = max_iterations if max_iterations is not None else ""
debug_log(f"\n--- 迭代 {current_iteration}/{iteration_limit_label} 开始 ---") 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: 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( or _has_pending_completion_work(
web_terminal=web_terminal, conversation_id=conversation_id 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: if needs_completion_poll:
ma_debug( ma_debug(

View File

@ -29,6 +29,7 @@ _VALID_SOURCES = {
"network_change", "network_change",
"work_mode_change", "work_mode_change",
"file_paths", "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}") 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): 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 消息通知。""" """轮询后台 run_command 完成状态并发送 system 消息通知。"""
manager = getattr(web_terminal, "background_command_manager", None) manager = getattr(web_terminal, "background_command_manager", None)

View File

@ -24,7 +24,7 @@ from modules.personalization_manager import load_personalization_config, resolve
from modules.auto_approval_service import run_auto_approval from modules.auto_approval_service import run_auto_approval
from modules.user_question_manager import format_user_question_answer from modules.user_question_manager import format_user_question_answer
from .deep_compression import run_deep_compression 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]]: 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) 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 常规链
审核在工具内同步 awaithandler 支持长阻塞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: 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 工具的计划批准流:弹窗展示计划文档 → 用户批准/拒绝 → """submit_plan 工具的计划批准流:弹窗展示计划文档 → 用户批准/拒绝 →
批准则自动切换运行模式到 execute联动恢复权限并更新运行期模式基线""" 批准则自动切换运行模式到 execute联动恢复权限并更新运行期模式基线"""
@ -922,6 +960,16 @@ async def _execute_tool_calls_impl(*, web_terminal, tool_calls, sender, messages
conversation_id=conversation_id, conversation_id=conversation_id,
tool_call_id=str(tool_call_id) if tool_call_id else None, 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: else:
tool_task = asyncio.create_task(web_terminal.handle_tool_call(function_name, arguments)) 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, sender=sender,
debug_log=debug_log, 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 都完成后再注入, # 运行期模式通知:必须等待同一轮全部 tool_call 都完成后再注入,
# 避免在 assistant.tool_calls 与对应 tool 消息之间插入 user 消息导致 API 报错。 # 避免在 assistant.tool_calls 与对应 tool 消息之间插入 user 消息导致 API 报错。

752
server/workflow_flow.py Normal file
View File

@ -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
记入 historyreview 驳回不记录阶段未完成
"""
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

View File

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

View File

@ -358,6 +358,7 @@
@delete-runtime-message="handleDeleteRuntimeMessage" @delete-runtime-message="handleDeleteRuntimeMessage"
@composer-height-change="handleComposerHeightChange" @composer-height-change="handleComposerHeightChange"
@toggle-goal-mode="handleToggleGoalMode" @toggle-goal-mode="handleToggleGoalMode"
@workflow-activated="handleWorkflowActivated"
@toggle-agent-type-menu="handleToggleAgentTypeMenu" @toggle-agent-type-menu="handleToggleAgentTypeMenu"
@select-new-conversation-type="handleSelectNewConversationType" @select-new-conversation-type="handleSelectNewConversationType"
@toggle-work-mode-menu="toggleWorkModeMenu" @toggle-work-mode-menu="toggleWorkModeMenu"

View File

@ -2,6 +2,7 @@
import { debugLog, traceLog } from '../common'; import { debugLog, traceLog } from '../common';
import { useQuickDockStore } from '../../../stores/quickDock'; import { useQuickDockStore } from '../../../stores/quickDock';
import { useConversationStore } from '../../../stores/conversation'; import { useConversationStore } from '../../../stores/conversation';
import { useWorkflowStore } from '../../../stores/workflow';
/** /**
* B * B
@ -120,6 +121,18 @@ export const bootstrapMethods = {
Array.isArray(data.edited_files) ? data.edited_files : [] 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 聚合, // 5. 运行中任务快速恢复(任务/事件/判据已由 bootstrap 聚合,
// 免去 GET /api/tasks + 历史死等 + GET /api/tasks/{id} 三次请求) // 免去 GET /api/tasks + 历史死等 + GET /api/tasks/{id} 三次请求)
const running = data.running || {}; const running = data.running || {};

View File

@ -3,6 +3,7 @@ import { probeMethods } from './probe';
import { aiStreamMethods } from './aiStream'; import { aiStreamMethods } from './aiStream';
import { toolMethods } from './tool'; import { toolMethods } from './tool';
import { goalMethods } from './goal'; import { goalMethods } from './goal';
import { workflowMethods } from './workflow';
import { titleMethods } from './title'; import { titleMethods } from './title';
import { lifecycleMethods } from './lifecycle'; import { lifecycleMethods } from './lifecycle';
import { messagingMethods } from './messaging'; import { messagingMethods } from './messaging';
@ -15,6 +16,7 @@ export const taskPollingMethods = {
...aiStreamMethods, ...aiStreamMethods,
...toolMethods, ...toolMethods,
...goalMethods, ...goalMethods,
...workflowMethods,
...titleMethods, ...titleMethods,
...lifecycleMethods, ...lifecycleMethods,
...messagingMethods, ...messagingMethods,

View File

@ -269,6 +269,14 @@ export const lifecycleMethods = {
this.handleGoalStopped?.(eventData, eventIdx); this.handleGoalStopped?.(eventData, eventIdx);
break; break;
case 'workflow_progress':
this.handleWorkflowProgress?.(eventData, eventIdx);
break;
case 'workflow_review_progress':
this.handleWorkflowReviewProgress?.(eventData, eventIdx);
break;
case 'append_payload': case 'append_payload':
this.handleAppendPayload(eventData, eventIdx); this.handleAppendPayload(eventData, eventIdx);
break; break;

View File

@ -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();
}
};

View File

@ -78,6 +78,41 @@ export const modeMethods = {
this.modeMenuOpen = false; this.modeMenuOpen = false;
this.modelMenuOpen = 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 // 输入栏「智能体/多智能体」类型选择器:空对话态可选(写 newConversationType
// 已有对话为禁用展示态(类型创建时确定、不可变)。 // 已有对话为禁用展示态(类型创建时确定、不可变)。
handleToggleAgentTypeMenu() { handleToggleAgentTypeMenu() {

View File

@ -7,6 +7,7 @@
}" }"
> >
<div ref="scrollRef" class="quick-dock__scroll" @scroll.passive="handleStackScroll"> <div ref="scrollRef" class="quick-dock__scroll" @scroll.passive="handleStackScroll">
<WorkflowWindow />
<TodoWindow /> <TodoWindow />
<RunnerWindow kind="agent" /> <RunnerWindow kind="agent" />
<RunnerWindow kind="cmd" /> <RunnerWindow kind="cmd" />
@ -52,14 +53,16 @@ import { useConversationStore } from '@/stores/conversation';
import { useUiStore } from '@/stores/ui'; import { useUiStore } from '@/stores/ui';
import { usePersonalizationStore, hasCachedQuickDockAutoExpand } from '@/stores/personalization'; import { usePersonalizationStore, hasCachedQuickDockAutoExpand } from '@/stores/personalization';
import { useFileStore } from '@/stores/file'; import { useFileStore } from '@/stores/file';
import { useWorkflowStore } from '@/stores/workflow';
import TodoWindow from './TodoWindow.vue'; import TodoWindow from './TodoWindow.vue';
import WorkflowWindow from './WorkflowWindow.vue';
import RunnerWindow from './RunnerWindow.vue'; import RunnerWindow from './RunnerWindow.vue';
import RunnerDetailPanel from './RunnerDetailPanel.vue'; import RunnerDetailPanel from './RunnerDetailPanel.vue';
import FileWindow from './FileWindow.vue'; import FileWindow from './FileWindow.vue';
/** /**
* 快捷窗口Quick Dock容器 * 快捷窗口Quick Dock容器
* 对话区右侧占位列待办 / 子智能体 / 后台指令 / 文件 个窗口上下排布 * 对话区右侧占位列工作流 / 待办 / 子智能体 / 后台指令 / 文件 个窗口上下排布
* 同时负责全局 菜单Esc 分层关闭列表轮询对话切换重置 * 同时负责全局 菜单Esc 分层关闭列表轮询对话切换重置
*/ */
@ -71,6 +74,7 @@ const bgStore = useBackgroundCommandStore();
const conversationStore = useConversationStore(); const conversationStore = useConversationStore();
const uiStore = useUiStore(); const uiStore = useUiStore();
const fileStore = useFileStore(); const fileStore = useFileStore();
const workflowStore = useWorkflowStore();
const personalizationStore = usePersonalizationStore(); const personalizationStore = usePersonalizationStore();
// hasContent / userCollapsed storeApp.vue // hasContent / userCollapsed storeApp.vue
@ -207,7 +211,7 @@ function releaseNoAnim(force = false) {
// hasContent dock // hasContent dock
watch( watch(
hasContent, hasContent,
() => { (content) => {
quickDock.userCollapsed = !autoExpand.value; quickDock.userCollapsed = !autoExpand.value;
}, },
// immediate // immediate
@ -450,6 +454,15 @@ watch(
// bootstrapedited_files fetchTodoList // bootstrapedited_files fetchTodoList
// /new app watcher // /new app watcher
quickDock.resetTransient(); quickDock.resetTransient();
//
// /new
// live=false退
const switchConvId = conversationStore.currentConversationId;
if (switchConvId) {
void workflowStore.fetchStatus(switchConvId);
} else {
workflowStore.reset();
}
refreshAll(); refreshAll();
// / // /
// flush:'sync' app watcher /new // flush:'sync' app watcher /new

View File

@ -0,0 +1,416 @@
<template>
<section
v-if="visible"
class="qd-window"
:class="{ 'qd-window-enter': windowEntering, 'qd-window-exit': windowExiting }"
>
<header class="qd-window__header">
<svg class="qd-window__icon" viewBox="0 0 16 16" fill="none">
<circle cx="3" cy="3.5" r="1.8" stroke="currentColor" stroke-width="1.3" />
<circle cx="13" cy="8" r="1.8" stroke="currentColor" stroke-width="1.3" />
<circle cx="3" cy="12.5" r="1.8" stroke="currentColor" stroke-width="1.3" />
<path
d="M4.8 3.5h4.4a2 2 0 0 1 2 2v.7"
stroke="currentColor"
stroke-width="1.3"
stroke-linecap="round"
/>
<path
d="M4.8 12.5h4.4a2 2 0 0 0 2-2v-.7"
stroke="currentColor"
stroke-width="1.3"
stroke-linecap="round"
/>
</svg>
<span class="qd-window__title">{{ snapshot.name }}</span>
</header>
<div ref="viewportRef" class="wf-viewport">
<ul ref="listRef" class="wf-list">
<li v-for="row in rows" :key="row.key" class="wf-row" :class="rowClasses(row)">
<span class="wf-row__dot"></span>
<span class="wf-row__name">{{ row.name }}</span>
<span v-if="row.kind === 'current' && row.reviewing" class="wf-row__meta">
<span class="wf-spinner"></span>审核中
</span>
<span v-else-if="row.rounds != null && row.kind !== 'next'" class="wf-row__meta">
{{ row.rounds }}
</span>
</li>
</ul>
</div>
<div
v-if="footnote"
class="wf-footnote"
:class="[`wf-footnote--${footnote.kind}`, { 'is-entering': footnoteEntering }]"
>
<span class="wf-footnote__dot"></span>
<span class="wf-footnote__text">{{ footnote.text }}</span>
</div>
</section>
</template>
<script setup lang="ts">
import { nextTick, ref, watch } from 'vue';
import { storeToRefs } from 'pinia';
import { useWorkflowStore } from '@/stores/workflow';
import type { WorkflowFootnote, WorkflowSnapshot } from '@/stores/workflow';
/**
* 工作流窗口Quick Dock 第五个窗口仅工作流活跃时出现
*
* 列表语义history已完成可向上滚动查看+ current进行中+ next未来仅一步
* 视口固定 3 行高刚好显示 刚完成/当前/下一步渲染到下一步为止不可向后滚动
*
* 动画 demo/workflow_dock_window.html 定稿一致
* 1. 推进当前行就地划线+点弹跳 列表平滑上滚一行 下一步底部进入
* 2. 审核当前行点变橙 + spinner审核中副状态
* 3. 驳回当前行红闪两下 未来行淡出 驳回目标行复活划线收回 上滚到位
* 实时事件live=true播动画加载/刷新恢复live=false静态呈现
*/
interface Row {
key: string;
name: string;
kind: 'done' | 'current' | 'next';
rounds: number | null;
reviewing: boolean;
justDone: boolean;
reviving: boolean;
leaving: boolean;
entering: boolean;
rejectFlash: boolean;
}
const SCROLL_MS = 420;
const REJECT_FLASH_MS = 660;
const LEAVE_MS = 240;
/** 整窗退出动画时长(与 CSS qd-window-out 对齐) */
const EXIT_MS = 240;
/** 工作流完成后停留展示时长,之后播退出动画收起窗口 */
const COMPLETED_LINGER_MS = 1600;
const workflowStore = useWorkflowStore();
const { snapshot } = storeToRefs(workflowStore);
const rows = ref<Row[]>([]);
const visible = ref(false);
const windowEntering = ref(false);
const windowExiting = ref(false);
const footnote = ref<WorkflowFootnote | null>(null);
const footnoteEntering = ref(false);
const viewportRef = ref<HTMLElement | null>(null);
/** 代际标记:动画流程中途来了新数据时,旧流程不再写状态(对齐 TodoWindow */
let gen = 0;
/** 行 key 去重序号(同名阶段被驳回重访时不冲突) */
let keySeq = 0;
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
function rowClasses(row: Row) {
return {
'is-done': row.kind === 'done',
'is-current': row.kind === 'current',
'is-next': row.kind === 'next',
'is-just-done': row.justDone,
'is-reviving': row.reviving,
'is-leaving': row.leaving,
'is-entering': row.entering,
'is-reject-flash': row.rejectFlash,
'is-reviewing': row.reviewing
};
}
function baseRow(name: string, kind: Row['kind'], rounds: number | null): Row {
keySeq += 1;
return {
key: `${kind}-${name}-${keySeq}`,
name,
kind,
rounds,
reviewing: false,
justDone: false,
reviving: false,
leaving: false,
entering: false,
rejectFlash: false
};
}
/** 从快照构建静态行集 */
function buildRows(snap: WorkflowSnapshot): Row[] {
const out: Row[] = snap.history.map((h) => baseRow(h.name, 'done', h.rounds));
if (snap.current) {
const cur = baseRow(snap.current.name, 'current', snap.current.rounds);
cur.reviewing = snap.reviewing;
out.push(cur);
if (snap.next) {
out.push(baseRow(snap.next, 'next', null));
}
} else if (snap.status === 'completed') {
out.push(baseRow('结束', 'done', null));
}
return out;
}
function scrollToBottom(smooth: boolean): Promise<void> {
return nextTick(() => {
const el = viewportRef.value;
if (!el) {
return;
}
const target = el.scrollHeight;
if (!smooth) {
el.scrollTop = target;
return;
}
return smoothScrollTo(el, target, SCROLL_MS);
});
}
/** 手写平滑滚动easeOutCubic与 demo 定稿参数一致 */
function smoothScrollTo(el: HTMLElement, target: number, ms: number): Promise<void> {
return new Promise((resolve) => {
const start = el.scrollTop;
const delta = target - start;
if (Math.abs(delta) < 1) {
el.scrollTop = target;
resolve();
return;
}
const t0 = performance.now();
const tick = (t: number) => {
const p = Math.min(1, (t - t0) / ms);
const e = 1 - Math.pow(1 - p, 3);
el.scrollTop = start + delta * e;
if (p < 1) {
requestAnimationFrame(tick);
} else {
resolve();
}
};
requestAnimationFrame(tick);
});
}
/** 全量静态渲染 + 锚底 */
function renderStatic(snap: WorkflowSnapshot) {
rows.value = buildRows(snap);
void scrollToBottom(false);
}
/** 变化类型判定(快照 diff */
function detectChange(
o: WorkflowSnapshot,
n: WorkflowSnapshot
): 'advance' | 'reject' | 'update' | 'replace' {
const oHist = o.history.map((h) => h.name);
const nHist = n.history.map((h) => h.name);
// history
if (nHist.length === oHist.length + 1 && nHist[nHist.length - 1] === o.current?.name) {
return 'advance';
}
// history
if (nHist.length < oHist.length && oHist.slice(0, nHist.length).every((v, i) => v === nHist[i])) {
return 'reject';
}
// //
if (
nHist.join('|') === oHist.join('|') &&
n.current?.name === o.current?.name &&
n.next === o.next
) {
return 'update';
}
return 'replace';
}
/** 推进动画:就地划线 → 上滚一行 → 新「下一步」进入 */
async function playAdvance(newSnap: WorkflowSnapshot, myGen: number) {
//
await scrollToBottom(true);
if (myGen !== gen) {
return;
}
const curRow = rows.value.find((r) => r.kind === 'current');
const nextRow = rows.value.find((r) => r.kind === 'next');
// 线 +
if (curRow) {
curRow.kind = 'done';
curRow.reviewing = false;
curRow.justDone = true;
}
// next
if (nextRow) {
nextRow.kind = newSnap.current ? 'current' : 'done';
}
//
if (newSnap.current && newSnap.next) {
const entering = baseRow(newSnap.next, 'next', null);
entering.entering = true;
rows.value.push(entering);
}
//
await scrollToBottom(true);
if (myGen !== gen) {
return;
}
//
renderStatic(newSnap);
}
/** 驳回动画:红闪 → 未来行淡出 → 目标行复活 → 上滚到位 */
async function playReject(oldSnap: WorkflowSnapshot, newSnap: WorkflowSnapshot, myGen: number) {
// 1.
const curRow = rows.value.find((r) => r.kind === 'current');
if (curRow) {
curRow.rejectFlash = true;
}
await wait(REJECT_FLASH_MS);
if (myGen !== gen) {
return;
}
const keepCount = newSnap.history.length; // history
// 2. done keepCount+1 + next
// keepCount done =
const targetRow = rows.value[keepCount];
rows.value.forEach((row, i) => {
if (i > keepCount && row.kind !== 'current') {
row.leaving = true;
}
});
// 3. 线 + +
if (targetRow && targetRow.kind === 'done') {
targetRow.kind = 'current';
targetRow.reviving = true;
targetRow.rounds = newSnap.current?.rounds ?? null;
}
// 4.
if (curRow) {
curRow.rejectFlash = false;
curRow.kind = 'next';
curRow.rounds = null;
curRow.reviewing = false;
}
await wait(LEAVE_MS);
if (myGen !== gen) {
return;
}
// 5.
rows.value = rows.value.filter((r) => !r.leaving);
await scrollToBottom(true);
if (myGen !== gen) {
return;
}
renderStatic(newSnap);
}
/** 就地更新(结构不变:审核态/轮数) */
function applyInlineUpdate(newSnap: WorkflowSnapshot) {
const curRow = rows.value.find((r) => r.kind === 'current');
if (curRow && newSnap.current) {
curRow.reviewing = newSnap.reviewing;
curRow.rounds = newSnap.current.rounds;
}
}
/** 整窗退出动画:淡出上移后由调用方隐藏 */
async function playWindowExit(myGen: number) {
windowExiting.value = true;
await wait(EXIT_MS);
if (myGen !== gen) {
return;
}
windowExiting.value = false;
}
watch(
snapshot,
async (newSnap, oldSnap) => {
const myGen = ++gen;
const live = workflowStore.live;
// /
if (!newSnap.active) {
// live=true slash 退
// /live=false
// completed 退 reset rows
if (oldSnap?.active && live && rows.value.length) {
await playWindowExit(myGen);
if (myGen !== gen) {
return;
}
}
rows.value = [];
footnote.value = null;
visible.value = false;
return;
}
footnote.value = newSnap.footnote;
// /
if (!oldSnap?.active || !rows.value.length) {
visible.value = true;
renderStatic(newSnap);
if (live) {
windowEntering.value = true;
setTimeout(() => {
windowEntering.value = false;
}, 320);
}
return;
}
// /
if (!live) {
renderStatic(newSnap);
return;
}
const change = detectChange(oldSnap, newSnap);
if (change === 'advance') {
await playAdvance(newSnap, myGen);
} else if (change === 'reject') {
await playReject(oldSnap, newSnap, myGen);
} else if (change === 'update') {
applyInlineUpdate(newSnap);
} else {
renderStatic(newSnap);
}
// 退
// store.reset()live=false使 dock hasContent watch
if (newSnap.status === 'completed' && myGen === gen) {
await wait(COMPLETED_LINGER_MS);
if (myGen !== gen) {
return;
}
await playWindowExit(myGen);
if (myGen !== gen) {
return;
}
rows.value = [];
footnote.value = null;
visible.value = false;
workflowStore.reset();
}
},
// immediate + flush:sync TodoWindow
{ flush: 'sync', immediate: true }
);
//
watch(
() => snapshot.value.footnote,
(f, old) => {
if (f && f !== old) {
footnoteEntering.value = true;
setTimeout(() => {
footnoteEntering.value = false;
}, 260);
}
}
);
</script>

View File

@ -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 { .qd-window__header {
height: 38px; height: 38px;
display: flex; display: flex;
@ -1086,3 +1102,323 @@
stroke-width: 1.8; stroke-width: 1.8;
stroke-linecap: round; 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;
}

View File

@ -652,6 +652,7 @@ import StatusAvatar from '@/components/avatar/StatusAvatar.vue';
import { useInputStore } from '@/stores/input'; import { useInputStore } from '@/stores/input';
import { usePersonalizationStore } from '@/stores/personalization'; import { usePersonalizationStore } from '@/stores/personalization';
import { useUiStore } from '@/stores/ui'; import { useUiStore } from '@/stores/ui';
import { useWorkflowStore } from '@/stores/workflow';
defineOptions({ name: 'InputComposer' }); defineOptions({ name: 'InputComposer' });
@ -696,6 +697,7 @@ const emit = defineEmits([
'restore-user-question', 'restore-user-question',
'toggle-goal-mode', 'toggle-goal-mode',
'open-goal-dialog', 'open-goal-dialog',
'workflow-activated',
'open-git-changes-panel', 'open-git-changes-panel',
'toggle-agent-type-menu', 'toggle-agent-type-menu',
'select-new-conversation-type', 'select-new-conversation-type',
@ -831,6 +833,7 @@ type SkillItem = { name: string; description: string; path: string };
type SlashMenuMode = type SlashMenuMode =
| 'root' | 'root'
| 'skills' | 'skills'
| 'workflows'
| 'theme' | 'theme'
| 'permission' | 'permission'
| 'execution' | 'execution'
@ -849,6 +852,7 @@ type SlashMenuItem = {
mode?: SlashMenuMode; mode?: SlashMenuMode;
skill?: SkillItem; skill?: SkillItem;
}; };
type WorkflowItem = { name: string; description: string; source: string; updatedAt: string; nodeCount: number };
const availableSkills = ref<SkillItem[]>([]); const availableSkills = ref<SkillItem[]>([]);
const selectedSkillRefs = ref<SkillItem[]>([]); const selectedSkillRefs = ref<SkillItem[]>([]);
const skillsLoaded = ref(false); const skillsLoaded = ref(false);
@ -867,6 +871,11 @@ const slashManualScrolled = ref(false);
const slashSkillsEntrySource = ref<'typed' | 'menu' | null>(null); const slashSkillsEntrySource = ref<'typed' | 'menu' | null>(null);
/** Escape 从 // 直达的 skills 退回 root 后,抑制本次 token 存续期间的自动重进 */ /** Escape 从 // 直达的 skills 退回 root 后,抑制本次 token 存续期间的自动重进 */
const slashDoubleSlashSuppressed = ref(false); const slashDoubleSlashSuppressed = ref(false);
const workflowStore = useWorkflowStore();
const availableWorkflows = ref<WorkflowItem[]>([]);
const workflowsLoaded = ref(false);
const workflowsLoading = ref(false);
const workflowActionPending = ref(false);
let slashAnimId: number | null = null; let slashAnimId: number | null = null;
/** 动画真正进行中(比 animId 更可靠,可覆盖 rAF 结束后的延迟 scroll 事件) */ /** 动画真正进行中(比 animId 更可靠,可覆盖 rAF 结束后的延迟 scroll 事件) */
let slashAnimating = false; let slashAnimating = false;
@ -1509,6 +1518,15 @@ const rootSlashMenuItems = computed<SlashMenuItem[]>(() => {
description: '插入一个 AgentSkill 引用(// 快捷直达)', description: '插入一个 AgentSkill 引用(// 快捷直达)',
mode: 'skills' mode: 'skills'
}, },
{
id: 'workflows',
label: '工作流',
description: workflowStore.isActive
? `进行中:${workflowStore.snapshot.name} · 查看或退出`
: '激活一个工作流,让智能体按流程推进',
disabled: !props.isConnected,
mode: 'workflows'
},
{ {
id: 'conversation-type', id: 'conversation-type',
label: '对话类型', label: '对话类型',
@ -1819,6 +1837,9 @@ const activeSlashMenuItems = computed<SlashMenuItem[]>(() => {
if (slashMenuMode.value === 'skills') { if (slashMenuMode.value === 'skills') {
return skillSlashMenuItems.value; return skillSlashMenuItems.value;
} }
if (slashMenuMode.value === 'workflows') {
return workflowSlashMenuItems.value;
}
if (slashMenuMode.value === 'theme') { if (slashMenuMode.value === 'theme') {
return themeSlashMenuItems.value; return themeSlashMenuItems.value;
} }
@ -1851,6 +1872,7 @@ const activeSlashMenuItems = computed<SlashMenuItem[]>(() => {
const slashMenuAriaLabel = computed(() => { const slashMenuAriaLabel = computed(() => {
if (slashMenuMode.value === 'skills') return '可用 AgentSkills'; if (slashMenuMode.value === 'skills') return '可用 AgentSkills';
if (slashMenuMode.value === 'workflows') return '工作流选项';
if (slashMenuMode.value === 'theme') return '主题选项'; if (slashMenuMode.value === 'theme') return '主题选项';
if (slashMenuMode.value === 'permission') return '权限模式选项'; if (slashMenuMode.value === 'permission') return '权限模式选项';
if (slashMenuMode.value === 'execution') return '执行环境选项'; if (slashMenuMode.value === 'execution') return '执行环境选项';
@ -1867,6 +1889,8 @@ const slashMenuAriaLabel = computed(() => {
const slashMenuEmptyText = computed(() => { const slashMenuEmptyText = computed(() => {
if (slashMenuMode.value === 'skills') if (slashMenuMode.value === 'skills')
return skillsLoading.value ? '正在加载 skills...' : '没有匹配的 skill'; return skillsLoading.value ? '正在加载 skills...' : '没有匹配的 skill';
if (slashMenuMode.value === 'workflows')
return workflowsLoading.value ? '正在加载工作流...' : '暂无可用工作流(可去 /workflows 创建)';
if (slashMenuMode.value === 'permission') return '无可用权限模式'; if (slashMenuMode.value === 'permission') return '无可用权限模式';
if (slashMenuMode.value === 'execution') return '无可用执行环境'; if (slashMenuMode.value === 'execution') return '无可用执行环境';
if (slashMenuMode.value === 'model') 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<string, any> = { 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 || '激活工作流失败');
}
// storesocketio 广
//
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<SlashMenuItem[]>(() => {
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 refreshSkillSlashState = () => {
const token = findSlashToken(); const token = findSlashToken();
if (!token || !props.isConnected || props.inputLocked) { if (!token || !props.isConnected || props.inputLocked) {
@ -2001,6 +2160,9 @@ const selectSlashMenuItem = (index = skillSlashActiveIndex.value) => {
if (item.mode === 'skills') { if (item.mode === 'skills') {
void loadSkills(); void loadSkills();
} }
if (item.mode === 'workflows') {
void loadWorkflows();
}
if (item.mode === 'workspace') { if (item.mode === 'workspace') {
emit('fetch-host-workspaces'); emit('fetch-host-workspaces');
} }

View File

@ -1882,6 +1882,156 @@
</transition> </transition>
</section> </section>
<section
v-else-if="activeTab === 'review-agents'"
key="review-agents"
class="settings-page"
>
<div class="settings-section-desc" style="margin: 0 0 16px; color: var(--text-secondary); font-size: 13px; line-height: 1.6">
统一配置三个审核智能体的模型与运行参数模型与子智能体共用模型库留空则使用模型库默认模型
</div>
<template v-for="agent in reviewAgentDefs" :key="agent.key">
<div class="settings-section-divider">
<span class="settings-section-divider__label">{{ agent.name }}</span>
</div>
<div style="margin: -4px 0 12px; color: var(--text-secondary); font-size: 12px; line-height: 1.5">
{{ agent.desc }}
</div>
<div class="settings-select-row">
<span class="settings-row-copy">
<span class="settings-row-title">模型</span>
<span class="settings-row-desc">留空则使用模型库默认模型</span>
</span>
<div
class="settings-select-wrap"
:class="{ open: activeDropdown === `review-model-${agent.key}` }"
@click.stop
>
<button
type="button"
class="settings-select-button"
@click="toggleDropdown(`review-model-${agent.key}`)"
>
{{ reviewAgentOf(agent.key).model || '默认模型' }}
<span class="select-chevron" aria-hidden="true"></span>
</button>
<div
:class="['settings-floating-menu', { dark: activeTheme === 'dark' }]"
:style="activeDropdown === `review-model-${agent.key}` ? floatingMenuStyle : undefined"
>
<button
type="button"
class="settings-menu-option"
:class="{ selected: !reviewAgentOf(agent.key).model }"
@click="updateReviewAgent(agent.key, { model: '' }); closeDropdown()"
>
<strong>默认模型</strong><span>使用模型库的 default_model</span><svg viewBox="0 0 24 24"><path d="M5 12.5 9.5 17 19 7" /></svg>
</button>
<button
v-for="m in subAgentModels"
:key="m.name"
type="button"
class="settings-menu-option"
:class="{ selected: reviewAgentOf(agent.key).model === m.name }"
@click="updateReviewAgent(agent.key, { model: m.name }); closeDropdown()"
>
<strong>{{ m.name }}</strong><span>{{ m.modes }} · {{ m.multimodal || '纯文本' }}</span><svg viewBox="0 0 24 24"><path d="M5 12.5 9.5 17 19 7" /></svg>
</button>
</div>
</div>
</div>
<div class="settings-select-row">
<span class="settings-row-copy">
<span class="settings-row-title">思考模式</span>
<span class="settings-row-desc">模型不支持思考时自动回落快速模式</span>
</span>
<div
class="settings-select-wrap"
:class="{ open: activeDropdown === `review-thinking-${agent.key}` }"
@click.stop
>
<button
type="button"
class="settings-select-button"
@click="toggleDropdown(`review-thinking-${agent.key}`)"
>
{{ reviewAgentOf(agent.key).thinking ? 'thinking' : 'fast' }}
<span class="select-chevron" aria-hidden="true"></span>
</button>
<div
:class="['settings-floating-menu', { dark: activeTheme === 'dark' }]"
:style="activeDropdown === `review-thinking-${agent.key}` ? floatingMenuStyle : undefined"
>
<button
type="button"
class="settings-menu-option"
:class="{ selected: !reviewAgentOf(agent.key).thinking }"
@click="updateReviewAgent(agent.key, { thinking: false }); closeDropdown()"
>
<strong>fast</strong><span>快速响应模式</span><svg viewBox="0 0 24 24"><path d="M5 12.5 9.5 17 19 7" /></svg>
</button>
<button
type="button"
class="settings-menu-option"
:class="{ selected: reviewAgentOf(agent.key).thinking }"
@click="updateReviewAgent(agent.key, { thinking: true }); closeDropdown()"
>
<strong>thinking</strong><span>思考推理模式</span><svg viewBox="0 0 24 24"><path d="M5 12.5 9.5 17 19 7" /></svg>
</button>
</div>
</div>
</div>
<div class="settings-select-row">
<span class="settings-row-copy">
<span class="settings-row-title">审核请求超时</span>
<span class="settings-row-desc">单次模型请求超时5-3600 </span>
</span>
<input
type="number"
class="settings-number-input"
min="5"
max="3600"
:value="reviewAgentOf(agent.key).timeout_seconds"
@change="updateReviewAgentInt(agent.key, 'timeout_seconds', ($event.target as HTMLInputElement).value, 5, 3600)"
/>
</div>
<div class="settings-select-row">
<span class="settings-row-copy">
<span class="settings-row-title">最大审核轮次</span>
<span class="settings-row-desc">超过该轮次未产出结论时按兜底处理1-50 </span>
</span>
<input
type="number"
class="settings-number-input"
min="1"
max="50"
:value="reviewAgentOf(agent.key).max_rounds"
@change="updateReviewAgentInt(agent.key, 'max_rounds', ($event.target as HTMLInputElement).value, 1, 50)"
/>
</div>
<div class="settings-select-row" style="margin-bottom: 8px">
<span class="settings-row-copy">
<span class="settings-row-title">取证命令超时</span>
<span class="settings-row-desc">只读取证命令的单次超时1-600 </span>
</span>
<input
type="number"
class="settings-number-input"
min="1"
max="600"
:value="reviewAgentOf(agent.key).max_command_timeout"
@change="updateReviewAgentInt(agent.key, 'max_command_timeout', ($event.target as HTMLInputElement).value, 1, 600)"
/>
</div>
</template>
</section>
<section <section
key="admin" key="admin"
class="settings-page admin-monitor-page" class="settings-page admin-monitor-page"
@ -1966,6 +2116,7 @@ import { ref, computed, watch, onMounted, nextTick, onBeforeUnmount } from 'vue'
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import FancyCheck from '@/components/common/FancyCheck.vue'; import FancyCheck from '@/components/common/FancyCheck.vue';
import { usePersonalizationStore } from '@/stores/personalization'; import { usePersonalizationStore } from '@/stores/personalization';
import type { ReviewAgentKey, ReviewAgentSetting } from '@/stores/personalization';
import { useResourceStore } from '@/stores/resource'; import { useResourceStore } from '@/stores/resource';
import { useUiStore } from '@/stores/ui'; import { useUiStore } from '@/stores/ui';
import { usePolicyStore } from '@/stores/policy'; import { usePolicyStore } from '@/stores/policy';
@ -2036,6 +2187,7 @@ type PersonalTab =
| 'data' | 'data'
| 'voice' | 'voice'
| 'sub-agents' | 'sub-agents'
| 'review-agents'
| 'admin'; | 'admin';
const baseTabs = [ const baseTabs = [
@ -2049,7 +2201,8 @@ const baseTabs = [
{ id: 'files', label: '文件与图片', icon: 'file' }, { id: 'files', label: '文件与图片', icon: 'file' },
{ id: 'data', label: '数据管理', icon: 'layers' }, { id: 'data', label: '数据管理', icon: 'layers' },
{ id: 'voice', label: '语音模型', icon: 'mic' }, { id: 'voice', label: '语音模型', icon: 'mic' },
{ id: 'sub-agents', label: '子智能体', icon: 'bot' } { id: 'sub-agents', label: '子智能体', icon: 'bot' },
{ id: 'review-agents', label: '审核智能体', icon: 'checkbox' }
] as const satisfies ReadonlyArray<{ id: PersonalTab; label: string; icon: IconKey }>; ] as const satisfies ReadonlyArray<{ id: PersonalTab; label: string; icon: IconKey }>;
const sessionRole = ref(''); const sessionRole = ref('');
@ -2637,6 +2790,9 @@ watch(
loadSubAgentSettings(); loadSubAgentSettings();
loadSubAgentModels(); loadSubAgentModels();
} }
if (isVisible && tab === 'review-agents') {
loadSubAgentModels();
}
if ( if (
isVisible && isVisible &&
tab === 'general' && 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<ReviewAgentSetting>) => {
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 () => { const loadSubAgentSettings = async () => {
try { try {
const resp = await fetch('/api/multiagent/settings', { credentials: 'same-origin' }); const resp = await fetch('/api/multiagent/settings', { credentials: 'same-origin' });

View File

@ -11,6 +11,71 @@ type CommunicationStyle = 'default' | 'human_like' | 'auto';
type ConversationContinuity = 'low' | 'medium' | 'high'; type ConversationContinuity = 'low' | 'medium' | 'high';
type VersioningBackupMode = 'shallow' | 'full'; type VersioningBackupMode = 'shallow' | 'full';
/** 与后端 DEFAULT_PERSONALIZATION_CONFIG['review_agents'] 保持一致 */
const defaultReviewAgents = (): Record<ReviewAgentKey, ReviewAgentSetting> => ({
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<ReviewAgentKey, ReviewAgentSetting> => {
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<ReviewAgentKey, ReviewAgentSetting>;
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 { interface PersonalForm {
enabled: boolean; enabled: boolean;
communication_style: CommunicationStyle; communication_style: CommunicationStyle;
@ -76,6 +141,7 @@ interface PersonalForm {
goal_end_conditions: string[]; goal_end_conditions: string[];
goal_max_turns: number; goal_max_turns: number;
goal_max_tokens: number | null; goal_max_tokens: number | null;
review_agents: Record<ReviewAgentKey, ReviewAgentSetting>;
} }
interface ExperimentState { interface ExperimentState {
@ -278,7 +344,8 @@ const defaultForm = (): PersonalForm => ({
goal_review_mode: 'readonly', goal_review_mode: 'readonly',
goal_end_conditions: ['max_turns'], goal_end_conditions: ['max_turns'],
goal_max_turns: 5, goal_max_turns: 5,
goal_max_tokens: null goal_max_tokens: null,
review_agents: defaultReviewAgents()
}); });
const defaultExperimentState = (): ExperimentState => ({ const defaultExperimentState = (): ExperimentState => ({
@ -553,7 +620,8 @@ export const usePersonalizationStore = defineStore('personalization', {
goal_max_tokens: goal_max_tokens:
typeof data.goal_max_tokens === 'number' && data.goal_max_tokens > 0 typeof data.goal_max_tokens === 'number' && data.goal_max_tokens > 0
? data.goal_max_tokens ? data.goal_max_tokens
: null : null,
review_agents: sanitizeReviewAgents(data.review_agents)
}; };
// 如果theme发生变化应用到界面 // 如果theme发生变化应用到界面
const currentTheme = const currentTheme =

View File

@ -2,10 +2,11 @@ import { defineStore } from 'pinia';
import { useFileStore } from './file'; import { useFileStore } from './file';
import { useSubAgentStore } from './subAgent'; import { useSubAgentStore } from './subAgent';
import { useBackgroundCommandStore } from './backgroundCommand'; import { useBackgroundCommandStore } from './backgroundCommand';
import { useWorkflowStore } from './workflow';
/** /**
* Quick Dock * Quick Dock
* / / / * / / / /
* store * store
*/ */
@ -133,6 +134,21 @@ interface QuickDockState {
filesSyncSeq: number; 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', { export const useQuickDockStore = defineStore('quickDock', {
state: (): QuickDockState => ({ state: (): QuickDockState => ({
editedFiles: [], editedFiles: [],
@ -141,25 +157,29 @@ export const useQuickDockStore = defineStore('quickDock', {
previewPath: null, previewPath: null,
menu: null, menu: null,
userCollapsed: false, userCollapsed: false,
assumedContent: loadCachedHadContent(), assumedContent: loadInitialAssumedContent(),
assumedActive: loadCachedHadContent(), assumedActive: loadInitialAssumedContent(),
filesSyncSeq: 0 filesSyncSeq: 0
}), }),
getters: { getters: {
/** 四个窗口(待办/子智能体/后台指令/文件记录)任一有内容 */ /** 五个窗口(工作流/待办/子智能体/后台指令/文件记录)任一有内容 */
hasContent(state): boolean { hasContent(state): boolean {
const fileStore = useFileStore(); const fileStore = useFileStore();
const subAgentStore = useSubAgentStore(); const subAgentStore = useSubAgentStore();
const bgStore = useBackgroundCommandStore(); const bgStore = useBackgroundCommandStore();
const workflowStore = useWorkflowStore();
const todoCount = fileStore.todoList?.tasks?.length || 0; const todoCount = fileStore.todoList?.tasks?.length || 0;
const real = // 工作流是「实时状态」(激活/完成/停用即刻变化),不是回填内容,不参与乐观掩码——
// 否则切换对话时 assumedContent=false 会把刚激活的工作流掩盖掉(/new 激活闪烁根因)。
// 切对话时旧工作流残留走「保留至回填覆盖」策略,与其余窗口一致。
const restReal =
todoCount > 0 || todoCount > 0 ||
subAgentStore.subAgents.length > 0 || subAgentStore.subAgents.length > 0 ||
bgStore.commands.length > 0 || bgStore.commands.length > 0 ||
state.editedFiles.length > 0; state.editedFiles.length > 0;
// 乐观掩码生效期间(初始加载/切换对话的内容未到齐窗口)以假定状态为准; // 乐观掩码生效期间(初始加载/切换对话的内容未到齐窗口)以假定状态为准;
// 掩码关闭后纯真实状态 // 掩码关闭后纯真实状态
return state.assumedActive ? state.assumedContent : real; return workflowStore.isActive || (state.assumedActive ? state.assumedContent : restReal);
}, },
/** 实际处于展开态:有内容且未被用户手动收起 */ /** 实际处于展开态:有内容且未被用户手动收起 */
expanded(state): boolean { expanded(state): boolean {

View File

@ -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<WorkflowSnapshot> | 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();
}
}
}
});