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:
parent
d071292c81
commit
4d9b709a9e
10
AGENTS.md
10
AGENTS.md
@ -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` 为主,便于对齐主/子智能体会话格式
|
||||||
|
|||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -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()
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)):
|
||||||
|
|||||||
118
core/main_terminal_parts/tools_definition/workflow_tools.py
Normal file
118
core/main_terminal_parts/tools_definition/workflow_tools.py
Normal 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"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
@ -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")
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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` 为 async(tools_execution.py L960),`submit_plan` 已证明工具可长阻塞等待外部事件——审核在工具内同步 await 成立。
|
||||||
|
|
||||||
|
### 4.4 审核智能体
|
||||||
|
|
||||||
|
`modules/workflow_review_agent.py`(fork `goal_review_agent.py`),内部工具 `report_workflow_review(decision: pass|reject, message)`(仿 `report_goal_status`:唯一结论出口 + 强制工具调用重试)。配置统一走个人空间「审核智能体」页(`modules/review_agent_config.py::resolve_review_agent_config`,模型复用子智能体模型库);`review_mode: active` 时注入只读 run_command 取证。
|
||||||
|
|
||||||
|
**payload 构建**(核心:让审核看证据而非听汇报):
|
||||||
|
|
||||||
|
```
|
||||||
|
【工作流】{name}:{description}
|
||||||
|
【本次审核把关】{review 节点名}
|
||||||
|
审核关注点:{review.prompt}
|
||||||
|
|
||||||
|
【被审核阶段】{stage 名}
|
||||||
|
阶段目标:{stage.goal}
|
||||||
|
阶段要求:{stage.instructions}
|
||||||
|
|
||||||
|
【阶段执行痕迹】(消息游标 stage_start_msg_index 截取的对话增量,截断控长)
|
||||||
|
1. run_command: git diff --stat → …
|
||||||
|
2. read_file: server/chat.py L120-180 → …
|
||||||
|
|
||||||
|
【主智能体阶段汇报】{summary}
|
||||||
|
|
||||||
|
【历史审核意见】(若是重审,带上前几次驳回意见)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.5 审核结果与边界
|
||||||
|
|
||||||
|
- **pass** → 推进到 review.next(若为 branch 多出线 → 停在 branch 等选择,返回分支菜单)。
|
||||||
|
- **reject** → reject_counts[node]+1 → 回到 review.rejectTo(stage 或 branch),工具返回带审核意见。
|
||||||
|
- **审核异常/超时** → 视为驳回(计入计数),返回中含「审核服务可能异常,**请告知用户**」。
|
||||||
|
- **撞 maxRejects** → status=failed(exit_reason=max_rejects),工具返回「连续驳回超限,工作流已终止,请告知用户」。模型在场同轮闭环,不另发 user 消息。
|
||||||
|
|
||||||
|
### 4.6 max_stage_rounds 撞限(柔性询问)
|
||||||
|
|
||||||
|
- `stage_rounds` 跨任务累计:主循环**每轮迭代** +1(挂点在主循环层,不在工具循环内——遵守注入时序铁律),进入新阶段清零。
|
||||||
|
- 撞限 → 注入 inline user 消息(`inject_runtime_user_message(inline=True)`,下轮模型即看到):
|
||||||
|
「工作流阶段「X」已进行 N 轮,达到上限。请立刻停下当前工作,告知用户已超过 N 轮,并询问是否还要继续。」
|
||||||
|
- **工作流不停**:status 保持 active,原地挂起等用户发话。
|
||||||
|
- 防重复:撞限后 `round_limit_notified=true`。
|
||||||
|
- 清零时机:下一条真实用户消息到达、新任务开始时清零(视作用户已知情交互);之后若再跑 X 轮会再次询问——每超 X 轮问一次。
|
||||||
|
|
||||||
|
### 4.7 柔性退出与通知池
|
||||||
|
|
||||||
|
**模型自主 deactivate / completed**:工具返回同轮闭环,不需要 user 消息;前端 UI 走进度事件。
|
||||||
|
|
||||||
|
**用户 slash deactivate**(REST,随时可用,不做空闲检查——摘牌不打断正在跑的任务):
|
||||||
|
|
||||||
|
- 智能体**运行中** → 事件入 `pending_notices`;在 `execute_tool_calls` 末尾统一消费点注入 inline 通知(与 `process_sub_agent_updates` 同位置;遵守「循环内收集 → 循环后注入」铁律),模型下轮看到。
|
||||||
|
- 智能体**空闲** → REST 直接派发:预占主任务门闸 → 完整 user 消息(持久化+广播+metadata 齐全)→ 创建主任务。与 slash activate 共用派发函数。
|
||||||
|
- **兜底**:运行中模型在通知被消费前就停了 → 任务结尾轮询器 spawn 条件兜住(见下)。
|
||||||
|
|
||||||
|
**通知池 = 统一完成通知轮询器的第三路**(`poll_completion_notifications`,2026-06 实施、2026-08 门闸化),不新造轮子。改动点(均在 `server/chat_flow_task_main.py`):
|
||||||
|
|
||||||
|
1. `_collect_pending_completion_notices`:增加 workflow 一路(与子智能体、后台命令按时间混排)。
|
||||||
|
2. `needs_completion_poll`(L2478 附近):扩展条件 `or _has_pending_workflow_notices(...)`——否则无后台任务时工作流通知产生后轮询器不 spawn。
|
||||||
|
3. 轮询器主体零改动(门闸预占、批量预写+末条触发、新任务续轮询全复用)。
|
||||||
|
|
||||||
|
通知消息 metadata 新增 `message_source: "workflow"`(未识别时回落 "user")。
|
||||||
|
|
||||||
|
**摘牌后的工具行为**:stopped/failed 后模型再调 `report_workflow_stage` / `choose_workflow_branch` → 返回 `success:false` + 「工作流已退出(原因),如需重新开始请重新激活」——返回错误但不炸任务。
|
||||||
|
|
||||||
|
### 4.8 与 goal 模式叠加
|
||||||
|
|
||||||
|
不互斥。工作流活跃时主循环尾部 no-tool-calls 分支不做任何工作流拦截(兜底提醒已取消);goal 审核照常。
|
||||||
|
|
||||||
|
## 5. 工具清单
|
||||||
|
|
||||||
|
### 主智能体(5 个)
|
||||||
|
|
||||||
|
| 工具 | 参数 | 行为/返回 |
|
||||||
|
|---|---|---|
|
||||||
|
| `activate_workflow` | `name` | 加载+快照+初始化;返回全景目录+入口阶段详情。同工作流幂等;不同工作流拒绝 |
|
||||||
|
| `report_workflow_stage` | `summary` | 阶段汇报核心状态机,返回按 §4.3 矩阵分派 |
|
||||||
|
| `choose_workflow_branch` | `target_node_id` | 分支选择;校验候选集;返回新阶段详情 |
|
||||||
|
| `get_workflow_status` | — | 返回当前节点/已走路径/各阶段轮数/审核历史(迷失自查+用户问进度) |
|
||||||
|
| `deactivate_workflow` | `reason` | 柔性退出:摘牌+工具返回确认(模型自主退出无需 user 消息) |
|
||||||
|
|
||||||
|
### 审核智能体内部(1 个)
|
||||||
|
|
||||||
|
| 工具 | 参数 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `report_workflow_review` | `decision: pass\|reject`, `message` | 唯一结论出口,仿 report_goal_status |
|
||||||
|
|
||||||
|
### REST(3 个)
|
||||||
|
|
||||||
|
- `POST /api/workflow/activate`:仅智能体空闲(409 检查);快照+初始化+派发完整 user 消息任务
|
||||||
|
- `POST /api/workflow/deactivate`:随时可用;忙入通知池、闲直发任务
|
||||||
|
- `GET /api/workflow/status?conversation_id=`:轮询/刷新恢复
|
||||||
|
|
||||||
## 6. 通信与前端
|
## 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 3,React 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 导致主任务异常终止;柔性优先。
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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 == "":
|
||||||
|
|||||||
107
modules/review_agent_config.py
Normal file
107
modules/review_agent_config.py
Normal 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
|
||||||
348
modules/workflow_review_agent.py
Normal file
348
modules/workflow_review_agent.py
Normal 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"}。
|
||||||
|
兜底(异常/超时/未产出结论/配置缺失)一律返回 reject,message 说明审核服务异常
|
||||||
|
并提示主智能体「请告知用户」——定稿决策:审核异常视为驳回(计入 reject_counts),
|
||||||
|
用户看到消息后能发现是审核服务问题而非真实驳回。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Callable, Dict, List, Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from config import PROMPTS_DIR, LOGS_DIR
|
||||||
|
from modules.goal_state_manager import REVIEW_MODE_ACTIVE, REVIEW_MODE_READONLY
|
||||||
|
from modules.review_agent_config import resolve_review_agent_config
|
||||||
|
|
||||||
|
PROMPT_NAME = "workflow_review_agent.txt"
|
||||||
|
DEFAULT_TIMEOUT_SECONDS = 120
|
||||||
|
DEFAULT_MAX_ROUNDS = 6
|
||||||
|
DEFAULT_MAX_COMMAND_TIMEOUT = 60
|
||||||
|
DEBUG_SAVE_WORKFLOW_REVIEW_TRANSCRIPT = True
|
||||||
|
DEBUG_TRANSCRIPT_DIR = Path(LOGS_DIR) / "workflow_review_agent"
|
||||||
|
|
||||||
|
# 兜底驳回消息(审核智能体未能产出明确结论时使用)
|
||||||
|
FALLBACK_REJECT_MESSAGE = (
|
||||||
|
"本次审核未能正常完成(审核服务异常或未产出结论),按驳回处理。"
|
||||||
|
"请告知用户审核服务可能异常;若属偶发,可稍后重新汇报本阶段。"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_workflow_review_agent_config() -> Dict[str, Any]:
|
||||||
|
"""加载工作流审核智能体配置:统一走个人空间「审核智能体」设置 + 子智能体模型库。"""
|
||||||
|
cfg = resolve_review_agent_config("workflow_review")
|
||||||
|
cfg["name"] = "workflow-review-agent"
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowReviewAgent:
|
||||||
|
def __init__(self, *, web_terminal: Any):
|
||||||
|
self.web_terminal = web_terminal
|
||||||
|
self.cfg = load_workflow_review_agent_config()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _system_prompt(review_mode: str) -> str:
|
||||||
|
try:
|
||||||
|
template = (Path(PROMPTS_DIR) / PROMPT_NAME).read_text(encoding="utf-8")
|
||||||
|
except Exception:
|
||||||
|
template = "你是工作流阶段审核智能体。你必须调用 report_workflow_review 返回 pass 或 reject。"
|
||||||
|
|
||||||
|
active_start = "{{ACTIVE_REVIEW_ONLY}}"
|
||||||
|
active_end = "{{/ACTIVE_REVIEW_ONLY}}"
|
||||||
|
if review_mode == REVIEW_MODE_ACTIVE:
|
||||||
|
return template.replace(active_start, "").replace(active_end, "").strip()
|
||||||
|
|
||||||
|
while active_start in template and active_end in template:
|
||||||
|
start = template.find(active_start)
|
||||||
|
end = template.find(active_end, start)
|
||||||
|
if start < 0 or end < 0:
|
||||||
|
break
|
||||||
|
template = template[:start] + template[end + len(active_end):]
|
||||||
|
return template.strip()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _report_tool() -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "report_workflow_review",
|
||||||
|
"description": (
|
||||||
|
"提交工作流阶段审核结论并结束本次审核。pass=阶段产出合格,进入下一阶段;"
|
||||||
|
"reject=阶段产出不合格,打回整改。"
|
||||||
|
),
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"decision": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["pass", "reject"],
|
||||||
|
"description": "pass=通过;reject=驳回。",
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"type": "string",
|
||||||
|
"description": (
|
||||||
|
"decision=pass 时简述通过理由(给主智能体与用户看);"
|
||||||
|
"decision=reject 时写给主执行模型的整改意见,需具体可执行,"
|
||||||
|
"指出缺什么、怎么补、重新审核时要看到什么证据。"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["decision", "message"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _run_command_tool() -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "run_command",
|
||||||
|
"description": (
|
||||||
|
"在终端中执行只读指令。可用于查看文件内容、搜索代码/文本、检查目录结构、"
|
||||||
|
"查看 git 状态与差异、运行测试以核实阶段产出是否合格。"
|
||||||
|
"禁止用于写入、删除、改权限或其他修改性操作。"
|
||||||
|
),
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"command": {
|
||||||
|
"type": "string",
|
||||||
|
"description": (
|
||||||
|
"要执行的终端命令字符串。应优先使用只读命令,例如 ls、cat、grep、find、"
|
||||||
|
"rg、git status、git diff、pwd、stat、以及运行测试的命令等。"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["command"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def _build_tools(self, review_mode: str) -> List[Dict[str, Any]]:
|
||||||
|
if review_mode == REVIEW_MODE_ACTIVE:
|
||||||
|
return [self._run_command_tool(), self._report_tool()]
|
||||||
|
return [self._report_tool()]
|
||||||
|
|
||||||
|
async def _run_readonly_command(self, command: str) -> Dict[str, Any]:
|
||||||
|
work_path = Path(getattr(self.web_terminal.context_manager, "project_path", "."))
|
||||||
|
timeout = int(self.cfg.get("max_command_timeout") or DEFAULT_MAX_COMMAND_TIMEOUT)
|
||||||
|
try:
|
||||||
|
result = await self.web_terminal.terminal_ops.run_command(
|
||||||
|
command=command,
|
||||||
|
working_dir=str(work_path),
|
||||||
|
timeout=timeout,
|
||||||
|
sandbox_write_access=False,
|
||||||
|
)
|
||||||
|
return result if isinstance(result, dict) else {"success": False, "error": "run_command 返回格式异常"}
|
||||||
|
except Exception as exc:
|
||||||
|
return {"success": False, "error": str(exc)}
|
||||||
|
|
||||||
|
async def review(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
payload_text: str,
|
||||||
|
review_mode: str = REVIEW_MODE_READONLY,
|
||||||
|
progress_cb: Optional[Callable[[Dict[str, Any]], None]] = None,
|
||||||
|
cancel_check: Optional[Callable[[], Optional[Dict[str, Any]]]] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
review_mode = review_mode if review_mode in (REVIEW_MODE_READONLY, REVIEW_MODE_ACTIVE) else REVIEW_MODE_READONLY
|
||||||
|
debug_transcript: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
def _trace(event: str, payload: Dict[str, Any]) -> None:
|
||||||
|
if not DEBUG_SAVE_WORKFLOW_REVIEW_TRANSCRIPT:
|
||||||
|
return
|
||||||
|
debug_transcript.append({"ts": int(time.time() * 1000), "event": event, "payload": payload})
|
||||||
|
|
||||||
|
def _flush_trace(final_result: Dict[str, Any]) -> None:
|
||||||
|
if not DEBUG_SAVE_WORKFLOW_REVIEW_TRANSCRIPT:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
DEBUG_TRANSCRIPT_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
row = {
|
||||||
|
"created_at": int(time.time() * 1000),
|
||||||
|
"review_mode": review_mode,
|
||||||
|
"messages": messages,
|
||||||
|
"trace": debug_transcript,
|
||||||
|
"final_result": final_result,
|
||||||
|
}
|
||||||
|
file = DEBUG_TRANSCRIPT_DIR / f"workflow_review_{int(time.time() * 1000)}.json"
|
||||||
|
file.write_text(json.dumps(row, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _reject(message: str) -> Dict[str, Any]:
|
||||||
|
return {"decision": "reject", "message": message or FALLBACK_REJECT_MESSAGE, "source": "workflow_review_agent"}
|
||||||
|
|
||||||
|
url = str(self.cfg.get("url") or "").strip()
|
||||||
|
key = str(self.cfg.get("key") or "").strip()
|
||||||
|
model = str(self.cfg.get("model") or "").strip()
|
||||||
|
if not url or not key or not model:
|
||||||
|
out = _reject(f"工作流审核智能体配置缺失,无法完成审核。{FALLBACK_REJECT_MESSAGE}")
|
||||||
|
_flush_trace(out)
|
||||||
|
return out
|
||||||
|
endpoint = f"{url.rstrip('/')}/chat/completions"
|
||||||
|
headers = {"Authorization": f"Bearer {key}", "Content-Type": "application/json"}
|
||||||
|
timeout_seconds = int(self.cfg.get("timeout_seconds") or DEFAULT_TIMEOUT_SECONDS)
|
||||||
|
max_rounds = max(1, int(self.cfg.get("max_rounds") or DEFAULT_MAX_ROUNDS))
|
||||||
|
extra_params = dict(self.cfg.get("extra_params") or {})
|
||||||
|
tools = self._build_tools(review_mode)
|
||||||
|
|
||||||
|
messages: List[Dict[str, Any]] = [
|
||||||
|
{"role": "system", "content": self._system_prompt(review_mode)},
|
||||||
|
{"role": "user", "content": payload_text},
|
||||||
|
]
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=timeout_seconds) as client:
|
||||||
|
rounds = 0
|
||||||
|
forced_tool_retry_used = False
|
||||||
|
while True:
|
||||||
|
rounds += 1
|
||||||
|
if cancel_check:
|
||||||
|
external = cancel_check()
|
||||||
|
if external:
|
||||||
|
return external
|
||||||
|
if rounds > max_rounds:
|
||||||
|
out = _reject(f"审核超过最大轮次仍未产出结论。{FALLBACK_REJECT_MESSAGE}")
|
||||||
|
_flush_trace(out)
|
||||||
|
return out
|
||||||
|
if progress_cb:
|
||||||
|
progress_cb({"stage": "model_call", "round": rounds, "message": f"审核轮次 {rounds}"})
|
||||||
|
req = {
|
||||||
|
"model": model,
|
||||||
|
"messages": messages,
|
||||||
|
"tools": tools,
|
||||||
|
"tool_choice": "auto",
|
||||||
|
"temperature": 0.0,
|
||||||
|
**extra_params,
|
||||||
|
}
|
||||||
|
_trace("request", {"round": rounds, "request": req})
|
||||||
|
try:
|
||||||
|
resp = await client.post(endpoint, headers=headers, json=req)
|
||||||
|
resp.raise_for_status()
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
body = ""
|
||||||
|
try:
|
||||||
|
body = exc.response.text
|
||||||
|
except Exception:
|
||||||
|
body = str(exc)
|
||||||
|
_trace("http_error", {"round": rounds, "status_code": exc.response.status_code if exc.response else None, "body": body})
|
||||||
|
# 兼容某些模型/网关不接受额外参数:自动降级重试一次(去掉 extra_params)
|
||||||
|
if extra_params:
|
||||||
|
retry_req = {
|
||||||
|
"model": model,
|
||||||
|
"messages": messages,
|
||||||
|
"tools": tools,
|
||||||
|
"tool_choice": "auto",
|
||||||
|
"temperature": 0.0,
|
||||||
|
}
|
||||||
|
_trace("retry_request_without_extra_params", {"round": rounds, "request": retry_req})
|
||||||
|
retry_resp = await client.post(endpoint, headers=headers, json=retry_req)
|
||||||
|
try:
|
||||||
|
retry_resp.raise_for_status()
|
||||||
|
resp = retry_resp
|
||||||
|
except httpx.HTTPStatusError:
|
||||||
|
out = _reject(f"审核请求失败({retry_resp.status_code})。{FALLBACK_REJECT_MESSAGE}")
|
||||||
|
_flush_trace(out)
|
||||||
|
return out
|
||||||
|
else:
|
||||||
|
out = _reject(
|
||||||
|
f"审核请求失败({exc.response.status_code if exc.response else 'unknown'})。{FALLBACK_REJECT_MESSAGE}"
|
||||||
|
)
|
||||||
|
_flush_trace(out)
|
||||||
|
return out
|
||||||
|
except Exception as exc:
|
||||||
|
_trace("request_exception", {"round": rounds, "error": str(exc)})
|
||||||
|
out = _reject(f"审核请求异常({exc})。{FALLBACK_REJECT_MESSAGE}")
|
||||||
|
_flush_trace(out)
|
||||||
|
return out
|
||||||
|
|
||||||
|
choice = ((resp.json().get("choices") or [{}])[0] or {}).get("message") or {}
|
||||||
|
reasoning_content = (
|
||||||
|
choice.get("reasoning_content") or choice.get("reasoning") or choice.get("thinking") or ""
|
||||||
|
)
|
||||||
|
_trace("response", {"round": rounds, "message": choice, "reasoning_content": reasoning_content})
|
||||||
|
tool_calls = choice.get("tool_calls") or []
|
||||||
|
content = choice.get("content")
|
||||||
|
|
||||||
|
if not tool_calls:
|
||||||
|
if content or reasoning_content:
|
||||||
|
messages.append(
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": str(content or ""),
|
||||||
|
"reasoning_content": str(reasoning_content or ""),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
reminder = (
|
||||||
|
"禁止直接输出结论文本。必须调用工具:必要时先调用 run_command 核实,"
|
||||||
|
"再调用 report_workflow_review 给出最终结论。"
|
||||||
|
)
|
||||||
|
if not forced_tool_retry_used:
|
||||||
|
forced_tool_retry_used = True
|
||||||
|
reminder = "你刚刚未通过工具给出明确结果。必须调用工具,禁止直接输出内容。请立即调用 report_workflow_review 返回最终结论。"
|
||||||
|
messages.append({"role": "user", "content": reminder})
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 有 tool_calls:追加完整 assistant 消息(与主智能体结构对齐)
|
||||||
|
assistant_message = {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": str(content or ""),
|
||||||
|
"reasoning_content": str(reasoning_content or ""),
|
||||||
|
"tool_calls": tool_calls,
|
||||||
|
}
|
||||||
|
messages.append(assistant_message)
|
||||||
|
for call in tool_calls:
|
||||||
|
fn = (call.get("function") or {}).get("name")
|
||||||
|
raw_args = (call.get("function") or {}).get("arguments") or "{}"
|
||||||
|
try:
|
||||||
|
args = json.loads(raw_args) if isinstance(raw_args, str) else (raw_args or {})
|
||||||
|
except Exception:
|
||||||
|
args = {}
|
||||||
|
if fn == "report_workflow_review":
|
||||||
|
decision = str(args.get("decision") or "").strip().lower()
|
||||||
|
message = str(args.get("message") or "").strip()
|
||||||
|
if decision == "pass":
|
||||||
|
out = {"decision": "pass", "message": message or "审核通过。", "source": "workflow_review_agent"}
|
||||||
|
_flush_trace(out)
|
||||||
|
return out
|
||||||
|
if decision == "reject":
|
||||||
|
out = _reject(message or "审核未通过,请按整改意见补充后重新汇报。")
|
||||||
|
_flush_trace(out)
|
||||||
|
return out
|
||||||
|
# 非法 decision:保守驳回
|
||||||
|
out = _reject(message or f"审核返回了无法识别的结论。{FALLBACK_REJECT_MESSAGE}")
|
||||||
|
_flush_trace(out)
|
||||||
|
return out
|
||||||
|
if fn == "run_command":
|
||||||
|
cmd = str(args.get("command") or "").strip()
|
||||||
|
if progress_cb:
|
||||||
|
progress_cb({"stage": "run_command", "round": rounds, "command": cmd})
|
||||||
|
tool_result = await self._run_readonly_command(cmd) if cmd else {"success": False, "error": "command 不能为空"}
|
||||||
|
_trace("tool_result", {"round": rounds, "tool": "run_command", "command": cmd, "result": tool_result})
|
||||||
|
tool_content = json.dumps(tool_result, ensure_ascii=False)
|
||||||
|
messages.append(
|
||||||
|
{
|
||||||
|
"role": "tool",
|
||||||
|
"content": tool_content,
|
||||||
|
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime())
|
||||||
|
+ f".{int((time.time() % 1) * 1000000):06d}",
|
||||||
|
"message_id": f"msg_{uuid.uuid4().hex}",
|
||||||
|
"tool_call_id": call.get("id"),
|
||||||
|
"name": "run_command",
|
||||||
|
}
|
||||||
|
)
|
||||||
390
modules/workflow_state_manager.py
Normal file
390
modules/workflow_state_manager.py
Normal file
@ -0,0 +1,390 @@
|
|||||||
|
"""工作流(Workflow)对话级运行状态管理。
|
||||||
|
|
||||||
|
状态目录:`{data_dir}/workflow_states/<conversation_id>/`
|
||||||
|
- ``state.json``:运行状态(current / reject_counts / stage_rounds / history / pending_notices …)
|
||||||
|
- ``WORKFLOW.md``:激活时刻的工作流定义原样快照。运行期一切读取(下一节点详情、
|
||||||
|
分支候选、审核 prompt、maxRejects)只读快照——库文件在运行期间被修改不影响本实例。
|
||||||
|
|
||||||
|
设计要点(定稿文档 docs/workflow_feature_plan.md):
|
||||||
|
- 柔性原则:工作流是智能体的辅助流程,不是宿主。所有终态(completed/stopped/failed)
|
||||||
|
都只是「摘牌」——改状态 + 停止注入 + 柔性通知,绝不掐断智能体自身的工作循环。
|
||||||
|
- review 是瞬态节点:同步审核完直接走到下一站,``current_node_id`` 只停
|
||||||
|
stage / branch / end;review 只作为 history 记录。
|
||||||
|
- 消息游标 ``stage_start_msg_index``:进入阶段时记录 conversation_history 长度,
|
||||||
|
审核 payload 据此截取本阶段的工作痕迹。
|
||||||
|
- ``pending_notices``:柔性通知池(用户退出等),由统一完成通知轮询器消费。
|
||||||
|
|
||||||
|
不依赖 web_terminal,只接受 data_dir 与 conversation_id,便于单测与复用。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from copy import deepcopy
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional, Union
|
||||||
|
|
||||||
|
from modules.workflow_manager import workflow_from_markdown
|
||||||
|
|
||||||
|
WORKFLOW_STATES_DIRNAME = "workflow_states"
|
||||||
|
WORKFLOW_SNAPSHOT_FILENAME = "WORKFLOW.md"
|
||||||
|
|
||||||
|
# 状态机
|
||||||
|
STATUS_ACTIVE = "active"
|
||||||
|
STATUS_COMPLETED = "completed"
|
||||||
|
STATUS_STOPPED = "stopped"
|
||||||
|
STATUS_FAILED = "failed"
|
||||||
|
|
||||||
|
# 退出原因
|
||||||
|
REASON_USER = "user" # 用户主动退出(slash / 对话指令)
|
||||||
|
REASON_MODEL = "model" # 模型自主退出
|
||||||
|
REASON_MAX_REJECTS = "max_rejects" # 连续驳回撞上限
|
||||||
|
REASON_COMPLETED = "completed" # 走到 end 正常完成
|
||||||
|
|
||||||
|
PathLike = Union[str, Path]
|
||||||
|
|
||||||
|
_SAFE_CONVERSATION_ID = re.compile(r"^[A-Za-z0-9_-]+$")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_conversation_id(conversation_id: str) -> str:
|
||||||
|
cid = str(conversation_id or "").strip()
|
||||||
|
if not cid or not _SAFE_CONVERSATION_ID.match(cid):
|
||||||
|
raise ValueError(f"非法 conversation_id: {conversation_id!r}")
|
||||||
|
return cid
|
||||||
|
|
||||||
|
|
||||||
|
def _empty_state() -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"active": False,
|
||||||
|
"workflow_name": "",
|
||||||
|
"status": None,
|
||||||
|
"exit_reason": None,
|
||||||
|
"current_node_id": None,
|
||||||
|
"stage_rounds": 0,
|
||||||
|
"round_limit_notified": False,
|
||||||
|
"stage_start_msg_index": 0,
|
||||||
|
"reject_counts": {},
|
||||||
|
"history": [],
|
||||||
|
"pending_notices": [],
|
||||||
|
"started_at": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowStateManager:
|
||||||
|
"""对话级工作流状态。一个实例对应一个对话的 workflow_states/<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,
|
||||||
|
}
|
||||||
25
prompts/workflow_review_agent.txt
Normal file
25
prompts/workflow_review_agent.txt
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
你是工作流阶段审核智能体,负责判断工作流当前阶段的产出是否达到进入下一阶段的门槛。
|
||||||
|
你会收到一段文本,包含:工作流名称与描述、本次审核的关注点、被审核阶段的目标与要求、该阶段的执行痕迹(主执行模型在本阶段内的工具调用时间线)、主执行模型的阶段汇报,以及此前针对本阶段的历次驳回意见(如有)。
|
||||||
|
判断必须基于可观测的证据,而非主执行模型的口头声明——它说"我做完了"不算数。
|
||||||
|
你必须调用 report_workflow_review 返回结论,严禁在普通文本里输出"通过/不通过"这类结论。
|
||||||
|
若产出合格:decision=pass,message 简述通过理由(给主执行模型与用户看,一两句即可)。
|
||||||
|
若产出不合格:decision=reject,message 写给主执行模型的整改意见,具体、可执行——指出缺什么、怎么补、重新审核时要看到什么证据。
|
||||||
|
|
||||||
|
你必须采用"挑剔的把关者"立场:谨慎、多疑,默认产出不合格,除非已有充分、可复现的证据证明阶段目标确实达成。但你的审查范围以本次给定的「审核关注点」与「阶段目标/要求」为边界——不要把标准扩大到与阶段目标无关的领域,也不要无限穷举手段;只有当某个缺失手段明显可用、成本合理、且直接影响阶段目标达成时,才应视为证据不足。
|
||||||
|
|
||||||
|
判定 pass 前,必须确认:
|
||||||
|
1. 阶段目标(goal)中的每一项显式要求都已完成,且有执行痕迹或你可核实的证据支撑;
|
||||||
|
2. 阶段要求(instructions)中的关键约束(如输出格式、落盘位置、验证方式)已被遵守;
|
||||||
|
3. 没有未解释的失败、报错、跳过项或证据空白;
|
||||||
|
4. 若主执行模型的汇报与执行痕迹矛盾,以执行痕迹为准;痕迹不足时{{ACTIVE_REVIEW_ONLY}}先取证再结论{{/ACTIVE_REVIEW_ONLY}},痕迹不足且无法取证时判 reject 并说明缺什么证据。
|
||||||
|
|
||||||
|
以下情况一律判 reject:
|
||||||
|
- 只有主执行模型口头声明完成,无对应执行痕迹;
|
||||||
|
- 只完成了阶段目标的一部分;
|
||||||
|
- 关键结论没有佐证(如该验证的没验证、该落盘的没落盘);
|
||||||
|
- 执行痕迹显示走了捷径,明显违背阶段要求。
|
||||||
|
|
||||||
|
如果判定 reject,message 必须具体指出缺口,并给出可执行的补救清单:需要补做哪些事、用哪些工具/命令验证、重新审核时要看到什么证据。不要写空泛的"请继续努力"。
|
||||||
|
{{ACTIVE_REVIEW_ONLY}}
|
||||||
|
你可以使用 run_command 执行只读命令(查看文件、跑测试、git diff 等)来核实证据,核实完成后再给结论。
|
||||||
|
{{/ACTIVE_REVIEW_ONLY}}
|
||||||
@ -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 校验 + 响应头)
|
||||||
|
|||||||
@ -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) 工作区已存在活动目标(含压缩后重入、子智能体通知重入)→ 重新注入提示词,
|
||||||
@ -2020,6 +2107,27 @@ async def handle_task_with_sender(
|
|||||||
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:
|
||||||
debug_log(f"已达到最大工具调用次数限制 ({MAX_TOTAL_TOOL_CALLS})")
|
debug_log(f"已达到最大工具调用次数限制 ({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(
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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 常规链。
|
||||||
|
审核在工具内同步 await(handler 支持长阻塞,submit_plan 已验证该模式)。
|
||||||
|
推进成功后同步刷新 messages 里的工作流 system 段(当前位置不滞后)。
|
||||||
|
"""
|
||||||
|
from server import workflow_flow
|
||||||
|
|
||||||
|
args = arguments or {}
|
||||||
|
try:
|
||||||
|
if function_name == "report_workflow_stage":
|
||||||
|
result = await workflow_flow.handle_stage_report(
|
||||||
|
web_terminal=web_terminal,
|
||||||
|
data_dir=workspace.data_dir,
|
||||||
|
sender=sender,
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
summary=str(args.get("summary") or ""),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
result = await workflow_flow.handle_branch_choice(
|
||||||
|
web_terminal=web_terminal,
|
||||||
|
data_dir=workspace.data_dir,
|
||||||
|
sender=sender,
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
target_node_id=str(args.get("target_node_id") or ""),
|
||||||
|
)
|
||||||
|
if isinstance(result, dict) and result.get("success"):
|
||||||
|
workflow_flow.refresh_workflow_system_segment(
|
||||||
|
messages, data_dir=workspace.data_dir, conversation_id=conversation_id
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
# 柔性原则:任何工作流异常不得掐断智能体工作
|
||||||
|
result = {"success": False, "error": f"工作流工具执行异常:{exc}"}
|
||||||
|
return json.dumps(result, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
async def _handle_submit_plan(*, web_terminal, arguments: Dict[str, Any], sender, username: str, conversation_id: Optional[str], tool_call_id: Optional[str]) -> str:
|
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
752
server/workflow_flow.py
Normal 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)
|
||||||
|
记入 history;review 驳回不记录(阶段未完成)。
|
||||||
|
"""
|
||||||
|
kind = node.get("kind")
|
||||||
|
name = str(node.get("name") or node.get("id") or "")
|
||||||
|
|
||||||
|
if kind == "stage":
|
||||||
|
if stage_info:
|
||||||
|
wsm.record_stage_completion(summary=stage_info["summary"], rounds=stage_info["rounds"])
|
||||||
|
wsm.move_to(str(node["id"]), msg_index=_history_len(web_terminal))
|
||||||
|
emit_workflow_progress(wsm=wsm, sender=sender, conversation_id=conversation_id)
|
||||||
|
head = f"阶段「{stage_info['name']}」已记录完成。\n\n" if stage_info else ""
|
||||||
|
return head + _current_node_brief(wsm=wsm, current=node)
|
||||||
|
|
||||||
|
if kind == "end":
|
||||||
|
if stage_info:
|
||||||
|
wsm.record_stage_completion(summary=stage_info["summary"], rounds=stage_info["rounds"])
|
||||||
|
# 先广播「完成态」快照(前端播最后一行落定+「结束」行动画),再摘牌。
|
||||||
|
# 注意顺序不能反:deactivate 后 progress_snapshot 只剩 {"active": False},
|
||||||
|
# 前端会瞬间卸载窗口,完成动画与「结束」行都播不出来。
|
||||||
|
if callable(sender) and conversation_id:
|
||||||
|
snap = wsm.progress_snapshot()
|
||||||
|
snap.update({
|
||||||
|
"status": "completed",
|
||||||
|
"current": None,
|
||||||
|
"next": None,
|
||||||
|
"reviewing": False,
|
||||||
|
"footnote": {"kind": "success", "text": "工作流已完成"},
|
||||||
|
"event": "workflow_completed",
|
||||||
|
"conversation_id": conversation_id,
|
||||||
|
})
|
||||||
|
try:
|
||||||
|
sender("workflow_progress", snap)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
wsm.deactivate(status=STATUS_COMPLETED, reason=REASON_COMPLETED)
|
||||||
|
head = f"阶段「{stage_info['name']}」已记录完成。\n\n" if stage_info else ""
|
||||||
|
return head + f"工作流「{wsm.state.get('workflow_name')}」已到达终点「{name}」,全部完成。请向用户输出总结后结束。"
|
||||||
|
|
||||||
|
if kind == "branch":
|
||||||
|
routes = [r for r in (node.get("next") or []) if isinstance(r, dict) and r.get("target")]
|
||||||
|
if len(routes) <= 1:
|
||||||
|
# 并线器(单出线):自动穿过,不记完成、不停留
|
||||||
|
target = wsm.get_node(routes[0].get("target")) if routes else None
|
||||||
|
if not target:
|
||||||
|
return "流程定义异常:分支节点没有有效的出线。"
|
||||||
|
return await _arrive_at(
|
||||||
|
node=target, wsm=wsm, web_terminal=web_terminal, sender=sender,
|
||||||
|
conversation_id=conversation_id, stage_info=stage_info,
|
||||||
|
)
|
||||||
|
# AI 决策点(多出线):记完成 + 停留等选择
|
||||||
|
if stage_info:
|
||||||
|
wsm.record_stage_completion(summary=stage_info["summary"], rounds=stage_info["rounds"])
|
||||||
|
wsm.move_to(str(node["id"]), msg_index=_history_len(web_terminal))
|
||||||
|
emit_workflow_progress(wsm=wsm, sender=sender, conversation_id=conversation_id)
|
||||||
|
head = f"阶段「{stage_info['name']}」已记录完成。\n\n" if stage_info else ""
|
||||||
|
return head + _current_node_brief(wsm=wsm, current=node)
|
||||||
|
|
||||||
|
if kind == "review":
|
||||||
|
result = await run_stage_review(
|
||||||
|
web_terminal=web_terminal, wsm=wsm, sender=sender, conversation_id=conversation_id,
|
||||||
|
stage_info=stage_info, review_node=node,
|
||||||
|
)
|
||||||
|
wsm.record_review(
|
||||||
|
node_id=str(node.get("id")), name=name,
|
||||||
|
decision=str(result.get("decision") or ""), message=str(result.get("message") or ""),
|
||||||
|
)
|
||||||
|
if result.get("decision") == "pass":
|
||||||
|
nxt = wsm.get_node(node.get("next"))
|
||||||
|
if not nxt:
|
||||||
|
return f"审核「{name}」通过,但通过路由指向不存在的节点。流程定义异常,工作流无法继续。"
|
||||||
|
inner = await _arrive_at(
|
||||||
|
node=nxt, wsm=wsm, web_terminal=web_terminal, sender=sender,
|
||||||
|
conversation_id=conversation_id, stage_info=stage_info,
|
||||||
|
)
|
||||||
|
return f"审核「{name}」通过:{result.get('message')}\n\n{inner}"
|
||||||
|
# 驳回
|
||||||
|
count = wsm.increment_reject(str(node.get("id")))
|
||||||
|
max_rejects = int(node.get("maxRejects") or 3)
|
||||||
|
review_message = str(result.get("message") or "")
|
||||||
|
if count >= max_rejects:
|
||||||
|
wsm.deactivate(status=STATUS_FAILED, reason=REASON_MAX_REJECTS)
|
||||||
|
emit_workflow_progress(
|
||||||
|
wsm=wsm, sender=sender, conversation_id=conversation_id,
|
||||||
|
extra={"event": "workflow_failed", "reason": REASON_MAX_REJECTS},
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
f"审核「{name}」未通过(第 {count} 次,已达连续驳回上限 {max_rejects}):{review_message}\n\n"
|
||||||
|
"工作流已连续驳回超限而终止(failed)。请告知用户审核意见与终止原因;"
|
||||||
|
"如需重新开始,可在调整流程或准备充分后重新激活。"
|
||||||
|
)
|
||||||
|
reject_target = wsm.get_node(node.get("rejectTo"))
|
||||||
|
if not reject_target:
|
||||||
|
return f"审核「{name}」未通过:{review_message}\n\n但驳回路由指向不存在的节点,流程定义异常,工作流无法继续。"
|
||||||
|
wsm.move_to(str(reject_target["id"]), msg_index=_history_len(web_terminal))
|
||||||
|
emit_workflow_progress(
|
||||||
|
wsm=wsm, sender=sender, conversation_id=conversation_id,
|
||||||
|
extra={"event": "workflow_rejected", "review_node": name, "reject_count": count},
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
f"审核「{name}」未通过(第 {count}/{max_rejects} 次):{review_message}\n\n"
|
||||||
|
f"你已回到「{reject_target.get('name')}」。请按整改意见修改后,重新调用 report_workflow_stage 汇报。"
|
||||||
|
)
|
||||||
|
|
||||||
|
return f"流程定义异常:未知节点类型 {kind!r}(节点「{name}」)。"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 审核
|
||||||
|
|
||||||
|
|
||||||
|
def _summarize_tool_args(raw_args: Any) -> str:
|
||||||
|
"""工具参数摘要(取关键字段,截断控长)。"""
|
||||||
|
args = raw_args
|
||||||
|
if isinstance(raw_args, str):
|
||||||
|
try:
|
||||||
|
args = json.loads(raw_args)
|
||||||
|
except Exception:
|
||||||
|
args = {}
|
||||||
|
if not isinstance(args, dict):
|
||||||
|
return ""
|
||||||
|
for key in ("command", "path", "skill_name", "query", "file_path", "url", "task", "summary", "name"):
|
||||||
|
value = args.get(key)
|
||||||
|
if isinstance(value, str) and value.strip():
|
||||||
|
text = " ".join(value.split())
|
||||||
|
return text[:80] + ("…" if len(text) > 80 else "")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def build_stage_trace(web_terminal, start_index: int) -> str:
|
||||||
|
"""消息游标截取本阶段的工具调用时间线(审核 payload 的证据段)。"""
|
||||||
|
try:
|
||||||
|
history = web_terminal.context_manager.conversation_history or []
|
||||||
|
except Exception:
|
||||||
|
history = []
|
||||||
|
slice_ = history[start_index:] if 0 <= start_index < len(history) else []
|
||||||
|
lines: List[str] = []
|
||||||
|
for msg in slice_:
|
||||||
|
if not isinstance(msg, dict):
|
||||||
|
continue
|
||||||
|
role = msg.get("role")
|
||||||
|
if role == "assistant":
|
||||||
|
for call in msg.get("tool_calls") or []:
|
||||||
|
fn = (call or {}).get("function") or {}
|
||||||
|
fname = fn.get("name")
|
||||||
|
if not fname:
|
||||||
|
continue
|
||||||
|
summary = _summarize_tool_args(fn.get("arguments"))
|
||||||
|
lines.append(f"→ {fname}:{summary}" if summary else f"→ {fname}")
|
||||||
|
elif role == "tool":
|
||||||
|
content = " ".join(str(msg.get("content") or "").split())
|
||||||
|
if content:
|
||||||
|
lines.append(f" ↳ {content[:160]}{'…' if len(content) > 160 else ''}")
|
||||||
|
text = "\n".join(lines)
|
||||||
|
if len(text) > _STAGE_TRACE_MAX_CHARS:
|
||||||
|
text = "…(前段略)\n" + text[-_STAGE_TRACE_MAX_CHARS:]
|
||||||
|
return text or "(本阶段暂无工具调用记录)"
|
||||||
|
|
||||||
|
|
||||||
|
def build_review_payload(
|
||||||
|
*,
|
||||||
|
web_terminal,
|
||||||
|
wsm: WorkflowStateManager,
|
||||||
|
stage_info: Dict[str, Any],
|
||||||
|
review_node: Dict[str, Any],
|
||||||
|
) -> str:
|
||||||
|
"""审核 payload:工作流信息 + 审核关注点 + 阶段目标要求 + 执行痕迹 + 汇报 + 历史驳回。"""
|
||||||
|
definition = wsm.load_definition() or {}
|
||||||
|
stage_node = wsm.get_node(stage_info.get("node_id")) or {}
|
||||||
|
lines: List[str] = [
|
||||||
|
f"【工作流】{definition.get('name')}:{definition.get('description')}",
|
||||||
|
"",
|
||||||
|
f"【本次审核把关】{review_node.get('name')}",
|
||||||
|
f"审核关注点:{review_node.get('prompt') or '阶段产出是否达到进入下一阶段的门槛'}",
|
||||||
|
"",
|
||||||
|
f"【被审核阶段】{stage_info.get('name')}",
|
||||||
|
f"阶段目标:{stage_node.get('goal') or '(未填写)'}",
|
||||||
|
]
|
||||||
|
instructions = str(stage_node.get("instructions") or "").strip()
|
||||||
|
if instructions:
|
||||||
|
lines.append(f"阶段要求:{instructions}")
|
||||||
|
lines += [
|
||||||
|
"",
|
||||||
|
"【阶段执行痕迹】(本阶段内的工具调用时间线)",
|
||||||
|
build_stage_trace(web_terminal, wsm.get_stage_start_msg_index()),
|
||||||
|
"",
|
||||||
|
"【主执行模型阶段汇报】",
|
||||||
|
stage_info.get("summary") or "(无)",
|
||||||
|
]
|
||||||
|
rejects = [
|
||||||
|
h for h in (wsm.state.get("history") or [])
|
||||||
|
if isinstance(h, dict)
|
||||||
|
and h.get("kind") == "review"
|
||||||
|
and h.get("node_id") == review_node.get("id")
|
||||||
|
and h.get("decision") == "reject"
|
||||||
|
]
|
||||||
|
if rejects:
|
||||||
|
lines.append("")
|
||||||
|
lines.append("【历史审核意见】")
|
||||||
|
for idx, item in enumerate(rejects, start=1):
|
||||||
|
lines.append(f"第 {idx} 次驳回:{item.get('message') or ''}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
async def run_stage_review(
|
||||||
|
*,
|
||||||
|
web_terminal,
|
||||||
|
wsm: WorkflowStateManager,
|
||||||
|
sender,
|
||||||
|
conversation_id: str,
|
||||||
|
stage_info: Dict[str, Any],
|
||||||
|
review_node: Dict[str, Any],
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""调审核智能体。异常兜底 = reject(定稿:视为驳回 + 请告知用户)。"""
|
||||||
|
review_name = str(review_node.get("name") or "")
|
||||||
|
if callable(sender):
|
||||||
|
try:
|
||||||
|
sender(
|
||||||
|
"workflow_review_progress",
|
||||||
|
{"conversation_id": conversation_id, "progress": {"stage": "start", "message": f"审核「{review_name}」开始"}},
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
payload = build_review_payload(
|
||||||
|
web_terminal=web_terminal, wsm=wsm, stage_info=stage_info, review_node=review_node,
|
||||||
|
)
|
||||||
|
definition = wsm.load_definition() or {}
|
||||||
|
review_mode = definition.get("reviewMode") or "active"
|
||||||
|
|
||||||
|
def _progress(progress: Dict[str, Any]) -> None:
|
||||||
|
if callable(sender):
|
||||||
|
try:
|
||||||
|
sender("workflow_review_progress", {"conversation_id": conversation_id, "progress": progress})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
agent = WorkflowReviewAgent(web_terminal=web_terminal)
|
||||||
|
result = await agent.review(payload_text=payload, review_mode=review_mode, progress_cb=_progress)
|
||||||
|
except Exception as exc:
|
||||||
|
result = {
|
||||||
|
"decision": "reject",
|
||||||
|
"message": (
|
||||||
|
f"审核智能体执行异常({exc})。本次按驳回处理:请告知用户审核服务可能异常;"
|
||||||
|
"若属偶发,可稍后重新汇报本阶段。"
|
||||||
|
),
|
||||||
|
"source": "workflow_review_agent",
|
||||||
|
}
|
||||||
|
if not isinstance(result, dict) or result.get("decision") not in ("pass", "reject"):
|
||||||
|
result = {
|
||||||
|
"decision": "reject",
|
||||||
|
"message": "审核未产出有效结论。本次按驳回处理:请告知用户审核服务可能异常;若属偶发,可稍后重新汇报本阶段。",
|
||||||
|
"source": "workflow_review_agent",
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 退出 / 通知文本
|
||||||
|
|
||||||
|
|
||||||
|
def deactivate_workflow(*, data_dir, conversation_id: str, reason: str) -> Dict[str, Any]:
|
||||||
|
"""模型自主退出(deactivate_workflow 工具):摘牌,工具返回闭环,不发 user 通知。"""
|
||||||
|
wsm = get_active_manager(data_dir, conversation_id)
|
||||||
|
if wsm is None:
|
||||||
|
return {"success": False, "error": "当前对话没有激活的工作流。"}
|
||||||
|
name = str(wsm.state.get("workflow_name") or "")
|
||||||
|
wsm.deactivate(status=STATUS_STOPPED, reason=REASON_MODEL)
|
||||||
|
note = str(reason or "").strip()
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": f"工作流「{name}」已退出({note or '模型自主退出'})。工作流状态已摘牌,你可以继续自由工作。",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def deactivate_workflow_by_user(*, data_dir, conversation_id: str) -> Dict[str, Any]:
|
||||||
|
"""用户 slash 退出(REST):摘牌 + 柔性通知入池(忙时工具循环消费 / 闲时 REST 直发)。"""
|
||||||
|
wsm = get_active_manager(data_dir, conversation_id)
|
||||||
|
if wsm is None:
|
||||||
|
return {"success": False, "error": "当前对话没有激活的工作流。"}
|
||||||
|
name = str(wsm.state.get("workflow_name") or "")
|
||||||
|
wsm.deactivate(status=STATUS_STOPPED, reason=REASON_USER)
|
||||||
|
wsm.push_notice(
|
||||||
|
notice_type="deactivated_by_user",
|
||||||
|
message=(
|
||||||
|
f"用户已通过快捷操作退出工作流「{name}」。工作流已摘牌,无需继续按流程推进;"
|
||||||
|
"你可以继续自由工作。若用户之后要求恢复,可重新激活。"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return {"success": True, "workflow_name": name}
|
||||||
|
|
||||||
|
|
||||||
|
def build_round_limit_notice(*, data_dir, conversation_id: str) -> Optional[str]:
|
||||||
|
"""max_stage_rounds 撞限通知文本(主循环层注入)。未撞限返回 None。"""
|
||||||
|
wsm = get_active_manager(data_dir, conversation_id)
|
||||||
|
if wsm is None or wsm.round_limit_notified():
|
||||||
|
return None
|
||||||
|
definition = wsm.load_definition() or {}
|
||||||
|
max_rounds = int(definition.get("maxStageRounds") or 20)
|
||||||
|
rounds = wsm.get_stage_rounds()
|
||||||
|
if rounds < max_rounds:
|
||||||
|
return None
|
||||||
|
wsm.mark_round_limit_notified()
|
||||||
|
current = wsm.get_node(wsm.get_current_node_id()) or {}
|
||||||
|
return (
|
||||||
|
f"工作流「{definition.get('name')}」的当前步骤「{current.get('name')}」已进行 {rounds} 轮,"
|
||||||
|
f"达到单步轮数上限({max_rounds})。请立刻停下当前工作,告知用户已超过 {max_rounds} 轮,"
|
||||||
|
"并询问是否还要继续。(工作流仍在进行中,等待用户决定;用户回复后可继续推进或退出。)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 状态查询 / 进度事件
|
||||||
|
|
||||||
|
|
||||||
|
def build_status_text(*, data_dir, conversation_id: str) -> str:
|
||||||
|
"""get_workflow_status 工具返回文本。"""
|
||||||
|
wsm = get_active_manager(data_dir, conversation_id)
|
||||||
|
if wsm is None:
|
||||||
|
return "当前对话没有激活的工作流。"
|
||||||
|
definition = wsm.load_definition() or {}
|
||||||
|
current = wsm.get_node(wsm.get_current_node_id()) or {}
|
||||||
|
lines: List[str] = [
|
||||||
|
f"【工作流状态】{definition.get('name')}:{definition.get('description')}",
|
||||||
|
f"已进行时长:{int(time.time() - float(wsm.state.get('started_at') or time.time()))} 秒",
|
||||||
|
"",
|
||||||
|
"【已完成的步骤】",
|
||||||
|
]
|
||||||
|
stage_records = [h for h in (wsm.state.get("history") or []) if isinstance(h, dict) and h.get("kind") == "stage"]
|
||||||
|
if stage_records:
|
||||||
|
for item in stage_records:
|
||||||
|
lines.append(f"- {item.get('name')}({item.get('rounds') or 0} 轮):{(item.get('summary') or '')[:80]}")
|
||||||
|
else:
|
||||||
|
lines.append("(暂无)")
|
||||||
|
review_records = [h for h in (wsm.state.get("history") or []) if isinstance(h, dict) and h.get("kind") == "review"]
|
||||||
|
if review_records:
|
||||||
|
lines.append("")
|
||||||
|
lines.append("【审核记录】")
|
||||||
|
for item in review_records:
|
||||||
|
label = "通过" if item.get("decision") == "pass" else "驳回"
|
||||||
|
lines.append(f"- {item.get('name')}:{label} — {(item.get('message') or '')[:80]}")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(_current_node_brief(wsm=wsm, current=current))
|
||||||
|
lines.append(f"当前步骤已进行轮数:{wsm.get_stage_rounds()}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def emit_workflow_progress(
|
||||||
|
*,
|
||||||
|
wsm: WorkflowStateManager,
|
||||||
|
sender,
|
||||||
|
conversation_id: Optional[str],
|
||||||
|
extra: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> None:
|
||||||
|
"""广播工作流进度快照(sender → session_data → REST 轮询透传,对齐 goal 链路)。"""
|
||||||
|
if not callable(sender) or not conversation_id:
|
||||||
|
return
|
||||||
|
snap = wsm.progress_snapshot()
|
||||||
|
snap["conversation_id"] = conversation_id
|
||||||
|
if extra:
|
||||||
|
snap.update(extra)
|
||||||
|
try:
|
||||||
|
sender("workflow_progress", snap)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
303
server/workflow_runtime_api.py
Normal file
303
server/workflow_runtime_api.py
Normal 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
|
||||||
@ -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"
|
||||||
|
|||||||
@ -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 || {};
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
44
static/src/app/methods/taskPolling/workflow.ts
Normal file
44
static/src/app/methods/taskPolling/workflow.ts
Normal 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();
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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() {
|
||||||
|
|||||||
@ -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 提升到 store:App.vue 的展开收起按钮共用同一判定
|
// hasContent / userCollapsed 提升到 store:App.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(
|
|||||||
// 只关瞬态面板;列表数据保留,由 bootstrap(edited_files)与 fetchTodoList 回填覆盖,
|
// 只关瞬态面板;列表数据保留,由 bootstrap(edited_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 场景)之前捕获同步序号,
|
||||||
|
|||||||
416
static/src/components/chat/quickdock/WorkflowWindow.vue
Normal file
416
static/src/components/chat/quickdock/WorkflowWindow.vue
Normal 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>
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@ -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 || '激活工作流失败');
|
||||||
|
}
|
||||||
|
// 快照随响应返回:立即写入 store(socketio 广播在轮询架构下不可靠,
|
||||||
|
// 任务事件流的首个进度事件要等模型首次汇报才发出)
|
||||||
|
if (data?.snapshot) {
|
||||||
|
workflowStore.setWorkflow(data.snapshot, true);
|
||||||
|
}
|
||||||
|
useUiStore().pushToast({ title: `工作流「${name}」已激活`, type: 'success' });
|
||||||
|
const newCid = String(data?.conversation_id || '').trim();
|
||||||
|
if (newCid && newCid !== conversationId) {
|
||||||
|
emit('workflow-activated', newCid);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
useUiStore().pushToast({ title: '激活工作流失败', message: String(error?.message || error || ''), type: 'error' });
|
||||||
|
} finally {
|
||||||
|
workflowActionPending.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deactivateWorkflow = async () => {
|
||||||
|
if (workflowActionPending.value) return;
|
||||||
|
const conversationId = String(props.currentConversationId || '').trim();
|
||||||
|
if (!conversationId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
workflowActionPending.value = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/workflow/deactivate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ conversation_id: conversationId })
|
||||||
|
});
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok || !data?.success) {
|
||||||
|
throw new Error(data?.error || '退出工作流失败');
|
||||||
|
}
|
||||||
|
// 摘牌成功:写 live=true 的空快照,触发窗口退出动画(与出现对称),而非瞬间卸载
|
||||||
|
workflowStore.setWorkflow(null, true);
|
||||||
|
useUiStore().pushToast({ title: `已退出工作流「${data?.workflow_name || ''}」`, type: 'success' });
|
||||||
|
} catch (error: any) {
|
||||||
|
useUiStore().pushToast({ title: '退出工作流失败', message: String(error?.message || error || ''), type: 'error' });
|
||||||
|
} finally {
|
||||||
|
workflowActionPending.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 工作流子菜单项:激活中首项为「退出当前工作流」,其后为可激活列表。 */
|
||||||
|
const workflowSlashMenuItems = computed<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');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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' });
|
||||||
|
|||||||
@ -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 =
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
106
static/src/stores/workflow.ts
Normal file
106
static/src/stores/workflow.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user